Python-和-Dask-数据科学-全-
Python 和 Dask 数据科学(全)
原文:Data Science with Python and Dask
译者:飞龙
第一部分
可扩展计算的基本构建块
本书这一部分涵盖了可扩展计算的一些基本概念,为你理解 Dask 的独特之处及其工作原理提供良好的基础。
在第一章中,你将学习什么是有向无环图(DAG)以及为什么它在扩展工作负载到许多不同的工作者时很有用。
*第二章解释了 Dask 如何使用 DAGs 作为抽象,使你能够分析大型数据集,并利用可扩展性和并行性,无论你在笔记本电脑上还是在成千上万的机器集群上运行代码。
完成第一部分后,你将基本了解 Dask 的内部结构,并准备好通过实际数据集获得一些实践经验。* *# 1
可扩展计算的重要性
本章内容涵盖
-
展示是什么使 Dask 成为可扩展计算领域的杰出框架
-
以意大利面食谱作为具体实例,展示如何阅读和解释有向无环图(DAGs)
-
讨论为什么 DAGs 对分布式工作负载很有用,以及 Dask 的任务调度器如何使用 DAGs 来组合、控制和监控计算
-
介绍配套数据集
欢迎来到使用 Python 和 Dask 进行数据科学!既然你决定拿起这本书,毫无疑问你对数据科学和机器学习感兴趣——也许你甚至是一名实践中的数据科学家、分析师或机器学习工程师。然而,我怀疑你目前可能面临一个重大的挑战,或者你在职业生涯的某个阶段遇到过。我当然是在谈论与大型数据集一起工作时出现的臭名昭著的挑战。症状很容易识别:计算时间漫长——即使是简单的计算——代码不稳定,工作流程难以管理。但不要绝望!随着收集和存储大量数据的成本和努力显著下降,这些挑战已经变得司空见惯。作为回应,计算机科学界投入了大量努力,创建更好的、更易于使用的编程框架,以降低处理大量数据集的复杂性。虽然许多不同的技术和框架旨在解决这些问题,但 Dask 作为其中之一,既强大又灵活。本书旨在通过提供你分析和使用 Dask 分析大型数据集所需的工具和技术,将你的数据科学技能提升到下一个层次。
尽管这本书的大部分内容都集中在数据科学家或数据工程师在大多数项目中会遇到的实际任务的手动示例,但本章将涵盖理解 Dask“内部工作原理”的一些基本知识。首先,我们将探讨为什么在数据科学工具箱中拥有像 Dask 这样的工具是必要的,以及它独特之处在哪里;然后,我们将介绍有向无环图,这是 Dask 广泛使用来控制代码并行执行的概念。有了这些知识,当你要求 Dask 处理大数据集时,你应该对它的工作原理有更好的理解;这些知识将伴随你在 Dask 之旅中继续前进,我们将在后面的章节中回顾这些知识,当我们介绍如何在云中构建自己的集群时。考虑到这一点,我们将关注 Dask 的独特之处,以及为什么它是数据科学中的一个宝贵工具。
1.1 为什么选择 Dask?
对于许多现代组织来说,数据科学变革力量的承诺具有普遍的吸引力——而且理由充分。在合适的人手中,有效的数据科学团队能够将零散的数据转化为真正的竞争优势。做出更好的决策、优化业务流程和发现战略盲点都被视为投资数据科学能力的益处。然而,我们今天所说的“数据科学”并不是一个全新的概念。在过去几十年里,全球各地的组织都在尝试找到更好的方法来做出战略和战术决策。使用诸如“决策支持”、“商业智能”、“分析”或简单的“运筹学”等名称,每个领域的目标都是相同的:跟踪正在发生的事情并做出更明智的决策。然而,近年来发生的变化是,学习和应用数据科学的障碍已经显著降低。数据科学不再局限于运筹学期刊或大型咨询集团类似学术的研究和开发部门。将数据科学带给大众的关键推动力是 Python 编程语言的日益流行及其强大的 Python 开放数据科学堆栈库集。这些库包括 NumPy、SciPy、Pandas 和 scikit-learn,已成为行业标准工具,拥有庞大的开发者社区和丰富的学习资料。其他历史上被用于此类工作的语言,如 FORTRAN、MATLAB 和 Octave,学习起来更困难,且社区支持远不如 Python 及其开放数据科学堆栈。
在数据科学可访问性不断发展的同时,计算机的运算能力也在不断增强。这使得生产、收集、存储和处理比以前多得多的数据变得容易,而且价格持续下降。但如今这股数据洪流让许多组织开始质疑收集和存储所有这些数据的价值——这是理所当然的!原始数据本身没有内在价值;必须对其进行清理、审查和解释,才能从中提取可操作的信息。显然,这正是你——数据科学家——发挥作用的地方。与 Python 开放数据科学栈一起工作,数据科学家通常会转向 Pandas 等工具进行数据清理和探索性数据分析,使用 SciPy 和 NumPy 对数据进行统计分析,以及使用 scikit-learn 构建预测模型。这对于相对较小且能够舒适地适应 RAM 的大小数据集来说效果很好。但由于数据收集和存储成本的降低,数据科学家越来越多地处理涉及分析巨大数据集的问题。当处理超过一定大小的数据集时,这些工具的可行性存在上限。一旦超过这个阈值,章节开头描述的问题就开始出现。但这个阈值在哪里?为了避免使用定义不明确且经常被滥用的术语“大数据”,本书将使用三层定义来描述不同大小的数据集及其带来的挑战。表 1.1 描述了本书中将使用的不同标准来定义“小型数据集”、“中型数据集”和“大型数据集”。
表 1.1 数据大小的分层定义
| 数据集类型 | 大小范围 | 是否适应 RAM | 是否适应本地磁盘 |
|---|---|---|---|
| 小型数据集 | 小于 2–4 GB | 是 | 是 |
| 中型数据集 | 小于 2 TB | 否 | 是 |
| 大型数据集 | 大于 2 TB | 否 | 否 |
小型数据集是指可以舒适地适应 RAM 的数据集,留有足够的内存进行操作和转换。它们的大小通常不超过 2–4 GB,复杂的操作如排序和聚合可以在不进行分页的情况下完成。分页,或溢出到磁盘,使用计算机的持久存储(如硬盘或固态驱动器)作为存储中间结果的额外空间,在处理过程中可能会大大减慢处理速度,因为持久存储在快速数据访问方面不如 RAM 效率高。这些数据集在学习数据科学时经常遇到,Pandas、NumPy 和 scikit-learn 等工具是处理这些工作的最佳工具。实际上,将这些更复杂的工具应用于这些问题不仅过度,而且可能适得其反,因为它们增加了不必要的复杂性和管理开销,这可能会降低性能。
中等数据集是指不能完全保持在 RAM 中,但可以舒适地存放在单个计算机的持久存储中的数据集。这些数据集的大小通常在 10 GB 到 2 TB 之间。虽然可以使用相同的工具集来分析小数据集和中等数据集,但由于这些工具必须使用分页来避免内存不足错误,因此会带来显著的性能损失。这些数据集也足够大,引入并行处理以减少处理时间是有意义的。而不是将执行限制在单个 CPU 核心上,将工作分配到所有可用的 CPU 核心可以显著加快计算速度。然而,Python 并非设计为在多核系统上共享进程间的任务变得特别容易。因此,在 Pandas 中利用并行性可能会很困难。
大数据集是指既不能完全装入 RAM,也不能装入单个计算机的持久存储的数据集。这些数据集通常大小超过 2 TB,根据问题的不同,可以达到 PB 甚至更高。Pandas、NumPy 和 scikit-learn 完全不适用于这种规模的数据集,因为它们并非天生就是为了在分布式数据集上操作而构建的。
自然地,这些阈值之间的界限有些模糊,并且取决于你的计算机有多强大。其重要性更多在于不同数量级的差异,而不是硬性的大小限制。例如,在非常强大的计算机上,小数据可能只有 10s GB 的数量级,而不是 TB 的数量级。中等数据可能达到 10s TB 的数量级,而不是 PB 的数量级。无论如何,最重要的启示是,当你的数据集正在推动我们对小数据定义的极限时,寻找替代分析工具是有优势的(并且通常是必需的)。然而,选择合适的工具同样具有挑战性。很多时候,这会导致数据科学家陷入评估不熟悉的技术、用不同语言重写代码,以及一般性地减缓他们正在工作的项目。
Dask 是由 Matthew Rocklin 在 2014 年底推出的,旨在为 Python Open Data Science Stack 带来原生可扩展性,并克服其单机限制。随着时间的推移,该项目已经发展成为 Python 开发者可用的最佳可扩展计算框架之一。Dask 由几个不同的组件和 API 组成,可以分为三个层次:调度器、低级 API 和高级 API。这些组件的概述可以在图 1.1 中看到。

图 1.1 构成 Dask 的组件和层次结构
使 Dask 非常强大的原因是这些组件和层是如何相互构建的。核心是任务调度器,它协调并监控跨 CPU 核心和机器的计算执行。这些计算在代码中表现为 Dask 延迟对象或 Dask 未来对象(关键区别在于前者是惰性评估——意味着仅在需要值时才进行评估,而后者是即时评估——意味着无论是否需要立即评估,都会实时进行评估)。Dask 的高级 API 为延迟和未来对象提供了一层抽象。对这些高级对象的操作会导致许多由任务调度器管理的并行低级操作,这为用户提供了无缝的体验。正因为这种设计,Dask 带来了四个关键优势:
-
Dask 完全用 Python 实现,并原生扩展了 NumPy、Pandas 和 scikit-learn。
-
Dask 可以有效地用于处理单台机器上的中等数据集以及集群上的大数据集。
-
Dask 可以作为一个通用框架,用于并行化大多数 Python 对象。
-
Dask 的配置和维护开销非常低。
Dask 与其他竞争者最大的不同之处在于,它是完全用 Python 编写和实现的,并且其集合 API 能够原生地扩展 NumPy、Pandas 和 scikit-learn。这并不意味着 Dask 仅仅反映了 NumPy 和 Pandas 用户会感到熟悉的常见操作和模式;这意味着 Dask 所使用的底层对象与每个相应库中的对应对象是相匹配的。一个 Dask DataFrame 由许多较小的 Pandas DataFrame 组成,一个 Dask Array 由许多较小的 NumPy Array 组成,依此类推。每个较小的底层对象,称为 chunks 或 partitions,可以在集群内的机器之间传输,或者排队并逐个处理本地。我们将在稍后更深入地介绍这个过程,但将中等和大型数据集拆分成更小的部分并管理这些部分上函数的并行执行,是 Dask 能够优雅地处理其他情况下难以处理的大型数据集的基本方法。使用这些对象作为 Dask 分布式集合的基础的实际结果是,许多 Pandas 和 NumPy 用户已经熟悉的函数、属性和方法在 Dask 中的语法是等效的。这种设计选择使得经验丰富的 Pandas、NumPy 和 scikit-learn 用户从处理小型数据集过渡到中等和大型数据集变得非常容易。过渡数据科学家不必学习新的语法,可以专注于学习可扩展计算最重要的方面:编写健壮、性能良好且针对并行性优化的代码。幸运的是,Dask 为常见用例做了很多繁重的工作,但在这本书中,我们将检查一些最佳实践和陷阱,这将使您能够充分利用 Dask。
接下来,Dask 在处理单个机器上的中等数据集时同样有用,就像在集群上处理大型数据集一样。调整 Dask 的规模上下并不复杂。这使得用户能够在本地机器上原型化任务,并在需要时无缝地将这些任务提交到集群。所有这些都可以在不修改现有代码或编写额外的代码来处理集群特定问题(如资源管理、恢复和数据移动)的情况下完成。这也为用户提供了很大的灵活性,以选择最佳的代码部署和运行方式。通常,使用集群来处理中等数据集是完全不必要的,有时由于协调多台机器协同工作所带来的开销,可能会更慢。Dask 优化了其内存占用,因此即使在相对低功耗的机器上也能优雅地处理中等数据集。这种透明的可扩展性归功于 Dask 设计精良的内置任务调度器。当 Dask 在单个机器上运行时,可以使用本地任务调度器;而对于本地执行和跨集群执行,可以使用分布式任务调度器。Dask 还支持与流行的集群资源管理器(如 YARN、Mesos 和 Kubernetes)接口,允许您使用分布式任务调度器使用现有的集群。配置任务调度器和使用资源管理器将 Dask 部署到任何数量的系统只需付出最小的努力。在整个书中,我们将探讨在不同配置下运行 Dask:使用本地任务调度器在本地运行,以及使用 Docker 和亚马逊弹性容器服务在云中集群化运行。
Dask 最不寻常的方面之一是其固有的扩展大多数 Python 对象的能力。Dask 的低级 API,Dask Delayed 和 Dask Futures,是扩展 Dask Array 中使用的 NumPy 数组、Dask DataFrame 中使用的 Pandas DataFrame 以及 Dask Bag 中使用的 Python 列的共同基础。Dask 的低级 API 可以直接使用,将 Dask 的所有可扩展性、容错性和远程执行能力应用于任何问题,而无需从头开始构建分布式应用程序。
最后,Dask 非常轻量级,易于设置、拆除和维护。所有依赖项都可以使用 pip 或 conda 软件包管理器安装。使用 Docker 构建和部署集群工作器镜像非常容易,我们将在本书的后续部分进行操作,而 Dask 默认配置要求很少。正因为如此,Dask 不仅在处理重复性工作方面表现良好,而且也是构建概念验证和执行临时数据分析的出色工具。
数据科学家首次发现 Dask 时心中常见的疑问是它与 Apache Spark 等其他表面上类似的技术如何比较。Spark 确实已经成为分析大数据集的非常流行的框架,并在这一领域表现出色。然而,尽管 Spark 支持包括 Python 在内的几种不同的语言,但其作为 Java 库的遗产可能会给缺乏 Java 专业知识的使用者带来一些挑战。Spark 于 2010 年作为 Apache Hadoop 的 MapReduce 处理引擎的内存替代品而推出,其核心功能高度依赖于 Java 虚拟机(JVM)。Python 的支持在几个发布周期后出现,名为 PySpark 的 API,但这个 API 仅仅允许您使用 Python 与 Spark 集群交互。任何提交给 Spark 的 Python 代码都必须通过 Py4J 库在 JVM 中传递。这可能会使 PySpark 代码的微调和调试变得相当困难,因为一些执行发生在 Python 上下文之外。
PySpark 用户可能会最终确定他们无论如何都需要将代码库迁移到 Scala 或 Java,以充分利用 Spark。Spark 的新功能和增强首先添加到 Java 和 Scala API 中,通常需要几个发布周期才能将此功能暴露给 PySpark。此外,PySpark 的学习曲线并不简单。其 DataFrame API 虽然在概念上与 Pandas 相似,但在语法和结构上有很大的差异。这意味着新的 PySpark 用户必须重新学习如何“Spark 方式”做事,而不是从现有的使用 Pandas 和 scikit-learn 的经验和知识中汲取。Spark 高度优化以应用于集合对象上的计算,例如向数组中的每个元素添加一个常数或计算数组的总和。但这种优化是以灵活性为代价的。Spark 无法处理无法表示为集合上的映射或归约类型操作的代码。因此,您不能像使用 Dask 那样优雅地扩展自定义算法。Spark 还以其设置和配置的困难而闻名,需要许多依赖项,如 Apache ZooKeeper 和 Apache Ambari,这些依赖项本身也可能难以安装和配置。对于使用 Spark 和 Hadoop 的组织来说,拥有专门的 IT 资源并不罕见,这些资源的唯一责任是配置、监控和维护集群。
这种比较并非有意对 Spark 不公平。Spark 在其擅长的领域表现得非常出色,并且当然是一个分析处理大数据集的有效解决方案。然而,Dask 的学习曲线短、灵活性高以及熟悉的 API 使其成为有 Python Open Data Science Stack 背景的数据科学家更吸引人的解决方案。
我希望到现在你已经开始看到为什么 Dask 是一个如此强大且多功能的工具集。而且,如果我的早期怀疑是正确的——你决定拿起这本书是因为你目前正在处理大量数据集——我希望你感到既鼓励尝试 Dask,又兴奋地想了解更多关于如何使用 Dask 分析真实世界数据集的信息。然而,在我们查看一些 Dask 代码之前,回顾几个核心概念将有助于你理解 Dask 的任务调度器如何“分而治之”计算。这对于你来说尤其有帮助,因为你对分布式计算的新手来说,理解任务调度的机制将给你一个很好的想法,当计算执行时会发生什么,以及潜在瓶颈可能在哪里。
1.2 使用 DAG 烹饪
Dask 的任务调度器使用有向无环图(或简称 DAG)的概念来组合、控制和表达计算。DAG 来自一个更大的数学领域,称为图论。与名字可能让你预期的不同,图论与饼图或条形图无关。相反,图论将图描述为具有相互关系的一组对象的表示。虽然这个定义相当模糊和抽象,但它意味着图可以用来表示非常广泛的信息。有向无环图有一些特殊的属性,使它们的定义稍微狭窄一些。但与其继续在抽象上谈论图,不如让我们看看使用 DAG 来模拟一个真实过程的例子。
当我不忙于写作、教学或分析数据时,我喜欢烹饪。对我来说,在这个世界上很少有东西能与一碗热腾腾的意大利面相比。在我的所有时间最喜欢的意大利面菜肴中,bucatini all’Amatriciana 位居榜首。如果你喜欢意大利菜,你会喜欢厚实的 bucatini 面条的口感,Pecorino Romano 奶酪的鲜明咸味,以及用猪脸肉和洋葱烹制的番茄酱的辛辣丰富。但我在这里并不是要让你放下这本书就跑到厨房去。相反,我想解释如何使用有向无环图来模拟制作美味的 bucatini all’Amatriciana。首先,让我们快速概述一下食谱,如图 1.2 所示。

图 1.2 我最喜欢的 bucatini all’Amatriciana 食谱
烹饪一道菜谱包括遵循一系列连续的步骤,其中生食材料被转化为中间状态,直到所有食材最终结合成一道完整的菜肴。例如,当你切洋葱时,你从一个完整的洋葱开始,将其切成片,然后你剩下一些切好的洋葱。在软件工程术语中,我们会将切洋葱的过程描述为一个函数。
切洋葱虽然很重要,但只是整个食谱中很小的一部分。要完成整个食谱,我们必须定义更多步骤(或函数)。在图中,每个这些函数被称为一个节点。由于大多数食谱步骤遵循逻辑顺序(例如,你不会在煮面之前上菜),每个节点可以承担依赖关系,这意味着在开始下一个节点的操作之前,必须完成先前的步骤(或步骤)。食谱的另一个步骤是将切好的洋葱在橄榄油中炒熟,这由另一个节点表示。当然,如果你还没有切洋葱,是不可能炒洋葱的!因为炒洋葱直接依赖于并关联到切洋葱,这两个节点通过一条线连接。

图 1.3 显示具有依赖关系的节点图
图 1.3 表示了到目前为止描述的过程的图。注意,炒食材节点有三个直接依赖项:洋葱和大蒜必须切好,猪脸肉必须炒熟,这三样食材才能一起炒。相反,切洋葱、切碎大蒜和加热橄榄油节点没有任何依赖项。完成这些步骤的顺序并不重要,但你必须完成所有这些步骤才能进行最后的炒制步骤。此外,注意连接节点的线条的端点有箭头。这意味着只有一种可能的遍历图的方式。在洋葱切好之前炒洋葱,或者在没有热油锅的情况下尝试炒洋葱是没有意义的。这就是所谓的有向无环图:从没有依赖关系的节点到单个终端节点存在一种逻辑的、单向的遍历。
关于图 1.3 中的图形,你可能还会注意到没有线条将后续节点连接回早期节点。一旦一个节点完成,它就永远不会重复或再次访问。这就是使图形成为无环图的原因。如果图形包含反馈循环或某种连续过程,它将是一个循环图。当然,这不适合表示烹饪,因为食谱有有限步骤,有有限状态(完成或未完成),并且可以确定地解决到完成状态,除非发生厨房灾难。图 1.4 展示了循环图可能的样子。

图 1.4 展示了一个具有无限反馈循环的循环图示例
从编程的角度来看,这听起来可能像是有向无环图不允许循环操作。但这并不一定是这样:可以通过复制要重复的节点并按顺序连接它们来从确定性循环(如for循环)构建有向无环图。在图 1.3 中,guanciale 是在两个不同的步骤中煎炒的——首先单独煎炒,然后与洋葱一起煎炒。如果需要煎炒的食材数量是不确定的,那么这个过程就不能表示为无环图。
关于图 1.3 中的图形,需要注意的最后一件事是它采用了一种称为传递闭包的特殊形式。这意味着任何表达传递依赖的线条都被消除了。传递依赖简单来说就是通过完成另一个节点间接满足的依赖。图 1.5 展示了没有传递闭包的图 1.3 的重绘。

图 1.5 展示了没有传递闭包的图 1.3 中的图形。
注意到在包含操作“加热橄榄油”和“煎炒食材(8 分钟)”的节点之间画了一条线。加热橄榄油是煎炒洋葱、大蒜和 guanciale 的传递依赖,因为 guanciale 必须在加入洋葱和大蒜之前单独煎炒。为了煎炒 guanciale,你必须先加热装有橄榄油的平底锅,所以当你准备好煎炒所有三种食材时,你已经有了一个热的平底锅和油——依赖关系已经满足!
图 1.6 表示了完整菜谱的完整有向无环图。如图所示,该图完全代表了从开始到结束的过程。你可以从任何红色节点(本书印刷版的浅灰色)开始,因为它们没有依赖关系,最终你会到达标记为“Buon appetito!”的终端节点。在查看这个图时,你可能很容易发现一些瓶颈,并可能重新排列一些节点以产生更优或更节省时间的准备菜肴的方法。例如,如果意大利面的水需要 20 分钟才能达到翻滚的沸腾,也许你可以画一个只有一个起始节点——把水烧开——的图。这样,你就不必在准备完其他菜肴之后再等待水加热了。这些都是优化示例,无论是智能任务调度器还是你,作为工作负载的设计者,都可能提出这些优化。现在,你已经有了有向无环图如何工作的基础理解,你应该能够阅读和理解任何任意的图形——从煮意大利面到在大数据集上计算描述性统计。接下来,我们将探讨为什么 DAGs 对于可扩展计算如此有用。

图 1.6 bucatini all’Amatriciana 食谱的完整有向无环图表示
1.3 向外扩展、并发性和恢复
到目前为止,我们烹饪 bucatini all’Amatriciana 的例子假设你是厨房中唯一的厨师。如果你只是为家人做饭或者与朋友的小聚,这可能没问题,但如果你需要在曼哈顿中城繁忙的晚餐服务中烹饪数百份菜肴,你可能会很快达到自己能力的极限。现在,是时候寻找一些帮助了!
首先,你必须决定你将如何处理资源问题:你应该升级你的设备以帮助你在厨房中更有效率,还是应该雇佣更多的厨师来帮助分担工作量?在计算中,这两种方法分别被称为向上扩展和向外扩展。就像在我们假设的厨房中一样,这两种方法都不像听起来那么简单。在第 1.3.1 节中,我将讨论向上扩展解决方案的限制以及向外扩展解决方案如何克服这些限制。由于 Dask 的关键用例是向外扩展复杂问题,我们将假设我们假设的厨房的最佳行动方案是雇佣更多工人并扩展。基于这个假设,理解与在许多不同工人之间协调复杂任务相关的挑战将非常重要。我将在第 1.3.2 节中讨论工人如何共享资源,在第 1.3.3 节中讨论如何处理工人故障。
1.3.1 向上扩展与向外扩展

图 1.7 扩大规模是用更大/更快/更高效的设备替换现有设备,而扩展则是将工作分配给许多工人并行进行。
在我们假设的厨房里,你面临着一个问题:在晚餐高峰时段,你被期望为一大群饥饿的顾客提供食物。你首先可能注意到的是,随着你需要制作的意大利面的数量增加,每个步骤所需的时间也会增加。例如,原始食谱制作四份,需要¾杯切碎的洋葱。这个数量大约相当于一个中等大小的黄洋葱。如果你要制作 400 份这道菜,你需要切碎 100 个洋葱。假设你可以在大约两分钟内切碎一个洋葱,清理切菜板并抓取另一个洋葱需要你 30 秒,那么你将花费大约五小时来切洋葱!别提准备其他食谱所需的时间了。在你仅仅完成切洋葱的工作时,愤怒的顾客可能已经将他们的生意带到了别处。更糟糕的是,你可能会因为花了最后五小时切洋葱而哭干了眼泪!解决这个问题的两种潜在方法是:用更快、更高效的设备替换现有的厨房设备(扩大规模)或者雇佣更多的工人并行工作(扩展规模)。图 1.7 展示了这两种方法的样子。
决定是扩展规模还是横向扩展并不容易,因为两者都有其优势和权衡。你可能想要考虑扩展规模,因为你仍然需要从开始到结束全面监督整个过程。你不必处理他人可能的不稳定或技能上的差异,也不必担心在厨房里遇到其他人。也许你可以用你那把可靠的刀子和切菜板交换一台食品加工机,它可以在你手动切洋葱所需时间的十分之一内完成切洋葱的工作。这会满足你的需求,直到你再次开始扩展。随着你的业务扩大,你开始每天供应 800、1,600 和 3,200 份意大利面,你将开始遇到之前遇到的相同产能问题,最终你会超出食品加工机的处理能力。将会有一个时刻你需要购买一台新的、更快的机器。将这个例子推向极端,你最终会达到当前厨房技术的极限,不得不付出巨大的努力和费用来开发和制造更好、更好的食品加工机。最终,你那台简单的食品加工机将变得高度专业化,用于切割大量的洋葱,并且需要惊人的工程成就来建造和维护。即使如此,你也会达到一个点,进一步的创新将不再可行(在某个时刻,刀片将不得不旋转得如此之快,以至于洋葱将变成泥状!)。但是,等等,我们不要太过分了。对于大多数厨师来说,在市中心开设一家小格子桌布餐馆并不需要制定成为全球意大利面巨头和食品加工机研发强国的计划——这意味着简单地选择购买食品加工机(扩展规模)可能是最好的选择。同样,在大多数情况下,将一台廉价的低端工作站升级为高端服务器,比购买大量硬件并设置集群更容易、更便宜。这尤其适用于你面临的问题处于中等数据集的高端或大型数据集的低端。如果你在云端工作,这也会成为一个更容易做出的选择,因为从一种实例类型扩展到另一种实例类型要比支付购买可能最终不能满足你需求的硬件更容易。话虽如此,如果你可以利用大量的并行性,或者你正在处理大型数据集,横向扩展可能是一个更好的选择。让我们看看横向扩展在厨房中会带来什么结果。
而不是试图提升你自己的技能和能力,你雇佣了九位额外的厨师来帮助分担工作量。如果你们十个人都将 100%的时间和注意力集中在切洋葱这个过程中,那么原本的五小时工作现在只需 30 分钟,前提是你们拥有相同的技能水平。当然,你需要购买额外的刀具、切割板和其他工具,还需要提供足够的设施并支付额外厨师的费用,但长远来看,如果其他选择是不断投入资金开发专用设备,这将是一个更经济有效的解决方案。不仅额外的厨师可以帮助你减少准备洋葱所需的时间,而且由于他们是非专业工人,他们还可以被训练来完成所有其他必要的任务。另一方面,无论你多么努力,食品加工机也无法被训练来煮意大利面!权衡利弊是,其他厨师可能会生病,可能需要请假,或者做些意料之外的事情,从而阻碍进程。让你的厨师团队共同为一个单一目标工作并非不花钱。起初,如果你厨房里只有三四个其他厨师,你可能还能监督他们,但随着厨房规模的扩大,你可能最终需要雇佣一位副厨师长。同样,维护一个团队群组也有实际成本,在考虑是否扩大规模或扩展规模时,这些成本应该被诚实地评估。
在你的新厨师团队中继续前进,你现在必须想出如何传达指令给每位厨师,并确保食谱按照预期制作。有向无环图是规划和管理跨工作者池的复杂任务的优秀工具。最重要的是,节点之间的依赖关系有助于确保工作将遵循一定的顺序(记住,一个节点在所有依赖项完成之前不能开始工作),但没有任何限制来规定单个节点如何完成——无论是单个实体完成还是多个实体并行工作。节点是一个独立的工作单元,因此可以将工作细分并在许多工作者之间共享。这意味着你可以指派四位厨师切洋葱,同时四位其他厨师煎香肠,剩下的两位厨师切碎蒜。在厨房中划分和监督工作是副厨的职责,它代表了 Dask 的任务调度器。随着每位厨师完成他们的任务,副厨可以分配给他们下一个可用的任务。为了以高效的方式让食物在厨房中流动,副厨应不断评估需要完成的工作,并尽可能快地开始接近终端节点的任务。例如,而不是等待所有 100 个洋葱都被切好,如果已经准备了足够的洋葱、大蒜和香肠来开始制作一整批酱汁,副厨应该告诉下一个可用的厨师开始准备一批酱汁。这种策略允许一些顾客更快地被服务,而不是让所有顾客都等待直到所有人都能同时被服务。同样,避免一次性将所有洋葱都切成切碎状态也更有效率,因为这可能会占用大量的切菜板空间。同样,Dask 的任务调度器旨在在许多任务之间循环工作者,以减少内存负载并快速输出完成的结果。它以高效的方式将工作单元分配到机器上,并旨在最小化工作者池的空闲时间。在工作者之间组织图的执行并为每个任务分配适当数量的工作者对于最小化完成图所需的时间至关重要。图 1.8 描述了原始图可以分配给多个工作者的可能方式。

图 1.8 一个节点分布到多个工作者的图,展示了任务在不同时间完成时的动态工作重新分配
1.3.2 并发与资源管理
更多的时候,你不得不考虑的约束不仅仅是可用的工作者数量。在可扩展计算中,这些问题被称为并发性问题。例如,如果你雇佣更多的厨师来切洋葱,但厨房里只有五把刀,那么同时只能进行五次需要刀子的操作。一些其他任务可能需要共享资源,比如需要切碎大蒜的步骤。因此,如果所有五把刀都被切洋葱的厨师使用,那么至少有一把刀可用之前,大蒜是无法切碎的。即使剩下的五名厨师完成了所有其他可能的节点,切碎大蒜的步骤也会因为资源饥饿而延迟。图 1.9 展示了我们假设的厨房中资源饥饿的一个例子。

图 1.9 资源饥饿的例子
其他厨师被迫保持空闲,直到洋葱切丁步骤完成。当一个共享资源正在使用时,会对其放置一个资源锁,这意味着其他工作者不能在锁定资源的工作者完成使用之前“偷取”该资源。如果你们的厨师之间不断争夺谁下一个使用刀子,这些争执会消耗本可以用来完成食谱的时间。副厨师负责通过制定关于谁可以使用某些资源以及资源可用时会发生什么的规则来平息这些对抗。同样,在可扩展计算框架中的任务调度器必须决定如何处理资源竞争和锁定。如果处理不当,资源竞争可能会对性能造成很大的损害。但幸运的是,大多数框架(包括 Dask)在高效任务调度方面做得相当不错,通常不需要手动调整。
1.3.3 从失败中恢复
最后,任何关于可扩展计算的讨论如果没有提及恢复策略都是不完整的。就像副厨师难以同时密切监督所有厨师一样,随着集群中机器数量的增加,协调处理任务的分配变得越来越困难。由于最终结果是由所有单个操作的汇总组成,因此确保所有部分都能到达它们需要去的地方非常重要。但是,机器,就像人一样,是不完美的,有时会失败。必须考虑两种类型的故障:工作节点故障和数据丢失。例如,如果你让你的一个厨师切洋葱,经过连续三个小时的切菜,他决定无法再忍受这种单调,他可能会放下刀子,脱掉外套,走出门口。你现在失去了一个工人!你的另一位厨师需要接替他的位置来完成切洋葱的工作,但幸运的是,你仍然可以使用之前厨师离开前切的洋葱。这是没有数据丢失的工作节点故障。失败的工作节点完成的工作不需要重新生成,因此对性能的影响并不严重。
当发生数据丢失时,对性能的影响可能会更加显著。例如,你的厨房工作人员已经完成了所有初步准备工作,酱汁正在炉子上慢慢煮沸。不幸的是,锅子意外被撞倒,酱汁洒满了地板。知道刮掉地板上的酱汁并尝试恢复将违反所有健康法规,你被迫重新制作酱汁。这意味着要回到切更多的洋葱,炒更多的猪脸肉等等。Simmer Sauce 节点的依赖关系不再满足,这意味着你必须退回到第一个无依赖关系的节点,并从那里开始工作。虽然这是一个相当灾难性的例子,但重要的是要记住,在任何时刻,在图中,给定节点之前的所有操作完整记录可以在发生故障时“重放”。任务调度器最终负责停止工作并将工作重新分配以进行重放。由于任务调度器可以动态地将任务重新分配到失败的工人之外,因此之前完成任务的特定工人不需要在场来重新执行任务。例如,如果决定提前辞职的厨师带了一些切好的洋葱,你就不需要停止整个厨房并从头开始重做所有事情。你只需要确定还需要切多少洋葱,并指派一个新的厨师来完成这项工作。
在罕见情况下,任务调度器可能会遇到问题并失败。这就像你的副厨师决定挂上帽子,走出大门一样。这种失败是可以恢复的,但由于只有任务调度器知道完整的 DAG 以及完成了多少,唯一的选择是从步骤 1 重新开始,使用全新的任务图。诚然,这里的厨房类比有点破裂。在现实中,你的厨师对食谱足够了解,可以在副厨的微观管理下完成服务,但 Dask 的情况并非如此。工人只是按照指示行事,如果没有任务调度器告诉他们该做什么,他们就不能自己做出决定。
希望你现在已经很好地理解了 DAG 的力量以及它们如何与可扩展计算框架相关联。这些概念肯定会在本书中再次出现,因为 Dask 的所有任务调度都是基于这里提出的 DAG 概念。在我们结束本章之前,我们将简要地看一下我们将贯穿整本书使用的数据集,以了解 Dask 的操作和功能。
1.4 介绍配套数据集
由于本书的目的是让你通过使用 Dask 进行数据科学获得实践经验,因此拥有一个可以与即将到来的章节中的示例一起工作的数据集是自然而然的。与其在书中通过一系列专门设计的“玩具”示例进行工作,不如将你新获得的技术应用到真实且混乱的数据集上更有价值。同时,对你来说,使用一个适当大的数据集获得经验也很重要,因为这将使你更好地将你的知识应用到现实世界中的中大型数据集上。因此,在接下来的几章中,我们将使用由纽约市开放数据(opendata.cityofnewyork.us)提供的公共领域数据集作为背景,学习如何使用 Dask。
每月第三个星期,纽约市财政部门会记录并发布到目前为止整个财政年度发出的所有停车罚单数据集。城市收集的数据非常丰富,甚至包括一些有趣的地理特征。为了使数据更易于访问,由 Aleksey Bilogur 和 Jacob Boysen 在纽约市账户下收集并发布在流行的机器学习网站上,Kaggle,包含四年纽约市 OpenData 数据的存档。该数据集从 2013 年到 2017 年 6 月,未压缩超过 8 GB。虽然对于拥有非常强大计算机的人来说,这个数据集可能符合小数据的定义,但对于大多数读者来说,它应该是一个大小合适的中等数据集。尽管确实存在更大的数据集,但我希望你能欣赏到在进入下一章之前不需要下载 2 TB 数据。你可以在 www.kaggle.com/new-york-city/nyc-parking-tickets 上获取数据。下载完数据后,卷起袖子,准备好在下一章中首次了解 Dask!
摘要
-
Dask 可以用于扩展流行的数据分析库,如 Pandas 和 NumPy,让你能够轻松分析中等和大型数据集。
-
Dask 使用有向无环图(DAG)来协调跨 CPU 核心和机器的并行化代码的执行。
-
有向无环图由节点组成,具有明确定义的开始和结束、单一路径和没有循环。
-
上游节点必须完成,然后才能开始任何依赖的下游节点的作业。
-
扩展通常可以提高复杂工作负载的性能,但它会创建额外的开销,这可能会大幅减少这些性能提升。
-
在发生故障的情况下,可以重复从开始到节点的步骤,而不会干扰整个过程的其余部分。
2
介绍 Dask
本章涵盖
-
使用 Dask DataFrames 的简短示例进行数据清洗
-
使用 graphviz 可视化 Dask 工作负载生成的 DAG
-
探索 Dask 任务调度器如何将 DAG 的概念应用于协调代码的执行
现在你已经对 DAG 的工作原理有了基本的了解,让我们来看看 Dask 是如何使用 DAG 来创建健壮、可扩展的工作负载的。为此,我们将使用你在上一章末尾下载的纽约市停车罚单数据。这将帮助我们同时完成两件事:你将第一次尝试使用 Dask 的 DataFrame API 分析结构化数据集,并且你将开始熟悉数据集中的一些怪癖,这些怪癖将在接下来的几章中解决。我们还将查看一些有用的诊断工具,并使用低级别的 Delayed API 创建一个简单的自定义任务图。
在我们深入 Dask 代码之前,如果你还没有这样做,请查看附录以获取有关如何安装 Dask 以及本书其余部分代码示例所需的所有包的说明。你还可以在 www.manning.com/books/data-science-with-python-and-dask 和 bit.ly/daskbook 上找到完整的代码笔记本。对于本书中的所有示例(除非另有说明),我建议你使用 Jupyter Notebook。Jupyter Notebooks 将帮助你保持代码的整洁,并在必要时轻松生成可视化。示例中的代码已在 Python 2.7 和 Python 3.6 环境中进行了测试,因此你应该能够无问题地使用其中任何一个。Dask 适用于 Python 的两个主要版本,但我强烈建议你使用 Python 3 进行任何新的项目,因为 Python 2 的支持将在 2020 年结束。
最后,在我们开始之前,我们将花一点时间来设定接下来几章我们将要走向的方向。如前所述,本书的目的是以实用主义的方式教授你 Dask 的基础知识,重点关注如何将其用于常见的数据科学任务。图 2.1 代表了一种相当标准的处理数据科学问题的方法,我们将以此工作流程为背景来演示如何将 Dask 应用于其各个部分。

图 2.1 在 使用 Python 和 Dask 进行数据科学 中,我们的工作流程一览
在本章中,我们将查看一些 Dask 代码片段,这些代码片段涉及数据收集、数据清理和探索性分析。然而,第 4、5 和 6 章将更深入地探讨这些主题。在这里,我们的重点是给你一个 Dask 语法的第一印象。我们还将关注我们给予 Dask 的高级命令如何与底层调度器生成的 DAG 相关联。那么,让我们开始吧!
2.1 欢迎使用 Dask:DataFrame API 的首次探索
任何数据科学项目的关键步骤之一是对数据集进行探索性分析。在探索性分析过程中,你需要检查数据中的缺失值、异常值以及任何其他数据质量问题。清理数据集确保你进行的分析和关于数据的任何结论都不会受到错误或异常数据的影响。在我们第一次使用 Dask DataFrames 的探索中,我们将逐步演示如何读取数据文件,扫描数据中的缺失值,以及删除那些数据缺失过多或对分析无用的列。
2.1.1 检查 Dask 对象的元数据
在这个例子中,我们将查看从 2017 年收集的数据。首先,你需要导入 Dask 模块并读取你的数据。
列表 2.1 导入相关库和数据
import dask.dataframe as dd
from dask.diagnostics import ProgressBar
from matplotlib import pyplot as plt ①
df = dd.read_csv('nyc-parking-tickets/*2017.csv') ②
df
如果你是一个经验丰富的 Pandas 用户,列表 2.1 看起来会非常熟悉。事实上,它在语法上是等价的!为了简单起见,我已经将数据解压缩到与我正在工作的 Python 笔记本相同的文件夹中。如果你将数据放在其他地方,你可能需要找到正确的路径来使用,或者使用 os.chdir 更改工作目录到包含你的数据的文件夹。检查我们刚刚创建的 DataFrame 会产生图 2.2 中所示的输出。

图 2.2 检查 Dask DataFrame
列表 2.1 的输出可能不是你所期望的。虽然 Pandas 会显示数据的一个样本,但在检查 Dask DataFrame 时,我们看到的却是 DataFrame 的元数据。列名位于顶部,下面是每个列的相应数据类型。Dask 非常努力地尝试智能地推断数据类型,就像 Pandas 一样。但它的准确性受到限制,因为 Dask 是为了处理那些不能一次性加载到 RAM 中的中等和大数据集而构建的。由于 Pandas 可以在内存中执行所有操作,它可以快速轻松地扫描整个 DataFrame 以找到每个列的最佳数据类型。另一方面,Dask 必须能够与本地数据集以及可能分散在分布式文件系统中的多个物理机器上的大型数据集一样好地工作。因此,Dask DataFrame 使用随机抽样方法来从数据的小样本中分析和推断数据类型。如果数据异常,如数字列中出现字母,是普遍现象,这没问题。然而,如果在数百万或数十亿行中只有一行异常,那么在随机样本中选中异常行的可能性非常低。这将导致 Dask 选择一个不兼容的数据类型,这将在后续的计算中导致错误。因此,为了避免这种情况的最佳实践是显式设置数据类型,而不是依赖于 Dask 的推断过程。甚至更好的做法是存储支持显式数据类型的二进制文件格式,如 Parquet,这可以完全避免问题,并带来一些额外的性能提升。我们将在后面的章节中回到这个问题,但现在我们将让 Dask 推断数据类型。
DataFrame 的元数据中其他有趣的信息让我们了解到 Dask 调度器是如何决定分割处理这个文件的工作的。npartitions值显示了 DataFrame 被分割成多少个分区。由于 2017 年的文件大小略超过 2 GB,在 33 个分区的情况下,每个分区的大小大约为 64 MB。这意味着不是一次性将整个文件加载到 RAM 中,而是每个 Dask 工作线程一次处理一个 64 MB 的数据块。

图 2.3 Dask 将大型数据文件拆分成多个分区,并一次处理一个分区。
图 2.3 展示了这种行为。Dask 不是将整个 DataFrame 整个加载到 RAM 中,而是将文件拆分成更小的块,这些块可以独立工作。这些块被称为 分区。在 Dask DataFrame 的情况下,每个分区都是一个相对较小的 Pandas DataFrame。在 图 2.3 的例子中,DataFrame 由两个分区组成。因此,单个 Dask DataFrame 由两个较小的 Pandas DataFrame 组成。每个分区可以单独加载到内存中并处理,或者并行处理。在这种情况下,工作节点首先获取分区 1 并处理它,并将结果保存在临时存储空间中。接下来,它获取分区 2 并处理它,将结果保存到临时存储空间中。最后,它将结果合并并发送到我们的客户端,客户端显示结果。因为工作节点可以一次处理数据的小块,所以可以将工作分配到多台机器上。或者,在本地集群的情况下,可以在不产生内存不足错误的情况下处理非常大的数据集。
我们从 DataFrame 获取的最后一点元数据是它由 99 个任务组成。这告诉我们 Dask 创建了一个包含 99 个节点的 DAG 来处理数据。图由 99 个节点组成,因为每个分区需要创建三个操作:读取原始数据、将数据分割成适当大小的块,以及初始化底层的 DataFrame 对象。总共 33 个分区,每个分区有 3 个任务,结果产生 99 个任务。如果我们有 33 个工作节点在工作池中,整个文件可以同时处理。只有一个工作节点时,Dask 将逐个循环处理每个分区。现在,让我们尝试计算整个文件中每个列的缺失值。
列表 2.2 在 DataFrame 中计算缺失值
missing_values = df.isnull().sum()
missing_values
Dask Series Structure:
npartitions=1
Date First Observed int64
Violation Time ...
dtype: int64
Dask Name: DataFrame-sum-agg, 166 tasks
计算空值的语法再次看起来很像 Pandas。但就像之前一样,检查结果的 Series 对象并没有给出我们可能期望的输出。Dask 并没有真正进行任何处理,因为它使用的是 延迟计算。这意味着 Dask 实际上在底层所做的只是准备另一个 DAG,然后将其存储在 missing_values 变量中。数据实际上是在任务图明确执行之前不会计算的。这种行为使得可以快速构建复杂的任务图,而无需等待每个中间步骤完成。你可能注意到任务计数现在增长到了 166。这是因为 Dask 从用于读取数据文件并创建名为 df 的 DataFrame 的 DAG 中取出了前 99 个任务,然后添加了 66 个任务(每个分区 2 个)来检查空值和求和,最后添加了一个最终步骤,将所有部分收集到一个单一的 Series 对象中并返回答案。
列表 2.3 计算 DataFrame 中缺失值的百分比
missing_count = ((missing_values **/** df.index.size) ***** 100)
missing_count
Dask Series Structure:
npartitions=1
Date First Observed float64
Violation Time ...
dtype: float64
Dask Name: mul, 235 tasks
在我们运行计算之前,我们将要求 Dask 将这些数字转换为百分比,方法是将缺失值计数(missing_values)除以 DataFrame 中的总行数(df.index.size),然后将所有结果乘以 100。请注意,任务的数量再次增加,结果的 Series 数据类型从 int64 变为 float64!这是因为除法操作产生了非整数(整数)的答案。因此,Dask 自动将答案转换为浮点数(小数)。正如 Dask 尝试从文件中推断数据类型一样,它也会尝试推断操作将如何影响输出的数据类型。由于我们在 DAG 中添加了一个除法阶段,Dask 推断我们可能会从整数转换为浮点数,并相应地更改结果的元数据。
2.1.2 使用 compute 方法运行计算
现在我们已经准备好运行并生成我们的输出。
列表 2.4 计算有向无环图(DAG)
with ProgressBar():
missing_count_pct = missing_count.compute()
missing_count_pct
Summons Number 0.000000
Plate ID 0.006739
Registration State 0.000000
Plate Type 0.000000
Issue Date 0.000000
Violation Code 0.000000
Vehicle Body Type 0.395361
Vehicle Make 0.676199
Issuing Agency 0.000000
Street Code1 0.000000
Street Code2 0.000000
Street Code3 0.000000
Vehicle Expiration Date 0.000000
Violation Location 19.183510
Violation Precinct 0.000000
Issuer Precinct 0.000000
Issuer Code 0.000000
Issuer Command 19.093212
Issuer Squad 19.101506
Violation Time 0.000583
Time First Observed 92.217488
Violation County 0.366073
Violation In Front Of Or Opposite 20.005826
House Number 21.184968
Street Name 0.037110
Intersecting Street 68.827675
Date First Observed 0.000000
Law Section 0.000000
Sub Division 0.007155
Violation Legal Code 80.906214
Days Parking In Effect 25.107923
From Hours In Effect 50.457575
To Hours In Effect 50.457548
Vehicle Color 1.410179
Unregistered Vehicle? 89.562223
Vehicle Year 0.000000
Meter Number 83.472476
Feet From Curb 0.000000
Violation Post Code 29.530489
Violation Description 10.436611
No Standing or Stopping Violation 100.000000
Hydrant Violation 100.000000
Double Parking Violation 100.000000
dtype: float64
每当你想让 Dask 计算你工作的结果时,你需要调用 DataFrame 的 .compute() 方法。这告诉 Dask 开始运行计算并显示结果。你有时可能会看到这被称为 materializing 结果,因为 Dask 创建用于运行计算的 DAG 是结果的一个逻辑表示,但实际上结果只有在您明确计算它们时才会被计算(即 materialized)。你还会注意到我们将对 compute 的调用包裹在 ProgressBar 上下文中。这是 Dask 提供的几个诊断上下文之一,可以帮助你跟踪正在运行的任务,尤其是在使用本地任务调度器时特别有用。ProgressBar 上下文将简单地打印出一个基于文本的进度条,显示计算的估计完成百分比和已过时间。
通过我们的缺失值计算输出,看起来我们可以立即丢弃一些列:无站立或停止违规、消防栓违规和双停车违规都是完全空的,所以保留它们没有价值。我们将删除任何缺失值超过 60% 的列(注意:60% 只是为了示例而选择的任意值;你用来丢弃具有缺失数据的列的阈值取决于你试图解决的问题,并且通常依赖于你的最佳判断)。
列表 2.5 过滤稀疏列
columns_to_drop = missing_count_pct[missing_count_pct > 60].index
with ProgressBar():
df_dropped = df.drop(columns_to_drop, axis=1).persist()
这很有趣。由于我们在 清单 2.4 中 materialized 了数据,missing_count_pct 是一个 Pandas Series 对象,但我们可以使用它对 Dask DataFrame 应用 drop 方法。我们首先取在 清单 2.4 中创建的 Series,并过滤它以获取具有超过 60% 缺失值的列。然后我们获取过滤后的 Series 的索引,它是一个列名列表。然后我们使用该索引在具有相同名称的 Dask DataFrame 中删除列。通常可以混合 Pandas 对象和 Dask 对象,因为 Dask DataFrame 的每个分区都是一个 Pandas DataFrame。在这种情况下,Pandas Series 对象对所有线程都是可用的,因此它们可以在计算中使用它。在集群上运行的情况下,Pandas Series 对象将被序列化并广播到所有工作节点。
2.1.3 使用 persist 提高复杂计算的效率
由于我们已经决定我们不在乎刚刚删除的列,每次我们想要进行额外的计算并再次删除它们时重新读取这些列到内存中将会非常低效。我们真正关心的是分析我们刚刚创建的过滤数据子集。回想一下,一旦活动任务图中的节点发出结果,其中间工作就会被丢弃,以最小化内存使用。这意味着如果我们想要对过滤数据进行一些额外的操作(例如,查看 DataFrame 的前五行),我们就必须麻烦地重新运行整个转换链。为了避免多次重复相同的计算,Dask 允许我们存储计算的中间结果,以便可以重用。使用 Dask DataFrame 的persist()方法告诉 Dask 尽可能多地保留中间结果在内存中。如果 Dask 需要一些由持久化的 DataFrame 使用的内存,它将选择一些分区从内存中删除。这些被删除的分区将在需要时即时重新计算,尽管重新计算缺失分区可能需要一些时间,但它仍然可能比重新计算整个 DataFrame 要快得多。适当地使用persist可以在你有非常庞大且复杂的 DAG 需要多次重用时非常有用,以加快计算速度。
这就结束了我们对 Dask DataFrame 的第一瞥。你看到了,仅用几行代码,我们就能读取数据集并开始为探索性分析做准备。这段代码的美丽之处在于,无论你是在一台机器上运行 Dask 还是在数千台机器上运行,无论你是在处理几 GB 的数据(就像我们在这里做的那样)还是在分析 PB 级的数据,它的工作方式都是一样的。此外,由于与 Pandas 的语法相似性,你可以很容易地将工作负载从 Pandas 迁移到 Dask,而无需进行大量的代码重构(这主要涉及到添加 Dask 导入和compute调用)。我们将在接下来的章节中深入分析,但现在我们将更深入地探讨 Dask 是如何使用 DAGs 来管理支撑我们刚刚走过的代码的任务的分布。
2.2 可视化 DAGs
到目前为止,你已经学习了有向无环图(DAGs)的工作原理,并且了解到 Dask 使用 DAGs 来协调 DataFrame 的分布式计算。然而,我们还没有深入“引擎盖下”看看调度器实际创建的 DAGs。Dask 使用 graphviz 库来生成任务调度器创建的 DAGs 的可视化表示。如果你按照附录中的步骤安装了 graphviz,你将能够检查任何 Dask Delayed 对象的 DAG。你可以通过在对象上调用visualize()方法来检查 DataFrame、series、bags 和数组的 DAG。
2.2.1 使用 Dask Delayed 对象可视化简单的 DAG
对于这个例子,我们将从之前例子中看到的 Dask DataFrame 对象退后一步,降低一个抽象级别:Dask Delayed 对象。我们将转向延迟对象的原因是,Dask 为即使是简单的 DataFrame 操作创建的 DAG 可能会变得非常大,难以可视化。因此,为了方便起见,我们将使用 Dask Delayed 对象进行这个例子,以便我们更好地控制 DAG 的组合。
列表 2.6 创建一些简单的函数
import dask.delayed as delayed
from dask.diagnostics import ProgressBar
def inc(i):
return i + 1
def add(x, y):
return x + y
x = delayed(inc)(1)
y = delayed(inc)(2)
z = delayed(add)(x, y)
z.visualize()
列表 2.6 首先导入了本例所需的包:在这种情况下,是 delayed 包和之前使用的 ProgressBar 诊断工具。接下来,我们定义了几个简单的 Python 函数。第一个函数将其输入值加一,第二个函数将两个输入值相加。接下来的三行介绍了 delayed 构造函数。通过将 delayed 包裹在一个函数周围,可以生成该函数的 Dask Delayed 表示。延迟对象相当于 DAG 中的一个节点。原始函数的参数通过第二组括号传入。例如,对象 x 代表对 inc 函数的延迟评估,传入 1 作为 i 的值。延迟对象也可以引用其他延迟对象,这在对象 z 的定义中可以看得到。将这些延迟对象连接起来最终构成了一个图。为了评估对象 z,必须首先评估对象 x 和 y。如果 x 或 y 有其他必须满足的延迟依赖,那么这些依赖需要先被评估,依此类推。这听起来非常像 DAG:评估对象 z 有一个已知的依赖链,必须以确定性的顺序进行评估,并且有一个明确的起点和终点。确实,这是代码中一个非常简单的 DAG 的表示。我们可以通过使用 visualize 方法来看看它是什么样子。

图 2.4 由 列表 2.6 产生的 DAG 的可视化表示
如您在 图 2.4 中所见,对象 z 由一个 DAG 表示。在图的底部,我们可以看到对 inc 函数的两个调用。该函数没有自己的延迟依赖,因此没有指向 inc 节点的箭头线。然而,add 节点有两个指向它的箭头线。这表示在能够对两个值求和之前,必须先计算 x 和 y。由于每个 inc 节点没有依赖,一个独特的工人可以独立地处理每个任务。如果 inc 函数的评估需要很长时间,利用这种并行性可能会非常有优势。
2.2.2 使用循环和集合可视化更复杂的 DAG
让我们看看一个稍微复杂一点的例子。
列表 2.7 执行add_two操作
def add_two(x):
return x + 2
def sum_two_numbers(x,y):
return x + y
def multiply_four(x):
return x * 4
data = [1, 5, 8, 10]
step1 = [delayed(add_two)(i) for i in data]
total = delayed(sum)(step1)
total.visualize()
现在事情变得有趣了。让我们分析一下这里发生了什么。我们再次定义了一些简单的函数,并定义了一个整数列表以供使用。不过,这次不是从单个函数调用创建一个 Delayed 对象,而是将 Delayed 构造函数放在一个列表推导式中,该推导式遍历数字列表。结果是step1变成了一个 Delayed 对象的列表,而不是一个整数列表。
下一行代码使用内置的sum函数将列表中的所有数字加起来。sum函数通常需要一个可迭代对象作为参数,但由于它被包裹在一个 Delayed 构造函数中,所以可以将 Delayed 对象的列表传递给它。和之前一样,这段代码最终代表了一个图。让我们看看这个图是什么样子。

图 2.5 代表列表 2.7 中计算的定向无环图
现在,变量 total 是一个 Delayed 对象,这意味着我们可以使用它的visualize方法来绘制 DAG,这样如果要求 Dask 计算答案时,Dask 会使用这个 DAG!图 2.5 显示了visualize方法的输出。需要注意的是,Dask 是从下往上绘制 DAG 的。我们从列表data中的四个数字开始,这对应于 DAG 底部的四个节点。Dask DAG 上的圆圈代表函数调用。这很有道理:我们本来有四个数字,我们想要将add_two函数应用到每个数字上,所以我们必须调用它四次。同样,我们只调用一次sum函数,因为我们传递的是完整的列表。DAG 上的方块代表中间结果。例如,迭代数字列表并应用add_two函数到原始数字上的结果是四个转换后的数字,每个数字都增加了二。就像前一个部分中的 DataFrame 一样,Dask 实际上不会在调用 total 对象上的compute方法之前计算答案。

图 2.6 在图 2.5 上的 DAG,数值叠加到计算中
在图 2.6 中,我们从数据列表中取出了四个数字,并将它们叠加到 DAG 上,以便您可以看到每个函数调用的结果。结果 32 是通过取原始的四个数字,对每个数字应用addTwo转换,然后求和得到的。
我们现在将通过在收集结果之前将每个数字乘以四来增加 DAG 的复杂度。
列表 2.8 将每个值乘以四
def add_two(x):
return x + 2
def sum_two_numbers(x,y):
return x + y
def multiply_four(x):
return x * 4
data = [1, 5, 8, 10]
step1 = [delayed(add_two)(i) for i in data]
step2 = [delayed(multiply_four)(j) for j in step1]
total = delayed(sum)(step2)
total.visualize()
这看起来非常像之前的代码列表,但有一个关键的区别。在代码的第一行,我们将 multiply_four 函数应用于 step1,这是通过将原始数字列表加 2 得到的 Delayed 对象列表。就像你在 DataFrame 示例中看到的那样,可以在不立即计算中间结果的情况下链式进行计算。

图 2.7 包含乘以四步骤的 DAG
图 2.7 展示了 列表 2.8 中计算的结果。如果你仔细观察 DAG,你会注意到在 addTwo 节点和求和节点之间增加了一层。这是因为我们现在指示 Dask 从列表中取出每个数字,将其加 2,然后加 4,然后再求和结果。
2.2.3 使用持久化降低 DAG 复杂度
现在我们再进一步:假设我们想要将这个总和加回到我们的原始数字上,然后将所有这些数字加在一起。
列表 2.9 在 DAG 中添加另一层
data2 = [delayed(sum_two_numbers)(k, total) for k in data]
total2 = delayed(sum)(data2)
total2.visualize()

图 2.8 由 列表 2.9 生成的 DAG
在这个例子中,我们使用了在上一个例子中创建的完整 DAG,它存储在 total 变量中,并使用它创建了一个新的 Delayed 对象列表。
图 2.8 中的 DAG 看起来像是 列表 2.9 中的 DAG 被复制,并在其上方融合了另一个 DAG。这正是我们想要的!首先,Dask 将计算第一组变换的总和,然后将其添加到每个原始数字上,最后计算这个中间步骤的总和。正如你可以想象的那样,如果我们重复这个循环几次,DAG 将开始变得太大而无法可视化。同样,如果我们原始列表中有 100 个数字而不是 4 个,DAG 图也会非常大(尝试将数据列表替换为 range[100] 并重新运行代码!)但在上一节中,我们提到了一个更重要的原因,即为什么大型 DAG 可能会变得难以管理:持久化。
如前所述,每次你在 Delayed 对象上调用 compute 方法时,Dask 都会遍历整个 DAG 以生成结果。这对于简单的计算来说可能没问题,但如果你正在处理非常大的分布式数据集,重复计算可能会迅速变得低效。一种解决方法是持久化你想要重用的中间结果。但这会对 DAG 有什么影响呢?
列表 2.10 持久化计算
total_persisted = total.persist()
total_persisted.visualize()

图 2.9 由 列表 2.10 生成的 DAG
在这个例子中,我们取了我们创建的列表 2.9 中的 DAG 并将其持久化。我们得到的结果不是完整的 DAG,而是一个单一的结果,如图 2.9 所示(记住,一个矩形代表一个结果)。这个结果代表了当在 total 对象上调用 compute 方法时,Dask 会计算出的值。但是,我们不需要每次访问其值时都重新计算它,Dask 现在将只计算一次并将结果保存在内存中。现在我们可以在这个持久化的结果之上链式调用另一个延迟计算,并得到一些有趣的结果。
列表 2.11 从持久化 DAG 链接 DAG
data2 = [delayed(sum_two_numbers)(l, total_persisted) for l in data]
total2 = delayed(sum)(data2)
total2.visualize()
图 2.10 中生成的结果 DAG 要小得多。实际上,它看起来就像 列表 2.9 中的 DAG 的上半部分。这是因为 sum-#1 的结果已经被预先计算并持久化。因此,在 列表 2.11 中,Dask 可以使用持久化的数据,从而减少产生结果所需的计算次数。
在我们进入下一节之前,先尝试一下列表 2.12!Dask 可以生成非常大的 DAG。尽管这个图表可能不会在这个页面上完全显示,但它可能会让你对 Dask 能够非常优雅地处理的复杂性有所认识。

图 2.10 由 列表 2.11 生成的 DAG
列表 2.12 可视化最后的纽约市数据 DAG
missing_count.visualize()
2.3 任务调度
如我之前多次提到的,Dask 在其 API 中使用了 惰性计算 的概念。我们已经看到了这种效果的实际应用——无论我们对 Dask 延迟对象执行什么操作,我们都必须在实际上发生任何事情之前调用 compute 方法。当你考虑到处理 PB 级数据可能需要的时间时,这非常有优势。由于实际上只有在请求结果时才会发生计算,因此你可以在定义下一个计算之前,无需等待一个计算完成就可以定义 Dask 应该对数据进行的所有完整转换——这样你就可以在完整结果计算的同时做其他事情(比如为那锅你被我说服要做的 bucatini all’Amatriciana 切洋葱)!
2.3.1 惰性计算
惰性计算还允许 Dask 将工作拆分成更小的逻辑块,这有助于避免将整个数据结构加载到内存中。正如你在 2.1 节中看到的 DataFrame,Dask 将 2 GB 的文件分成了 33 个 64 MB 的块,并且一次操作 8 个块。这意味着整个操作的最大内存消耗不超过 512 MB,但我们仍然能够处理整个 2 GB 的文件。当你处理的数据集大小扩展到 TB 和 PB 范围时,这一点变得更加重要。
但当你从 Dask 请求结果时,实际上会发生什么呢?你定义的计算由一个 DAG 表示,这是一个计算所需结果的逐步计划。然而,这个逐步计划并没有定义应该使用哪些物理资源来执行计算。仍然必须考虑两个重要的事情:计算将在哪里进行,如果需要的话,每个计算的结果应该发送到哪里。与关系数据库系统不同,Dask 在开始工作之前并不预先确定每个任务的精确运行时位置。相反,任务调度器实时评估已完成的工作、剩余的工作以及可用于接受额外工作的空闲资源。这使得 Dask 能够优雅地处理分布式计算中出现的各种问题,包括从工作节点故障中恢复、网络不可靠以及工作节点以不同速度完成工作。此外,任务调度器可以跟踪中间结果存储的位置,允许后续工作将数据发送到数据而不是不必要地在网络上传输数据。这在使用集群上的 Dask 操作时,大大提高了效率。
2.3.2 数据局部性
由于 Dask 使得将代码从笔记本电脑扩展到数百或数千个物理服务器变得容易,任务调度器必须就哪些物理机器将被要求执行计算的具体部分做出明智的决策。Dask 使用集中式任务调度器来协调所有这些工作。为此,每个 Dask 工作节点向任务调度器报告它拥有的数据和它正在承受的负载。任务调度器持续评估集群的状态,为用户提交的计算提供公平、高效的执行计划。例如,如果我们把 2.1 节中的示例(读取纽约市停车票数据)分配到两台计算机(服务器 A 和服务器 B)之间,任务调度器可能会声明分区 26 的操作应由服务器 A 执行,而相同的操作应由服务器 B 在分区 8 上执行。在大多数情况下,如果任务调度器尽可能均匀地将工作分配给集群中的机器,计算将尽可能快和高效地完成。
但这个经验法则并不总是适用于多种场景:一个服务器比其他服务器负载更重,硬件不如其他服务器强大,或者无法快速访问数据。如果这些条件中的任何一个成立,那么繁忙/较弱的服务器将落后于其他服务器,因此应该分配较少的任务,以避免成为瓶颈。任务调度器的动态特性允许它在无法避免这些情况时相应地做出反应。
为了获得最佳性能,Dask 集群应该使用分布式文件系统如 S3 或 HDFS 来支持其数据存储。为了说明这一点的重要性,考虑以下反例,其中文件仅存储在一台机器上。为了我们的例子,数据存储在服务器 A 上。当服务器 A 被指示处理分区 26 时,它可以直接从其硬盘上读取该分区。然而,这对服务器 B 来说是个问题。在服务器 B 开始处理分区 8 之前,服务器 A 需要将分区 8 发送到服务器 B。服务器 B 将要处理的任何其他附加分区也必须在开始工作之前发送到服务器 B。这将导致计算速度显著减慢,因为涉及网络的操作(即使是 10 Gb 光纤)比直接从本地连接的硬盘读取要慢。

图 2.11 从本地硬盘读取数据比从远程存储的数据读取要快得多。
图 2.11 展示了这个问题。如果节点 1 想要处理分区 1,如果它能在本地硬盘上找到分区 1,那么它将能够更快地完成。如果这不是一个选项,它可以从节点 2 通过网络读取数据,但这将会慢得多。
解决这个问题的方法是在事先将文件分割,将一些分区存储在服务器 A 上,并将一些分区存储在服务器 B 上。这正是分布式文件系统所做的事情。逻辑文件在物理机器之间分割。除了其他明显的优点,如服务器硬盘故障时的冗余之外,将数据分布到许多物理机器上可以使工作负载更加均匀地分散。将计算带到数据那里比将数据带到计算那里要快得多!
Dask 的任务调度器在考虑计算应该在何处进行时,会考虑到数据局部性,即数据的物理位置。尽管有时 Dask 无法完全避免将数据从一个工作者移动到另一个工作者,例如某些数据必须广播到集群中的所有机器的情况,但任务调度器会尽力最小化在物理服务器之间移动的数据量。当数据集较小时,这可能不会造成太大差异,但当数据集非常大时,在网络中移动数据的影响就更加明显。因此,最小化数据移动通常会导致计算性能更优。
希望你现在对 DAGs 在使 Dask 能够将大量工作分解成更易管理的小块所起的重要作用有了更好的理解。我们将在后面的章节中回到延迟 API,但请记住,本书中我们接触到的每一个 Dask 都是基于延迟对象的,并且你可以在任何时间可视化支持 DAG。在实践中,你可能不需要经常以如此明确的方式调试计算,但理解 Dask 的底层机制将帮助你更好地识别工作中潜在的问题和瓶颈。在下一章中,我们将开始深入探讨 DataFrame API。
摘要
-
Dask DataFrame 上的计算是通过任务调度器使用 DAGs 来组织的。
-
计算是懒加载构建的,调用
compute方法来执行计算并检索结果。 -
你可以在任何 Dask 对象上调用
visualize方法来查看底层 DAG 的视觉表示。 -
可以通过使用
persist方法来存储和重用复杂计算的中间结果,从而简化计算流程。 -
数据局部性将计算带到数据处,以最小化网络和 I/O 延迟.*
第二部分
使用 Dask DataFrames 处理结构化数据
现在你已经对 Dask 如何使你既能处理大型数据集又能利用并行性有了基本的理解,你就可以通过实际数据集的动手实践来学习如何使用 Dask 解决常见的数据科学挑战。第二部分专注于 Dask DataFrames——这是广受欢迎的 Pandas DataFrame 的并行化实现——以及如何使用它们来清理、分析和可视化大型结构化数据集。
第三章通过解释 Dask 如何并行化 Pandas DataFrames,并描述为什么 Dask DataFrame API 的一些部分与其 Pandas 对应部分不同,来开启这一部分的内容。
第四章通过解决如何从各种数据源将数据读入 DataFrames 的问题,跳入了数据科学工作流程的第一部分。
第五章通过深入常见的数据操作和清理任务,如排序、过滤、重新编码和填充缺失数据,继续工作流程。
第六章展示了如何使用一些内置函数生成描述性统计,以及如何构建自己的自定义聚合和窗口函数。
第七章和第八章通过从基本可视化到高级交互式可视化,甚至是在地图上绘制基于位置的数据,结束第二部分的内容。
完成第二部分后,你将熟练掌握如何处理数据科学项目中常见的许多数据准备和分析任务,并且你将处于一个很好的位置,可以进入更高级的主题!
3
介绍 Dask DataFrames
本章涵盖
-
定义结构化数据并确定何时使用 Dask DataFrames
-
探索 Dask DataFrames 的组织方式
-
检查 DataFrames 以查看它们是如何分区的
-
处理 DataFrame 的一些局限性
在上一章中,我们开始探索 Dask 如何使用 DAGs 在多台机器上协调和管理复杂任务。然而,我们只看了使用延迟 API 的一些简单示例,以帮助说明 Dask 代码与 DAG 元素之间的关系。在本章中,我们将开始更仔细地查看 DataFrame API。我们还将遵循一个相当典型的数据科学工作流程来处理纽约市停车罚单数据。这个工作流程及其相应的章节可以在图 3.1 中看到。

图 3.1 使用 Python 和 Dask 进行数据科学的流程
Dask DataFrame 围绕 Pandas DataFrame 包装延迟对象,以便您可以对更复杂的数据结构进行操作。而不是编写自己的复杂函数网络,DataFrame API 包含一系列复杂的转换方法,如笛卡尔积、连接、分组操作等,这些方法对于常见的数据处理任务非常有用。在我们深入探讨这些操作之前,我们将在第五章中这样做,我们将从解决数据收集的必要背景知识开始探索 Dask。更具体地说,我们将研究 Dask DataFrame 如何非常适合操作结构化数据,即由行和列组成的数据。我们还将研究 Dask 如何通过将数据分成称为分区的小块来支持并行处理和处理大型数据集。此外,我们将在本章中探讨一些性能优化的最佳实践。
3.1 为什么使用 DataFrame?
在自然界中找到的数据形状通常以两种方式之一来描述:结构化或非结构化。结构化数据由行和列组成:从简单的电子表格到复杂的数据库系统,结构化数据是存储信息的直观方式。图 3.2 展示了一个具有行和列的结构化数据集示例。

图 3.2 结构化数据示例
当思考数据时,自然倾向于这种格式,因为这种结构有助于将相关的信息片段保持在同一视觉空间中。一行代表一个逻辑实体:在电子表格中,每一行代表一个人。行由一个或多个列组成,这些列代表我们对每个实体的了解。在电子表格中,我们记录了每个人的姓氏、名字、出生日期和唯一标识符。许多类型的数据都可以适应这种形状:来自销售点的交易数据、市场调查结果、点击流数据,甚至经过特殊编码后的图像数据。
由于结构化数据的组织方式,我们很容易想到许多不同的数据操作方法。例如,我们可以找到数据集中最早的出生日期,过滤掉不符合特定模式的人,按姓氏将人分组,或按名字对人进行排序。将此与如果我们将其存储在几个列表对象中的数据可能看起来如何进行比较。
列表 3.1 图 3.2 的列表表示
person_IDs = [1,2,3]
person_last_names = ['Smith', 'Williams', 'Williams']
person_first_names = ['John', 'Bill', 'Jane']
person_DOBs = ['1982-10-06', '1990-07-04', '1989-05-06']
在 列表 3.1 中,列被存储为单独的列表。尽管仍然可以执行之前建议的所有转换,但并不立即明显这四个列表是相互关联的,并形成一个完整的数据集。此外,对数据进行分组和排序等操作所需的代码将相当复杂,并且需要深入了解数据结构和算法来编写高效执行的代码。Python 提供了许多不同的数据结构,我们可以使用它们来表示这些数据,但没有一个像 DataFrame 那样直观地用于存储结构化数据。
类似于电子表格或数据库表,DataFrame 是按行和列组织的。然而,当与 DataFrame 一起工作时,我们需要注意一些额外的术语:索引和轴。图 3.3 展示了 DataFrame 的结构。

图 3.3 从 图 3.2 的结构化数据示例的 Dask 表示
图 3.3 中的示例显示了从 图 3.2 的结构化数据到 DataFrame 的表示。注意图中的附加标签:行被称为“轴 0”,列被称为“轴 1”。当处理会改变数据形状的 DataFrame 操作时,这一点很重要。DataFrame 操作默认在轴 0 上工作,所以除非您明确指定,否则 Dask 将按行执行操作。
图 3.3 中突出显示的另一个区域是索引。索引为每一行提供了一个标识符。理想情况下,这些标识符应该是唯一的,特别是如果您计划将索引用作与其他 DataFrame 连接的键。然而,Dask 并不强制唯一性,因此如果需要,您可以有重复的索引。默认情况下,DataFrame 是以类似于 图 3.3 中看到的顺序整数索引创建的。如果您想指定自己的索引,可以将 DataFrame 中的某一列设置为索引,或者您可以创建自己的索引对象并将其分配给 DataFrame 的索引。我们将在第五章中深入探讨常见的索引函数,但 Dask 中索引的重要性不容忽视:它们是跨机器集群分配 DataFrame 工作负载的关键。考虑到这一点,我们现在将探讨如何使用索引来形成分区。
3.2 Dask 和 Pandas
如前几次提到的,Pandas 是一个非常流行且强大的用于分析结构化数据的框架,但它的最大局限性在于它没有考虑到可扩展性。Pandas 非常适合处理小型结构化数据集,并且高度优化以在内存中存储的数据上执行快速和高效的操作。然而,正如我们在第一章的假设厨房场景中看到的,随着工作量的显著增加,聘请额外的人手并将任务分散到多个工作者上可能是一个更好的选择。这就是 Dask 的 DataFrame API 发挥作用的地方:通过围绕 Pandas 提供智能包装,将大型数据帧分割成更小的部分,并将它们分散到一组工作者中,可以更快、更稳健地完成大型数据集的操作。
Dask 管理的 DataFrame 的不同部分被称为分区。每个分区都是一个相对较小的 DataFrame,可以被分配给任何工作者,并在必要时保持其完整的血缘关系以供重新生成。图 3.4 展示了 Dask 如何使用分区进行并行处理。
在图 3.4 中,你可以看到 Pandas 和 Dask 处理数据集的方式有何不同。使用 Pandas,数据集将被加载到内存中,并逐行顺序处理。另一方面,Dask 可以将数据分割成多个分区,允许工作负载被并行化。这意味着如果我们有一个要应用于 DataFrame 的长时间运行函数,Dask 可以通过在多台机器上分散工作来更有效地完成工作。然而,需要注意的是,图 3.4 中的 DataFrame 仅用于示例。如前所述,任务调度器确实会在过程中引入一些开销,因此使用 Dask 处理只有 10 行数据的 DataFrame 可能不是最快的解决方案。图 3.5 展示了两个主机如何更详细地协调在这个分割数据集上的工作。
由于节点 1 正在驱动计算并告诉节点 2 做什么,它目前承担着任务调度器的角色。节点 1 告诉节点 2 处理分区 2,而节点 1 处理分区 1。每个节点完成其处理任务,并将其部分结果发送回客户端。然后客户端组装结果的部分,并显示输出。

图 3.4 Dask 允许多个主机并行处理单个 Pandas DataFrame。
3.2.1 管理 DataFrame 分区
由于分区可能会对性能产生如此重大的影响,你可能会担心管理分区将是构建 Dask 工作负载中困难且繁琐的部分。然而,无需担忧:Dask 尝试通过包含一些合理的默认值和启发式方法来创建和管理分区,以帮助你在不进行手动调优的情况下获得尽可能多的性能。例如,当使用 Dask DataFrames 的 read_csv 方法读取数据时,默认分区大小为每个 64 MB(这也称为默认块大小)。考虑到现代服务器通常有数十吉字节 RAM,64 MB 可能看起来相当小,但这是一种足够小的数据量,如果需要,它可以快速通过网络传输,但足够大,以最小化机器在等待下一个分区到来时耗尽可执行任务的可能性。使用默认的或用户指定的块大小,数据将被分割成必要的分区数量,以确保每个分区的大小不超过块大小。如果你希望创建具有特定分区数的 DataFrame,你可以在创建 DataFrame 时通过传递 npartitions 参数来指定这一点。

图 3.5 在多台机器上并行处理数据
列表 3.2 使用特定分区数创建 DataFrame
import pandas
import dask.dataframe as daskDataFrame
person_IDs = [1,2,3,4,5,6,7,8,9,10]
person_last_names = ['Smith', 'Williams', 'Williams','Jackson','Johnson','Smith','Anderson','Christiansen','Carter','Davidson']
person_first_names = ['John', 'Bill', 'Jane','Cathy','Stuart','James','Felicity','Liam','Nancy','Christina']
person_DOBs = ['1982-10-06', '1990-07-04', '1989-05-06', '1974-01-24', '1995-06-05', '1984-04-16', '1976-09-15', '1992-10-02', '1986-02-05', '1993-08-11'] ①
peoplePandasDataFrame = pandas.DataFrame({'Person ID':personIDs,
'Last Name': personLastNames,
'First Name': personFirstName,
'Date of Birth': personDOBs},
columns=['Person ID', 'Last Name', 'First Name', 'Date of Birth']) ②
peopleDaskDataFrame = daskDataFrame.from_pandas(peoplePandasDataFrame, npartitions=2) ③
在 列表 3.2 中,我们创建了一个 Dask DataFrame,并使用 npartitions 参数显式地将它分割成两个分区。通常,Dask 会将这个数据集放入一个分区中,因为它相当小。
列表 3.3 检查 Dask DataFrame 的分区
print(people_dask_df.divisions) ①
print(people_dask_df.npartitions) ②
列表 3.3 展示了 Dask DataFrames 的几个有用属性,这些属性可以用来检查 DataFrame 的分区情况。第一个属性,divisions(0, 5, 9),显示了分区方案的边界(记住分区是在索引上创建的)。这可能会看起来有些奇怪,因为有两个分区但三个边界。每个分区的边界由来自 divisions 列表的一对数字组成。第一个分区的边界是“从 0 到(但不包括)5”,这意味着它将包含行 0、1、2、3 和 4。第二个分区的边界是“从 5 通过(包括)9”,这意味着它将包含行 5、6、7、8 和 9。最后一个分区始终包括上边界,而其他分区则达到但不包括它们的上边界。
第二个属性,npartitions,简单地返回 DataFrame 中存在的分区数。
列表 3.4 检查 DataFrame 中的行
people_dask_df.map_partitions(len).compute() ①
''' Produces the output:
0 5
1 5
dtype: int64 '''
列表 3.4 展示了如何使用 map_partitions 方法来计算每个分区的行数。map_partitions 通常将给定的函数应用于每个分区。这意味着 map_partitions 调用的结果将返回一个与 DataFrame 当前分区数量相等的 Series。由于在这个 DataFrame 中我们有两个分区,所以调用结果返回了两个项目。输出显示每个分区包含五行,这意味着 Dask 将 DataFrame 分割成了两个相等的部分。
有时候可能需要更改 Dask DataFrame 中的分区数量。尤其是在你的计算包括大量过滤操作时,每个分区的尺寸可能会变得不平衡,这可能会对后续计算的性能产生负面影响。原因在于,如果一个分区突然包含了大部分数据,所有并行化的优势实际上都丧失了。让我们来看一个这样的例子。首先,我们将通过在我们的原始 DataFrame 上应用一个过滤器来创建一个新的 DataFrame,该过滤器移除了所有姓威廉姆斯的人。然后,我们将使用相同的 map_partitions 调用来计算每个分区的行数,以检查新 DataFrame 的构成。
列表 3.5 DataFrame 重新分区
people_filtered = people_dask_df[people_dask_df['Last Name'] != 'Williams']
print(people_filtered.map_partitions(len).compute()) ①
people_filtered_reduced = people_filtered.repartition(npartitions=1)
print(people_filtered_reduced.map_partitions(len).compute()) ②
注意发生了什么:第一个分区现在只包含三行,第二个分区有原来的五行。姓威廉姆斯的人恰好在前一个分区中,所以我们的新 DataFrame 变得相当不平衡。
列表中的第二行和第三行代码旨在通过在过滤后的 DataFrame 上使用 repartition 方法来修复不平衡。这里的 npartitions 参数与我们在创建初始 DataFrame 时使用的 npartitions 参数的作用相同。只需指定你想要的分区数量,Dask 就会确定需要做什么来实现这一点。如果你指定了一个比当前分区数量低的数字,Dask 将通过连接现有分区来合并它们。如果你指定了一个比当前分区数量高的数字,Dask 将将现有分区分割成更小的部分。你可以在程序的任何时间点调用 repartition 来启动这个过程。然而,像所有其他 Dask 操作一样,它是一个惰性计算。实际上,直到你调用 compute、head 等操作之前,数据实际上是不会移动的。再次在新的 DataFrame 上调用 map_partitions 函数,我们可以看到分区数量已减少到一,并且它包含了所有八行。请注意,如果你再次重新分区,这次增加分区数量,旧的分隔(0、5、9)将被保留。如果你想均匀分割分区,你需要手动更新分隔以匹配你的数据。
3.2.2 什么是洗牌?
既然我们已经了解到分区的重要性,探讨了 Dask 如何处理分区,以及学习了如何影响它,我们将通过了解分布式计算中经常遇到的挑战来结束这次讨论:处理 洗牌。不,我并不是在谈论舞蹈动作——坦白说,我并不是提供舞蹈建议的最佳人选!在分布式计算中,洗牌是将所有分区广播到所有工作者的过程。当执行排序、分组和索引操作时,洗牌数据是必要的,因为每一行都需要与整个 DataFrame 中的每一行进行比较,以确定其正确的相对位置。这是一个耗时的操作,因为它需要在网络上传输大量数据。让我们看看这可能会是什么样子。

图 3.6 需要洗牌的 GroupBy 操作
在 图 3.6 中,我们看到如果我们想按姓氏对数据进行分组会发生什么。例如,我们可能想找到按姓氏最年长的人。对于大多数数据,这没有问题。这个数据集中大多数姓氏都是唯一的。正如你在 图 3.6 中的数据中可以看到,只有两种情况有多个同姓的人:威廉姆斯和史密斯。对于两个名叫威廉姆斯的人,他们处于同一个分区,所以服务器 1 可以在本地获取所有必要的信息来确定最老的威廉姆斯出生于 1989 年。然而,对于史密斯这个名字,有一个史密斯在分区 1,另一个在分区 2。在这两种情况下,为了使 Dask 能够比较每个史密斯的出生日期,其中一个人必须通过网络传输。
根据需要处理的数据,完全避免洗牌操作可能不可行。然而,你可以做一些事情来最小化洗牌数据的需求。首先,确保数据以预排序的顺序存储将消除使用 Dask 对数据进行排序的需求。如果可能的话,在源系统(如关系数据库)中对数据进行排序可能比在分布式系统中排序更快、更高效。其次,使用排序列作为 DataFrame 的索引将提高连接操作的效率。当数据预排序时,查找操作非常快,因为可以使用 DataFrame 上定义的分区来确定某个特定行所在的分区。最后,如果你必须使用触发洗牌的操作,如果你有资源,请持久化结果。这将防止在 DataFrame 需要重新计算时再次洗牌数据。
3.3 Dask DataFrames 的局限性
现在你已经对 DataFrame API 的用途有了很好的了解,通过介绍 DataFrame API 的一些局限性来结束本章将是有帮助的。
首先且最重要的是,Dask DataFrame 并不暴露整个 Pandas API。尽管 Dask DataFrame 由较小的 Pandas DataFrame 组成,但 Pandas 在某些方面做得很好的函数并不适合分布式环境。例如,会改变 DataFrame 结构的函数,如插入和弹出,由于 Dask DataFrame 是不可变的,因此不支持这些函数。一些更复杂的窗口操作也不支持,例如扩展和 EWM 方法,以及像堆叠/取消堆叠和熔合这样的复杂转置方法,因为它们倾向于导致大量数据洗牌。通常,这些昂贵的操作并不需要在完整的原始数据集上真正执行。在这些情况下,你应该使用 Dask 来完成所有正常的数据准备、过滤和转换,然后将最终数据集导入 Pandas。这样,你就可以在减少的数据集上执行这些昂贵的操作。Dask 的 DataFrame API 使得与 Pandas DataFrame 交互变得非常容易,因此当使用 Dask DataFrame 分析数据时,这种模式非常有用。
第二个局限性是与关系型操作相关,例如 join/merge、groupby 和 rolling。尽管这些操作是支持的,但它们很可能会涉及大量洗牌,从而成为性能瓶颈。这可以通过使用 Dask 准备一个较小的数据集并将其导入 Pandas 来最小化,或者通过限制这些操作仅使用索引来使用。例如,如果我们想将一个人的 DataFrame 与交易的 DataFrame 进行连接,如果这两个数据集都按 Person ID 排序并索引,那么这个计算将显著更快。这将最小化每个记录分散在许多分区中的可能性,从而使得洗牌更加高效。
第三,由于 Dask 的分布式特性,索引有一些挑战。如果你希望将 DataFrame 中的某一列用作索引而不是默认的数字索引,则需要对其进行排序。如果数据是预先排序的,这根本不是问题。如果数据没有预先排序,则对整个 DataFrame 进行排序可能非常缓慢,因为它需要大量洗牌。实际上,每个分区首先需要排序,然后需要与其他每个分区合并并再次排序。有时可能需要这样做,但如果你能主动将你的数据预先排序以供所需的计算,这将为你节省大量时间。
你可能注意到的另一个与索引相关的显著差异是 Dask 处理 reset_index 方法的方式。与 Pandas 不同,在 Pandas 中这将重新计算整个 DataFrame 的新顺序索引,而 Dask DataFrame 中的该方法表现得像是一个 map_partitions 调用。这意味着每个分区将获得自己的从 0 开始的顺序索引,因此整个 DataFrame 将不再具有唯一的顺序索引。在 图 3.7 中,你可以看到这种效果。

图 3.7 调用 reset_index 在 Dask DataFrame 上的结果
每个分区包含五行,因此当我们调用 reset_index 时,前五行的索引保持不变,但下一五行(位于下一个分区中)从 0 开始。不幸的是,没有简单的方法可以在分区感知方式下重置索引。因此,请谨慎使用 reset_index 方法,并且仅在你不打算使用生成的顺序索引进行连接、分组或排序 DataFrame 时使用。
最后,由于 Dask DataFrame 由多个 Pandas DataFrame 组成,因此在 Pandas 中效率低下的操作在 Dask 中也会效率低下。例如,使用 apply 和 iterrows 方法按行迭代在 Pandas 中是出了名的低效。因此,遵循 Pandas 的最佳实践将在使用 Dask DataFrame 时提供最佳性能。如果你还没有很好地掌握 Pandas,继续磨练你的技能不仅会在你更熟悉 Dask 和分布式工作负载时对你有所帮助,而且对作为数据科学家的一般工作也会有所帮助!
摘要
-
Dask DataFrame 由行(轴 0)、列(轴 1)和索引组成。
-
DataFrame 方法默认情况下倾向于按行操作。
-
通过访问 DataFrame 的
divisions属性可以检查 DataFrame 的分区情况。 -
过滤 DataFrame 可能会导致每个分区的尺寸不平衡。为了获得最佳性能,分区的大小应该大致相等。在过滤大量数据后,使用
repartition方法重新分区 DataFrame 是一个好习惯。 -
为了获得最佳性能,DataFrame 应该通过逻辑列进行索引,通过其索引进行分区,并且索引应该是预排序的。
4
将数据加载到 DataFrame 中
本章涵盖
-
从分隔符文本文件创建 DataFrame 并定义数据模式
-
从 SQL 关系型数据库中提取数据并使用 Dask 进行操作
-
从分布式文件系统(S3 和 HDFS)中读取数据
-
处理存储在 Parquet 格式的数据
在前三章中,我已经给你介绍了很多概念——所有这些都将在你成为 Dask 专家的旅程中为你提供帮助。但是,我们现在准备卷起袖子开始处理一些数据。作为提醒,图 4.1 展示了我们在处理 Dask 功能时将遵循的数据科学工作流程。

图 4.1 使用 Python 和 Dask 进行数据科学的工作流程
在本章中,我们停留在工作流程的最初步骤:问题定义和数据收集。在接下来的几章中,我们将使用纽约市停车罚单数据来回答以下问题:
我们能在数据中找到哪些与纽约市停车管理部门发放的罚单数量增加或减少相关的模式?
或许我们会发现老旧车辆更容易收到罚单,或者可能是某种特定颜色比其他颜色更容易吸引停车管理部门的注意。使用这个指导问题,我们将使用 Dask DataFrames 收集、清理和探索相关数据。考虑到这一点,我们将首先学习如何将数据读入 Dask DataFrames。
数据科学家面临的一个独特挑战是我们倾向于研究“静止的数据”,或者那些并非专门为预测建模和分析目的而收集的数据。这与传统学术研究中精心且深思熟虑地收集数据的方式大相径庭。因此,你可能会在整个职业生涯中遇到各种存储介质和数据格式。在本章中,我们将介绍如何读取一些最流行的格式和存储系统中的数据,但绝不意味着本章涵盖了 Dask 能力的全部范围。Dask 在许多方面都非常灵活,DataFrame API 与大量数据收集和存储系统的接口能力就是其光辉例证。
当我们处理将数据读入 DataFrames 的过程时,要牢记之前章节中关于 Dask 组件的知识:我们将创建的 Dask DataFrames 由许多逻辑上划分成分区的 Pandas DataFrames 组成。在 Dask DataFrame 上执行的所有操作都会生成一个 DAG(有向无环图)的 Delayed 对象,这些对象可以被分发到多个进程或物理机器上。任务调度器控制任务图的分发和执行。现在,让我们转向数据!
4.1 从文本文件中读取数据
我们将从最简单、最常见的数据格式开始:定界文本文件。定界文本文件有多种风味,但所有文件都共享使用称为定界符的特殊字符来将数据划分为逻辑行和列的通用概念。
每种分隔文本文件格式都有两种类型的分隔符:行分隔符和列分隔符。行分隔符是一个特殊字符,表示你已到达行的末尾,其右侧的任何额外数据都应被视为下一行的一部分。最常见的行分隔符只是一个换行符(\n)或一个回车符后跟一个换行符(\r\n)。按行分隔是一种标准选择,因为它提供了额外的视觉上分割原始数据的好处,并反映了电子表格的布局。
同样,列分隔符表示列的结束,其右侧的任何数据都应被视为下一列的一部分。在所有流行的列分隔符中,逗号(,)是最常用的。实际上,使用逗号作为列分隔符的分隔文本文件有一个特殊的文件格式,名为逗号分隔值或简称为 CSV。其他常见选项包括管道(|)、制表符、空格和分号。
在图 4.2 中,你可以看到分隔文本文件的一般结构。这个特定的文件是一个 CSV 文件,因为我们使用逗号作为列分隔符。此外,由于我们使用换行符作为行分隔符,你可以看到每一行都在自己的行上。

图 4.2 分隔文本文件的结构
文件分隔文本的另外两个尚未讨论的属性包括可选的标题行和文本限定符。标题行简单来说就是使用第一行来指定列名。在这里,人员 ID、姓氏和名字并不是对一个人的描述;它们是元数据,用于描述数据结构。虽然不是必需的,但标题行对于传达数据结构应该包含的内容是有帮助的。
*文本限定符是另一种特殊字符,用于表示列的内容是文本字符串。在允许实际数据包含用作行或列分隔符的字符的情况下,它们非常有用。这在处理包含文本数据的 CSV 文件时是一个相当常见的问题,因为逗号通常出现在文本中。用文本限定符包围这些列表示,在文本限定符内部出现的任何列或行分隔符实例都应该被忽略。
现在你已经了解了分隔文本文件的结构,让我们看看如何通过将一些分隔文本文件导入 Dask 来应用这些知识。在第二章中我们简要查看的纽约市停车罚单数据是以一组 CSV 文件的形式提供的,因此这将是一个非常适合本例的数据集。如果你还没有下载数据,你可以通过访问 www.kaggle.com/new-york-city/nyc-parking-tickets 来下载。正如我之前提到的,为了方便起见,我已经将数据解压到了我正在使用的 Jupyter 笔记本所在的同一个文件夹中。如果你将数据放在了其他地方,你需要更改文件路径以匹配你保存数据的位置。
列表 4.1 使用 Dask 默认设置导入 CSV 文件
import dask.dataframe as dd
from dask.diagnostics import ProgressBar
fy14 = dd.read_csv('nyc-parking-tickets/Parking_Violations_Issued_-_Fiscal_Year_2014__August_2013___June_2014_.csv')
fy15 = dd.read_csv('nyc-parking-tickets/Parking_Violations_Issued_-_Fiscal_Year_2015.csv')
fy16 = dd.read_csv('nyc-parking-tickets/Parking_Violations_Issued_-_Fiscal_Year_2016.csv')
fy17 = dd.read_csv('nyc-parking-tickets/Parking_Violations_Issued_-_Fiscal_Year_2017.csv')
fy17
在 列表 4.1 中,前三行应该看起来很熟悉:我们只是导入了 DataFrame 库和 ProgressBar 上下文。在接下来的四行代码中,我们正在读取 NYC 停车罚单数据集附带的所有四个 CSV 文件。目前,我们将每个文件读取到它自己的单独 DataFrame 中。让我们通过检查 fy17 DataFrame 来看看发生了什么。

图 4.3 fy17 DataFrame 的元数据
在 图 4.3 中,我们看到了 fy17 DataFrame 的元数据。使用默认的 64 MB 块大小,数据被分割成了 33 个分区。你可能还记得这一点来自第三章。你还可以看到顶部的列名,但它们是从哪里来的?默认情况下,Dask 假设你的 CSV 文件将有一个标题行,而我们的文件确实有一个标题行。如果你用你最喜欢的文本编辑器查看原始 CSV 文件,你将看到文件的第一行上的列名。如果你想查看所有列名,你可以检查 DataFrame 的 columns 属性。
列表 4.2 检查 DataFrame 的列
fy17.columns
'''
Produces the output:
Index([u'Summons Number', u'Plate ID', u'Registration State', u'Plate Type', u'Issue Date', u'Violation Code', u'Vehicle Body Type', u'Vehicle Make', u'Issuing Agency', u'Street Code1', u'Street Code2', u'Street Code3',u'Vehicle Expiration Date', u'Violation Location',
u'Violation Precinct', u'Issuer Precinct', u'Issuer Code',
u'Issuer Command', u'Issuer Squad', u'Violation Time',
u'Time First Observed', u'Violation County',
u'Violation In Front Of Or Opposite', u'House Number', u'Street Name', u'Intersecting Street', u'Date First Observed', u'Law Section',
u'Sub Division', u'Violation Legal Code', u'Days Parking In Effect ', u'From Hours In Effect', u'To Hours In Effect', u'Vehicle Color',
u'Unregistered Vehicle?', u'Vehicle Year', u'Meter Number',
u'Feet From Curb', u'Violation Post Code', u'Violation Description',
u'No Standing or Stopping Violation', u'Hydrant Violation',
u'Double Parking Violation'],
dtype='object')
'''
如果你偶然查看其他 DataFrame 的列,比如 fy14(2014 年停车罚单),你会注意到列与 fy17(2017 年停车罚单) DataFrame 的列不同。看起来纽约市政府在 2017 年改变了它收集关于停车违规的数据。例如,违规的纬度和经度在 2017 年之前没有记录,因此这些列对于分析年度趋势(例如停车违规“热点”如何在整个城市中迁移)将没有用。如果我们简单地像这样连接数据集,我们会得到一个包含大量缺失值的 DataFrame。在我们合并数据集之前,我们应该找到所有四个 DataFrame 共有的列。然后我们应该能够简单地联合 DataFrame 来生成一个新的 DataFrame,其中包含所有四年的数据。
我们可以手动查看每个 DataFrame 的列并推断哪些列重叠,但这将非常低效。相反,我们将通过利用 DataFrame 的 columns 属性和 Python 的集合操作来自动化这个过程。以下列表显示了如何进行此操作。
列表 4.3 查找四个 DataFrame 之间的公共列
# Import for Python 3.x
from functools import reduce
columns = [set(fy14.columns),
set(fy15.columns),
set(fy16.columns),
set(fy17.columns)]
common_columns = list(reduce(lambda a, i: a.intersection(i), columns))
在第一行,我们创建了一个包含四个集合对象的列表,分别代表每个 DataFrame 的列。在下一行,我们利用集合对象的 intersection 方法,该方法返回一个包含两个集合中存在的项的集合。通过将其包装在 reduce 函数中,我们能够遍历每个 DataFrame 的元数据,提取所有四个 DataFrame 共同的列,并丢弃在所有四个 DataFrame 中未找到的列。我们最终得到以下简化的列列表:
['House Number',
'No Standing or Stopping Violation',
'Sub Division',
'Violation County',
'Hydrant Violation',
'Plate ID',
'Plate Type',
'Vehicle Year',
'Street Name',
'Vehicle Make',
'Issuing Agency',
...
'Issue Date']
现在我们有一组所有四个 DataFrame 共享的公共列,让我们看一下 fy17 DataFrame 的前几行。
列表 4.4 查看 fy17 DataFrame 的头部
fy17[common_columns].head()

图 4.4 使用公共列集查看 fy17 DataFrame 的前五行
在 列表 4.4 中发生了两件重要的事情:列过滤操作和顶部收集操作。在 DataFrame 名称右侧的方括号中指定一个或多个列是您在 DataFrame 中选择/过滤列的主要方式。由于 common_columns 是一个列名列表,我们可以将其传递给列选择器,以获取包含列表中列的结果。我们还链式调用了 head 方法,这允许您查看 DataFrame 的顶部 n 行。如图 图 4.4 所示,默认情况下,它将返回 DataFrame 的前五行,但您可以将要检索的行数作为参数指定。例如,fy17.head(10) 将返回 DataFrame 的前 10 行。请注意,当您从 Dask 获取行时,它们正在被加载到您的计算机 RAM 中。因此,如果您尝试返回过多的数据行,您将收到内存不足错误。现在让我们在 fy14 DataFrame 上尝试相同的调用。
列表 4.5 查看 fy14 DataFrame 的头部
fy14[common_columns].head()
'''
Produces the following output:
Mismatched dtypes found in `pd.read_csv`/`pd.read_table`.
+-----------------------+---------+----------+
| Column | Found | Expected |
+-----------------------+---------+----------+
| Issuer Squad | object | int64 |
| Unregistered Vehicle? | float64 | int64 |
| Violation Description | object | float64 |
| Violation Legal Code | object | float64 |
| Violation Post Code | object | float64 |
+-----------------------+---------+----------+
The following columns also raised exceptions on conversion:
- Issuer Squad
ValueError('cannot convert float NaN to integer',)
- Violation Description
ValueError('invalid literal for float(): 42-Exp. Muni-Mtr (Com. Mtr. Z)',)
- Violation Legal Code
ValueError('could not convert string to float: T',)
- Violation Post Code
ValueError('invalid literal for float(): 05 -',)
Usually this is due to dask's dtype inference failing, and
*may* be fixed by specifying dtypes manually
'''
看起来 Dask 在尝试读取 fy14 数据时遇到了麻烦!幸运的是,Dask 开发团队在这个错误消息中给了我们一些相当详细的信息,关于发生了什么。五个列——发行方小队、未注册车辆?、违规描述、违规法律代码和违规邮政代码——未能正确读取,因为它们的数据类型不是 Dask 预期的。正如我们在第二章中学到的,Dask 使用随机抽样来推断数据类型,以避免扫描整个(可能巨大的)DataFrame。尽管这通常效果很好,但当列中缺少大量值或绝大多数数据可以归类为一种数据类型(如整数)时,它可能会失败,但少数边缘情况会打破这个假设(如一个或两个随机字符串)。当这种情况发生时,Dask 会在开始处理计算时抛出异常。为了帮助 Dask 正确读取我们的数据集,我们需要手动为我们的数据定义一个模式,而不是依赖于类型推断。在我们着手做那之前,让我们回顾一下 Dask 中可用的数据类型,这样我们就可以为我们的数据创建一个适当的模式。
4.1.1 使用 Dask 数据类型
与关系型数据库系统类似,列数据类型在 Dask DataFrame 中起着重要作用。它们控制可以在列上执行的操作类型,过载运算符(+、- 等)的行为,以及如何分配内存来存储和访问列的值。与 Python 中的大多数集合和对象不同,Dask DataFrame 使用显式类型而不是鸭子类型。这意味着列中包含的所有值都必须符合相同的类型。正如我们之前所看到的,如果发现列中的值违反了列的数据类型,Dask 将会抛出错误。
由于 Dask DataFrame 由 Pandas DataFrame 组成的分区构成,而 Pandas DataFrame 又是由 NumPy 数组组成的复杂集合,因此 Dask 从 NumPy 中获取其数据类型。NumPy 库是 Python 中一个强大且重要的数学库。它使用户能够执行线性代数、微积分和三角学的先进操作。这个库对于数据科学的需求非常重要,因为它为 Python 中许多统计分析方法和机器学习算法提供了基石数学。让我们看看 NumPy 的数据类型,这些类型可以在图 4.5 中看到。

图 4.5 Dask 使用的 NumPy 数据类型
如您所见,许多这些类型反映了 Python 中的原始类型。最大的区别是 NumPy 数据类型可以显式地使用指定的位宽来定义。例如,int32数据类型是一个 32 位整数,允许任何介于-2,147,483,648 和 2,147,483,647 之间的整数。相比之下,Python 总是根据您的操作系统和硬件支持使用最大位宽。因此,如果您在 64 位 CPU 上运行 64 位操作系统,Python 将始终分配 64 位内存来存储一个整数。在适当的情况下使用较小的数据类型的好处是您可以在一次内存和 CPU 缓存中保存更多的数据,从而实现更快、更高效的计算。这意味着在创建数据模式时,您应该始终选择可以存储数据的可能最小的数据类型。然而,风险是如果某个值超过了特定数据类型允许的最大大小,您将遇到溢出错误,因此您应该仔细考虑数据的范围和域。
例如,考虑美国的房价:房价通常高于$32,767,如果历史通货膨胀率持续存在,在相当长的一段时间内不太可能超过$2,147,483,647。因此,如果您要将房价四舍五入到最接近的整数,int32数据类型将是最合适的。虽然int64和int128类型足够宽,可以容纳这个数字范围,但使用超过 32 位的内存来存储每个值将是不高效的。同样,使用int8或int16将不足以容纳数据,从而导致溢出错误。
如果没有适合您数据的 NumPy 数据类型,可以将列存储为object类型,这代表任何 Python 对象。这也是当 Dask 的类型推断遇到包含数字和字符串混合的列,或者无法确定使用适当的数据类型时,Dask 默认使用的数据类型。然而,这个规则的一个常见例外是当您有一个具有高比例缺失数据的列。请看图 4.6,它再次显示了最后一个错误消息的部分输出。

图 4.6 一个显示不匹配数据类型的 Dask 错误
你真的相信一个名为“违规描述”的列应该是浮点数吗?可能不会!通常,我们可以预期描述列是文本类型,因此 Dask 应该使用对象数据类型。那么为什么 Dask 的类型推断认为该列包含 64 位浮点数呢?实际上,这个 DataFrame 中的大多数记录都有缺失的违规描述。在原始数据中,它们只是空白。Dask 在解析文件时将空白记录视为空值,并默认使用 NumPy 的 NaN(不是一个数字)对象 np.nan 来填充缺失值。如果你使用 Python 内置的类型函数来检查对象的类型,它会报告 np.nan 是浮点类型。因此,由于 Dask 的类型推断在尝试推断“违规描述”列的类型时随机选择了一组 np.nan 对象,它假设该列必须包含浮点数。现在让我们修复这个问题,以便我们可以使用适当的数据类型读取我们的 DataFrame。
4.1.2 为 Dask DataFrame 创建模式
在处理数据集时,通常你会在事先知道每列的数据类型、它是否可以包含缺失值以及其有效值范围。这些信息统称为数据的“模式”。如果你从关系型数据库中获取数据集,你很可能知道该数据集的模式。数据库表中的每一列都必须有一个已知的良好数据类型。如果你提前有这些信息,使用 Dask 就像编写模式并将其应用于 read_csv 方法一样简单。你将在本节的末尾看到如何做到这一点。然而,有时你可能不知道模式是什么,你需要自己找出它。也许你正在从没有适当文档的 Web API 中提取数据,或者你正在分析数据提取,并且没有访问数据源。这两种方法都不是理想的,因为它们可能会很繁琐且耗时,但有时你可能真的没有其他选择。这里有两种你可以尝试的方法:
-
猜测并检查
-
手动采样数据
猜测并检查的方法并不复杂。如果你有良好命名的列,例如产品描述、销售额等,你可以尝试使用名称来推断每个列包含的数据类型。如果你在运行我们看到的计算时遇到数据类型错误,只需更新模式并重新开始。这种方法的优势在于你可以快速轻松地尝试不同的模式,但缺点是如果由于数据类型问题而不断失败,可能需要不断地重新启动你的计算,这可能会变得繁琐。
手动采样方法旨在更加复杂,但可能需要更多的时间来扫描一些数据以进行配置。然而,如果你计划无论如何都要分析数据集,那么这并不是“浪费”的时间,因为在创建模式的同时,你将熟悉数据。让我们看看我们如何做到这一点。
列表 4.6 构建通用模式
import numpy as np
import pandas as pd
dtype_tuples = [(x, np.str) for x in common_columns]
dtypes = dict(dtype_tuples)
dtypes
'''
Displays the following output:
{'Date First Observed': str,
'Days Parking In Effect ': str,
'Double Parking Violation': str,
'Feet From Curb': str,
'From Hours In Effect': str,
...
}
'''
首先,我们需要构建一个将列名映射到数据类型的字典。这是因为我们稍后要传递给此对象的 dtype 参数期望一个字典类型。为此,在 列表 4.6 中,我们首先遍历我们之前制作的 common_columns 列表,该列表包含所有四个 DataFrame 中可以找到的所有列名。我们将每个列名转换成一个包含列名和 np.str 数据类型的元组,其中 np.str 代表字符串。在第二行,我们将元组列表转换为字典,部分内容如下所示。现在我们已经构建了一个通用模式,我们可以将其应用于 read_csv 函数,以使用该模式将 fy14 数据加载到 DataFrame 中。
列表 4.7 使用显式模式创建 DataFrame
fy14 = dd.read_csv('nyc-parking-tickets/Parking_Violations_Issued_-_Fiscal_Year_2014__August_2013___June_2014_.csv', dtype=dtypes)
`with ProgressBar():`
`display(fy14[common_columns].head())`
列表 4.7 在第一次读取 2014 年数据文件时看起来大致相同。然而,这次我们指定了 dtype 参数,并传递了我们的模式字典。在底层发生的事情是 Dask 将禁用具有匹配键的 dtype 字典中的列的类型推断,并使用显式指定的类型。虽然只包括你想要更改的列是完全合理的,但最好在可能的情况下根本不依赖 Dask 的类型推断。在这里,我向你展示了如何为 DataFrame 中的所有列创建显式模式,并鼓励你在处理大型数据集时将其作为常规做法。使用这个特定的模式,我们告诉 Dask 假设所有列都是字符串。现在,如果我们再次尝试使用 fy14[common_columns].head() 查看 DataFrame 的前五行,Dask 不会抛出错误信息!但我们的工作还没有完成。我们现在需要查看每个列,并选择一个更合适的(如果可能的话)数据类型以最大化效率。让我们看看“车辆年份”列。
列表 4.8 检查车辆年份列
`with ProgressBar():`
`print(`fy14['Vehicle Year'].unique().head(10))
# Produces the following output:
0 2013
1 2012
2 0
3 2010
4 2011
5 2001
6 2005
7 1998
8 1995
9 2003
Name: Vehicle Year, dtype: object
在 列表 4.8 中,我们只是查看“车辆年份”列中包含的 10 个唯一值。看起来它们都是可以舒适地存储在 uint16 数据类型中的整数。uint16 是最合适的,因为年份不能是负值,而且这些年份太大,不能存储在 uint8(最大大小为 255)中。如果我们看到任何字母或特殊字符,我们就不需要进一步分析这个列。我们之前选择的字符串数据类型将是该列唯一适合的数据类型。
需要注意的一件事是,10 个唯一值的样本可能不足以确定没有需要考虑的边缘情况。您可以使用 .compute() 而不是 .head() 来获取所有唯一值,但如果您查看的特定列具有高度的唯一性(例如主键或高维类别),这可能不是一个好主意。10-50 个唯一样本的范围在大多数情况下都对我很有帮助,但有时您仍然会遇到需要返回并调整数据类型的边缘情况。
由于我们考虑整数数据类型可能适用于此列,我们需要检查一件事:此列中是否有任何缺失值?如您之前所学,Dask 使用 np.nan 来表示缺失值,这被视为一个浮点类型对象。不幸的是,np.nan 不能被转换为或强制转换为整数 uint16 数据类型。在下一章中,我们将学习如何处理缺失值,但到目前为止,如果我们遇到有缺失值的列,我们需要确保该列将使用可以支持 np.nan 对象的数据类型。这意味着如果车辆年份列包含任何缺失值,我们将需要使用 float32 数据类型,而不是我们最初认为合适的 uint16 数据类型,因为 uint16 无法存储 np.nan。
列表 4.9 检查车辆年份列是否存在缺失值
with ProgressBar():
print(fy14['Vehicle Year'].isnull().values.any().compute())
# Produces the following output:
True
在 列表 4.9 中,我们使用了 isnull 方法,该方法检查指定列中每个值的 np.nan 存在情况。如果找到 np.nan,则返回 True,如果没有找到,则返回 False,然后将所有行的检查汇总为一个布尔 Series。通过 .values.any() 连接,布尔 Series 被简化为单个 True(如果至少有一行是 True),或者 False(如果没有一行是 True)。这意味着如果 列表 4.9 中的代码返回 True,则车辆年份列中至少有一行缺失。如果它返回 False,则表示车辆年份列中没有缺失数据。由于车辆年份列中存在缺失值,我们必须使用 float32 数据类型而不是 uint16 数据类型。
现在,我们应该对剩余的 42 个列重复此过程。为了简洁起见,我已经为您完成了这项工作。在这种情况下,我们也可以使用 Kaggle 网页上发布的数据字典(在 www.kaggle.com/new-york-city/nyc-parking-tickets/data)来帮助加快这一过程。
列表 4.10 纽约市停车罚单数据的最终模式
dtypes = {
'Date First Observed': np.str,
'Days Parking In Effect ': np.str,
'Double Parking Violation': np.str,
'Feet From Curb': np.float32,
'From Hours In Effect': np.str,
'House Number': np.str,
'Hydrant Violation': np.str,
'Intersecting Street': np.str,
'Issue Date': np.str,
'Issuer Code': np.float32,
'Issuer Command': np.str,
'Issuer Precinct': np.float32,
'Issuer Squad': np.str,
'Issuing Agency': np.str,
'Law Section': np.float32,
'Meter Number': np.str,
'No Standing or Stopping Violation': np.str,
'Plate ID': np.str,
'Plate Type': np.str,
'Registration State': np.str,
'Street Code1': np.uint32,
'Street Code2': np.uint32,
'Street Code3': np.uint32,
'Street Name': np.str,
'Sub Division': np.str,
'Summons Number': np.uint32,
'Time First Observed': np.str,
'To Hours In Effect': np.str,
'Unregistered Vehicle?': np.str,
'Vehicle Body Type': np.str,
'Vehicle Color': np.str,
'Vehicle Expiration Date': np.str,
'Vehicle Make': np.str,
'Vehicle Year': np.float32,
'Violation Code': np.uint16,
'Violation County': np.str,
'Violation Description': np.str,
'Violation In Front Of Or Opposite': np.str,
'Violation Legal Code': np.str,
'Violation Location': np.str,
'Violation Post Code': np.str,
'Violation Precinct': np.float32,
'Violation Time': np.str
}
列表 4.10 包含了纽约市停车罚单数据的最终模式。让我们使用它来重新加载所有四个 DataFrame,然后将所有四年的数据合并到一个最终的 DataFrame 中。
列表 4.11 将模式应用于所有四个 DataFrame
data = dd.read_csv('nyc-parking-tickets/*.csv', dtype=dtypes, usecols=common_columns)
在列表 4.11 中,我们重新加载数据并应用我们创建的模式。请注意,我们不再将四个单独的文件加载到四个单独的数据框中,而是现在通过使用*通配符将 nyc-parking-tickets 文件夹中包含的所有 CSV 文件加载到一个单独的数据框中。Dask 提供这种便利,因为将大型数据集拆分成多个文件是很常见的,尤其是在分布式文件系统中。和之前一样,我们将最终的架构传递给dtype参数,现在我们还把我们要保留的列的列表传递给usecols参数。usecols接受一个列名列表,并从结果数据框中删除任何未在列表中指定的列。由于我们只关心分析我们拥有的所有四年数据,我们将选择简单地忽略所有四年都不共有的列。
usecols是一个有趣的参数,因为如果你查看 Dask API 文档,它并没有列出。这也许不是立即显而易见的,但这是因为该参数来自 Pandas。由于 Dask 数据框的每个分区都是一个 Pandas 数据框,你可以通过*args和**kwargs接口传递任何 Pandas 参数,它们将控制构成每个分区的底层 Pandas 数据框。这个接口也是你可以控制诸如应该使用哪种列分隔符、数据是否有标题等事情的方式。read_csv的 Pandas API 文档及其许多参数可以在pandas.pydata.org/pandas-docs/stable/generated/pandas.read_csv.html找到。
我们现在已经读取了数据,我们准备清理和分析这个数据框。如果你数一数行数,我们就有超过 4230 万起停车违规行为要探索!然而,在我们深入之前,我们还将查看与其他存储系统的接口以及数据写入。现在我们将查看从关系型数据库系统中读取数据。
4.2 从关系型数据库读取数据
从关系型数据库系统(RDBMS)读取数据到 Dask 相当简单。实际上,你可能会发现与 RDBMS 接口的最繁琐部分是设置和配置你的 Dask 环境以实现这一点。由于生产环境中使用的 RDBMS 种类繁多,我们无法在这里涵盖每个系统的具体细节。但是,对于你正在工作的特定 RDBMS,网上有大量的文档和支持。重要的是要注意,当在多节点集群中使用 Dask 时,你的客户端机器不是唯一需要访问数据库的机器。每个工作节点都需要能够访问数据库服务器,因此安装正确的软件并配置集群中的每个节点以实现这一点非常重要。
Dask 使用 SQL Alchemy 库与 RDBMS 交互,我建议使用 pyodbc 库来管理你的 ODBC 驱动程序。这意味着你需要为集群中的每台机器安装和配置 SQL Alchemy、pyodbc 以及特定 RDBMS 的 ODBC 驱动程序,以便 Dask 能够正确工作。要了解更多关于 SQL Alchemy 的信息,你可以查看 www.sqlalchemy.org/library.html。同样,你可以在 github.com/mkleehammer/pyodbc/wiki 上了解更多关于 pyodbc 的信息。
列表 4.12 将 SQL 表读入 Dask DataFrame
username = 'jesse'
password = 'DataScienceRulez'
hostname = 'localhost'
database_name = 'DSAS'
odbc_driver = 'ODBC+Driver+13+for+SQL+Server'
connection_string = 'mssql+pyodbc://{0}:{1}@{2}/{3}?driver={4}'.format(username, password, hostname, database_name, odbc_driver)
data = dd.read_sql_table('violations', connection_string, index_col='Summons Number')
在 列表 4.12 中,我们首先通过构建连接字符串来设置与数据库服务器的连接。对于这个特定的例子,我正在使用 Mac 上的官方 SQL Server Docker 容器中的 SQL Server。你的连接字符串可能根据你运行的数据库服务器和操作系统而有所不同。最后一行演示了如何使用 read_sql_table 函数连接到数据库并创建 DataFrame。第一个参数是你想要查询的数据库表名,第二个参数是连接字符串,第三个参数是作为 DataFrame 索引使用的列。这些是这个函数正常工作的三个必需参数。然而,你应该注意几个重要的假设。
首先,关于数据类型,你可能认为 Dask 直接从数据库服务器获取数据类型信息,因为数据库已经有一个定义好的模式。相反,Dask 会采样数据并推断数据类型,就像它在读取带有分隔符的文本文件时做的那样。然而,Dask 会顺序读取表中的前五行,而不是在数据集上随机采样数据。由于数据库确实有一个定义良好的模式,因此当从关系型数据库管理系统(RDBMS)读取数据时,Dask 的类型推断比从带有分隔符的文本文件读取数据时更为可靠。然而,它仍然不是完美的。由于数据可能是有序的,可能会出现边缘情况,导致 Dask 选择错误的数据类型。例如,一个字符串列可能有一些行,其中的字符串只包含数字(例如,“1456”,“2986”,等等)。如果数据以这种方式排序,只有这些类似数字的字符串出现在 Dask 推断数据类型时采样的样本中,它可能会错误地假设该列应该是整数数据类型而不是字符串数据类型。在这些情况下,你可能仍然需要像上一节中学到的那样进行一些手动模式调整。
第二个假设是数据应该如何分区。如果index_col(当前设置为'Summons Number')是数值或日期/时间数据类型,Dask 将自动推断边界并基于 256 MB 的块大小(大于read_csv的 64 MB 块大小)对数据进行分区。然而,如果index_col不是数值或日期/时间数据类型,你必须指定分区数量或通过边界来分区数据。
列表 4.13 在非数值或日期/时间索引上进行均匀分区
data = dd.read_sql_table('violations', connection_string, index_col='Vehicle Color', npartitions=200)
在列表 4.13 中,我们选择通过车辆颜色列对 DataFrame 进行索引,这是一个字符串列。因此,我们必须指定 DataFrame 应该如何分区。在这里,使用 npartitions 参数,我们告诉 Dask 将 DataFrame 分割成 200 个大小均匀的部分。或者,我们也可以手动指定分区的边界。
列表 4.14 在非数值或日期/时间索引上进行自定义分区
partition_boundaries = sorted(['Red', 'Blue', 'White', 'Black', 'Silver', 'Yellow'])
data = dd.read_sql_table('violations', connection_string, index_col='Vehicle Color', divisions=partition_boundaries)
列表 4.14 展示了如何手动定义分区边界。需要注意的是,Dask 将这些边界用作按字母顺序排序的半闭区间。这意味着你不会只有包含其边界定义的颜色分区的数据。例如,因为绿色在蓝色和红色之间按字母顺序排列,绿色汽车将落入红色分区。所谓的“红色分区”实际上是所有按字母顺序大于蓝色且小于或等于红色的颜色。这最初可能并不直观,可能需要一些时间来适应。
当你只传递所需的最小参数时,Dask 做出的第三个假设是你想要选择表中的所有列。你可以使用columns参数来限制你获取的列,该参数的行为类似于read_csv中的usecols参数。虽然你可以在参数中使用 SQLAlchemy 表达式,但我建议你避免将任何计算卸载到数据库服务器,因为这样你会失去 Dask 为你提供的并行化计算的优势。
列表 4.15 选择列子集
# Equivalent to:
# SELECT [Summons Number], [Plate ID], [Vehicle Color] FROM dbo.violations
column_filter = ['Summons Number', 'Plate ID', 'Vehicle Color']
data = dd.read_sql_table('violations', connection_string, index_col='Summons Number', columns=column_filter)
列表 4.15 展示了如何向连接查询添加列过滤器。在这里,我们创建了一个存在于表中的列名列表;然后我们将它们传递给 columns 参数。即使你在查询视图而不是表,你也可以使用列过滤器。
提供最小参数所做的第四个也是最后一个假设是模式选择。当我说“模式”时,我并不是指 DataFrame 使用的数据类型;我指的是 RDBMS 用来将表分组到逻辑集群(例如数据仓库中的 dim/fact 或事务数据库中的 sales、hr 等)的数据库模式对象。如果您不提供模式,数据库驱动程序将使用平台的默认模式。对于 SQL Server,这会导致 Dask 在 dbo 模式中查找违规表。如果我们把表放在不同的模式中,比如叫做 chapterFour 的模式,我们会收到“找不到表”的错误。
列表 4.16 指定数据库模式
# Equivalent to:
# SELECT * FROM chapterFour.violations
data = dd.read_sql_table('violations', connection_string, index_col='Summons Number', schema='chapterFour')
列表 4.16 展示了如何在 Dask 中选择特定的模式。将模式名称传递给 schema 参数将导致 Dask 使用提供的数据库模式而不是默认模式。
与 read_csv 类似,Dask 允许您将参数传递给底层调用 Pandas read_sql 函数,该函数在分区级别用于创建 Pandas DataFrame。我们已经在这里涵盖了所有最重要的函数,但如果您需要额外的定制程度,请查看 Pandas read_sql 函数的 API 文档。所有参数都可以使用 Dask DataFrame 提供的 *args 和 **kwargs 接口进行操作。现在我们将看看 Dask 如何处理分布式文件系统。
4.3 从 HDFS 和 S3 读取数据
虽然在您的工作中遇到的大多数数据集很可能存储在关系数据库中,但强大的替代品正迅速增长其受欢迎程度。最值得注意的是从 2006 年开始的分布式文件系统技术的发展。由 Apache Hadoop 和亚马逊的简单存储系统(简称 S3)等技术驱动,分布式文件系统为文件存储带来了与分布式计算为数据处理带来的相同好处:提高吞吐量、可扩展性和健壮性。将分布式计算框架与分布式文件系统技术结合使用是一种和谐的组合:在最先进的分布式文件系统中,例如 Hadoop 分布式文件系统(HDFS),节点了解数据本地性,允许计算被发送到数据而不是数据被发送到计算资源。这节省了大量时间和网络上的往返通信。图 4.7 展示了为什么保持数据隔离,以便单个节点可以有一些性能影响。

图 4.7 在没有分布式文件系统的情况下运行分布式计算
需要将数据分块并传输到集群中的其他节点,这导致了显著的瓶颈。在这种配置下,当 Dask 读取数据时,它将像往常一样对 DataFrame 进行分区,但其他工作节点无法进行任何工作,直到数据分区被发送到它们那里。由于这些 64 MB 的大小块通过网络传输需要一些时间,因此总计算时间将因数据在拥有数据的节点和其他工作节点之间来回传输所需的时间而增加。如果集群的规模有任何显著的增长,这个问题将变得更加严重。如果我们有数百(或更多)个工作节点同时争夺数据块,数据节点的网络堆栈可能会很容易因为请求而饱和,速度变得极慢。这两个问题都可以通过使用分布式文件系统来缓解。图 4.8 展示了如何通过将数据分布到工作节点来使过程更加高效。

图 4.8 在分布式文件系统上运行分布式计算
与仅在单个节点上保留数据以造成瓶颈不同,分布式文件系统会提前将数据分块并分散到多台机器上。在许多分布式文件系统中,存储数据块/分区的冗余副本是标准做法,既为了可靠性也为了性能。从可靠性的角度来看,将每个分区存储三份(这是一个常见的默认配置)意味着必须有两台不同的机器同时出现故障,才会发生数据丢失。在短时间内两台机器同时出现故障的概率远低于一台机器出现故障的概率,因此这增加了额外的安全层,而额外存储的成本却很小。
从性能的角度来看,将数据分散到整个集群中,使得包含数据的节点更有可能在请求时可用以运行计算。或者,如果持有该分区的所有工作节点都已经忙碌,其中一个可以将数据发送到另一个工作节点。在这种情况下,分散数据可以避免任何单个节点因为数据请求而饱和。如果一个节点正忙于提供大量数据,它可以将一些请求卸载到其他持有所需数据的节点。 图 4.9 阐述了为什么数据本地分布式文件系统具有更大的优势。

图 4.9 将计算任务发送到数据
控制分布式计算编排的节点(称为 driver)知道它想要处理的数据位于几个位置,因为分布式文件系统维护着系统内数据的目录。它首先会询问拥有数据的本地机器是否忙碌。如果某个节点不忙碌,驱动器将指示工作节点执行计算。如果所有节点都忙碌,驱动器可以选择等待直到某个工作节点空闲,或者指示另一个空闲的工作节点远程获取数据并运行计算。HDFS 和 S3 是最受欢迎的分布式文件系统之一,但它们在我们的用途上有一个关键的区别:HDFS 是设计为允许计算在提供数据的同一节点上运行,而 S3 则不是。亚马逊设计了 S3 作为一种仅专注于文件存储和检索的专用网络服务。在 S3 服务器上执行应用程序代码是完全不可能的。这意味着当你处理存储在 S3 中的数据时,你将始终需要将分区从 S3 传输到 Dask 工作节点以进行处理。现在让我们看看我们如何使用 Dask 从这些系统中读取数据。
列表 4.17 从 HDFS 读取数据
data = dd.read_csv('hdfs://localhost/nyc-parking-tickets/*.csv', dtype=dtypes, usecols=common_columns)
在 列表 4.17 中,我们有一个 read_csv 调用,现在应该看起来非常熟悉了。实际上,唯一改变的是文件路径。在文件路径前加上 hdfs:// 告诉 Dask 在 HDFS 集群上查找文件,而不是在本地文件系统中查找,而 localhost 指示 Dask 应该查询本地 HDFS NameNode 以获取文件位置信息。
所有之前你学到的关于 read_csv 的参数都可以在这里使用。这样,Dask 使得与 HDFS 的工作变得极其简单。唯一的额外要求是,你需要在每个 Dask 工作节点上安装 hdfs3 库。这个库允许 Dask 与 HDFS 进行通信;因此,如果你没有安装这个包,这个功能将无法工作。你可以简单地使用 pip 或 conda 安装这个包(hdfs3 位于 conda-forge 频道)。
列表 4.18 从 S3 读取数据
data = dd.read_csv('s3://my-bucket/nyc-parking-tickets/*.csv', dtype=dtypes, usecols=common_columns)
在 列表 4.18 中,我们的 read_csv 调用(再次)几乎与 列表 4.17 完全相同。然而,这次我们给文件路径加上了 s3:// 前缀,以告诉 Dask 数据位于 S3 文件系统中,而 my-bucket 让 Dask 知道在名为 my-bucket 的 AWS 账户关联的 S3 桶中查找文件。
为了使用 S3 功能,您必须在每个 Dask 工作节点上安装 s3fs 库。类似于 hdfs3,这个库可以通过 pip 或 conda(来自 conda-forge 频道)简单地安装。最终要求是每个 Dask 工作节点都正确配置以与 S3 进行身份验证。s3fs 使用 boto 库与 S3 进行通信。您可以在 boto.cloudhackers.com/en/latest/getting_started.html 上了解更多有关配置 boto 的信息。最常见的 S3 身份验证配置是使用 AWS 访问密钥和 AWS 秘密访问密钥。与其将这些密钥注入到您的代码中,不如使用环境变量或配置文件设置这些值。Boto 会自动检查环境变量和默认配置路径,因此无需直接将身份验证凭据传递给 Dask。否则,与使用 HDFS 一样,对 read_csv 的调用允许您执行所有与在本地文件系统上操作相同的事情。Dask 真正简化了与分布式文件系统的交互!
现在您已经有一些使用不同存储系统的经验,我们将通过讨论一种对快速计算非常有用的特殊文件格式来结束本章的“读取数据”部分。
4.4 以 Parquet 格式读取数据
CSV 和其他定界文本文件因其简单性和可移植性而很受欢迎,但它们并不是真正针对最佳性能进行优化的,尤其是在执行复杂的操作,如排序、合并和聚合时。虽然各种文件格式试图以多种方式提高效率,但结果参差不齐,其中较新的、知名度较高的文件格式之一是 Apache Parquet。Parquet 是由 Twitter 和 Cloudera 联合开发的高性能列式存储格式,其设计考虑到了在分布式文件系统上的使用。其设计在文本格式之上带来了几个关键优势:更高效的 IO 使用、更好的压缩和严格的类型。图 4.10 展示了 Parquet 格式与类似 CSV 的行导向存储方案在数据存储方面的差异。

图 4.10 Parquet 结构与定界文本文件的比较
使用行格式时,值根据数据的行位置在磁盘和内存中顺序存储。考虑一下如果我们想要对 x 执行聚合函数,比如求平均值,我们会做什么。为了收集 x 的所有值,我们需要扫描 10 个值才能得到我们想要的 4 个值。这意味着我们花费更多的时间等待 I/O 完成,只是为了丢弃从磁盘读取的超过一半的值。与此相比,列格式:在那个格式中,我们只需简单地获取 x 值的连续块,就能得到我们想要的四个值。这种查找操作要快得多,也更有效率。
应用数据列向分块技术的另一个显著优势是,数据现在可以按列进行分区和分布。这导致 shuffle 操作更快、更高效,因为只需要传输操作所需的列,而不是整个行。
最后,高效的压缩也是 Parquet 的一大优势。在列向数据中,可以对单个列应用不同的压缩方案,从而使数据以最有效的方式压缩。Python 的 Parquet 库支持许多流行的压缩算法,如 gzip、lzo 和 snappy。
要使用 Dask 中的 Parquet,你需要确保你已经安装了 fastparquet 或 pyarrow 库,这两个库都可以通过 pip 或 conda(conda-forge)安装。我通常会推荐使用 pyarrow 而不是 fastparquet,因为它对序列化复杂嵌套数据结构的支持更好。你还可以安装你想要的压缩库,例如 python-snappy 或 python-lzo,这些库也通过 pip 或 conda(conda-forge)提供。现在让我们再次查看 NYC 停车罚单数据集的读取操作,以 Parquet 格式。作为旁注,我们将在整本书中广泛使用 Parquet 格式,在下一章中,你将把一些 NYC 停车罚单数据集写入 Parquet 格式。因此,你将多次看到 read_parquet 方法!这次讨论只是为了简单地让你看看如何使用该方法。现在,不再拖延,这就是如何使用 read_parquet 方法。
列表 4.19 读取 Parquet 数据
data = dd.read_parquet('nyc-parking-tickets-prq')
列表 4.19 简单到极致!read_parquet 方法用于从一个或多个 Parquet 文件中创建 Dask DataFrame,唯一必需的参数是路径。注意这个调用可能看起来有些奇怪的地方:nyc-parking-tickets-prq 是一个目录,而不是一个文件。这是因为以 Parquet 格式存储的数据集通常在磁盘上预先分区,从而产生可能成百上千个单独的文件。Dask 提供了这个方法以方便您不必手动创建一个长的文件名列表来传递。如果您想指定单个 Parquet 文件,也可以在路径中指定,但更常见的是将 Parquet 数据集作为文件目录而不是单个文件来引用。
列表 4.20 从分布式文件系统中读取 Parquet 文件
data = dd.read_parquet('hdfs://localhost/nyc-parking-tickets-prq')
# OR
data = dd.read_parquet('s3://my-bucket/nyc-parking-tickets-prq')
列表 4.20 展示了如何从分布式文件系统中读取 Parquet 文件。与分隔文本文件类似,唯一的区别是指定一个分布式文件系统协议,例如 hdfs 或 s3,并指定数据的相关路径。
Parquet 以预定义的模式存储,因此没有选项可以更改数据类型。Dask 给您提供的唯一真正相关的选项来控制导入 Parquet 数据是列过滤器索引选择。它们的工作方式与其他文件格式相同。默认情况下,它们将从与数据一起存储的模式中推断出来,但您可以通过手动传递相关参数的值来覆盖该选择。
列表 4.21 指定 Parquet 读取选项
columms = ['Summons Number', 'Plate ID', 'Vehicle Color']
data = dd.read_parquet('nyc-parking-tickets-prq', columns=columns, index='Plate ID')
在 列表 4.21 中,我们选择了一些想要从数据集中读取的列,并将它们放入一个名为 columns 的列表中。然后我们将列表传递给 columns 参数,并通过将 Plate ID 传递给 index 参数来指定用作索引的列。这将产生一个只包含这里显示的三个列,并按 Plate ID 列排序/索引的 Dask DataFrame。
我们已经介绍了几种将数据从各种系统和格式导入 Dask 的方法。正如您所看到的,DataFrame API 提供了多种灵活的方式来以相对简单的方式摄取结构化数据。在下一章中,我们将介绍基本的数据转换,并自然地以多种不同的方式将数据写回。
概述
-
使用
columns属性可以检查 DataFrame 的列。 -
Dask 的数据类型推断不应依赖于大型数据集。相反,您应该根据常见的 NumPy 数据类型定义自己的模式。
-
Parquet 格式因其列式格式和高度可压缩性而提供良好的性能。尽可能地将您的数据集转换为 Parquet 格式。* *# 5
清理和转换 DataFrame
本章涵盖
-
选择和过滤数据
-
创建和删除列
-
查找和修复缺失值的列
-
索引和排序 DataFrame
-
使用连接和并操作组合 DataFrame
-
将 DataFrame 写入分隔文本文件和 Parquet
在上一章中,我们为纽约市停车罚单数据集创建了一个模式,并成功地将数据加载到 Dask 中。现在我们准备对数据进行清洗,以便我们可以开始分析和可视化它!作为一个友好的提醒,图 5.1 展示了我们迄今为止所做的工作以及我们数据科学工作流程中的下一步。

图 5.1 使用 Python 和 Dask 进行数据科学的流程
数据清洗是任何数据科学项目的重要组成部分,因为数据中的异常值和离群值可能会对许多统计分析产生负面影响。这可能导致我们对数据的错误结论,并构建出随时间推移无法站立的机器学习模型。因此,在继续进行探索性分析之前,我们尽可能地对数据进行清洗是很重要的。
当我们致力于清洗和准备数据以进行分析时,你也会学到许多 Dask 提供的用于操作 DataFrame 的方法。鉴于 Dask DataFrame API 中许多方法的语法相似性,在本章中应该非常明显地看出 Dask DataFrame 由 Pandas DataFrame 组成。一些操作看起来完全相同,但我们也会看到一些操作由于 Dask 的分布式特性而有所不同,以及如何应对这些差异。
在我们开始工作之前,这里是对我们迄今为止导入数据到 Dask 的代码的回顾。如果你正在与本章一起工作,你需要运行此代码。
列表 5.1 导入纽约市停车罚单数据
import dask.dataframe as dd
from dask.diagnostics import ProgressBar
import numpy as np
dtypes = {
'Date First Observed': np.str,
'Days Parking In Effect ': np.str,
'Double Parking Violation': np.str,
'Feet From Curb': np.float32,
'From Hours In Effect': np.str,
'House Number': np.str,
'Hydrant Violation': np.str,
'Intersecting Street': np.str,
'Issue Date': np.str,
'Issuer Code': np.float32,
'Issuer Command': np.str,
'Issuer Precinct': np.float32,
'Issuer Squad': np.str,
'Issuing Agency': np.str,
'Law Section': np.float32,
'Meter Number': np.str,
'No Standing or Stopping Violation': np.str,
'Plate ID': np.str,
'Plate Type': np.str,
'Registration State': np.str,
'Street Code1': np.uint32,
'Street Code2': np.uint32,
'Street Code3': np.uint32,
'Street Name': np.str,
'Sub Division': np.str,
'Summons Number': np.uint32,
'Time First Observed': np.str,
'To Hours In Effect': np.str,
'Unregistered Vehicle?': np.str,
'Vehicle Body Type': np.str,
'Vehicle Color': np.str,
'Vehicle Expiration Date': np.str,
'Vehicle Make': np.str,
'Vehicle Year': np.float32,
'Violation Code': np.uint16,
'Violation County': np.str,
'Violation Description': np.str,
'Violation In Front Of Or Opposite': np.str,
'Violation Legal Code': np.str,
'Violation Location': np.str,
'Violation Post Code': np.str,
'Violation Precinct': np.float32,
'Violation Time': np.str
}
nyc_data_raw = dd.read_csv('nyc-parking-tickets/*.csv', dtype=dtypes, usecols=dtypes.keys())
列表 5.1 应该看起来非常熟悉。在前几行中,我们导入了本章所需的模块。接下来,我们加载了在第四章中创建的模式字典。最后,我们通过读取四个 CSV 文件、应用模式和选择我们在模式中定义的列(usecols=dtypes.keys())来创建一个名为 nyc_data_raw 的 DataFrame。现在我们准备出发了!
5.1 与索引和轴一起工作
在第三章中,你学习了 Dask DataFrame 有三个结构元素:一个索引和两个轴(行和列)。为了刷新你的记忆,图 5.2 展示了 DataFrame 结构的视觉指南。

图 5.2 DataFrame 的结构
5.1.1 从 DataFrame 中选择列
到目前为止,我们对纽约市停车罚单数据集的处理并不多,只是为每个列选择了合适的数据类型并将数据读入 Dask。现在数据已加载并准备好供我们开始探索,一个让我们轻松进入探索的好地方是学习如何导航 DataFrame 的索引和轴。让我们从简单的事情开始:选择和过滤列。
列表 5.2 从 DataFrame 中选择单个列
with ProgressBar():
display(nyc_data_raw['Plate ID'].head())
# Produces the following output:
# 0 GBB9093
# 1 62416MB
# 2 78755JZ
# 3 63009MA
# 4 91648MC
# Name: Plate ID, dtype: object
你已经多次看到 head 方法会检索 DataFrame 的前 n 行,但在那些例子中,我们检索了整个 DataFrame 的前 n 行。在 列表 5.2 中,你可以看到我们在 nyc_data_raw 的右侧放置了一对方括号 ([…]),并在那些方括号内指定了 DataFrame 的一个列名(车牌 ID)。列选择器接受字符串或字符串列表,并应用一个过滤器到 DataFrame 上,只返回请求的列。在这种情况下,因为我们只指定了一个列,所以返回的不是另一个 DataFrame。相反,我们得到的是一个 Series 对象,它类似于没有列轴的 DataFrame。你可以看到,就像 DataFrame 一样,Series 对象有一个索引,实际上是从 DataFrame 复制的。然而,在选择列时,你通常会想要返回多个列。列表 5.3 展示了如何从 DataFrame 中选择多个列,而 图 5.3 显示了列表的输出。
列表 5.3 使用内联列表从 DataFrame 中选择多个列
with ProgressBar():
print(nyc_data_raw[['Plate ID', 'Registration State']].head())

图 5.3 列表 5.3 的输出
在这里,我们使用了 head 方法请求 Dask 返回“车牌 ID”列和“注册状态”列的前五行。列选择器可能看起来有点奇怪——为什么我们重复使用了方括号?那是因为我们正在创建一个字符串的内联列表。要返回多个列,你需要将一个列名(作为字符串)的列表传递给列选择器。外层的方括号对表示我们正在使用列选择器,而内层的方括号对是列名列表的内联构造器。你也可以传递一个存储为变量的列名列表。列表 5.4 应该会使列选择器和列表构造器的区别更加明显——注意,图 5.4 中显示的输出与 图 5.3 完全相同。
列表 5.4 使用声明列表从 DataFrame 中选择多个列
columns_to_select = ['Plate ID', 'Registration State']
with ProgressBar():
display(nyc_data_raw[columns_to_select].head())

图 5.4 列表 5.4 的输出
由于我们首先创建了一个列名列表并将其存储到名为 columns_to_select 的变量中,我们可以将之前声明的列名列表传递给列选择器。关于列选择器的一个重要注意事项是:您引用的每个列名都必须存在于 DataFrame 中。这与我们在 DataFrame 构造函数的 dtype 和 usecols 参数中看到的先前行为相反。使用这些参数,我们可以传递一个列名列表,其中一些列名在数据中不存在,Dask 将简单地忽略这些列。另一方面,如果我们传递给列选择器的列名在 DataFrame 中不存在,Dask 将返回一个键错误。
5.1.2 从 DataFrame 中删除列
很常见的情况是,您可能不想选择一小部分列,而是想保留除了少数列之外的所有列。您可以使用您刚刚学到的列选择器方法来做这件事,但这会涉及大量的输入,尤其是如果您的 DataFrame 有很多像这样的列!幸运的是,Dask 提供了一种方法,可以为您选择性地从 DataFrame 中删除列,保留您指定的列之外的所有列。列表 5.5 展示了如何使用 drop 方法从 DataFrame 中删除违规代码列,新的 DataFrame 的输出可以在 图 5.5 中看到。
列表 5.5 从 DataFrame 中删除单个列
with ProgressBar():
display(nyc_data_raw.drop('Violation Code', axis=1).head())

图 5.5 列表 5.5 的输出
与列选择器类似,drop 方法接受单个字符串或字符串列表,表示您希望删除的列名。注意,我们必须指定删除操作应在轴 1(列)上执行,因为我们想从 DataFrame 中删除一列。
由于 Dask 操作默认为轴 0(行),如果我们没有指定 axis=1,则删除操作的预期行为将是尝试找到并删除索引为“违规代码”的行。这正是 Pandas 的行为。但是,这种行为尚未在 Dask 中实现。相反,如果您忘记指定 axis=1,您将收到一个错误消息,表明 NotImplementedError: Drop currently only works for axis=1。
与在列选择器中指定多个列类似,您也可以指定要删除的多个列。此操作对 DataFrame 的影响可以在 图 5.6 中看到。
列表 5.6 从 DataFrame 中删除多个列
violationColumnNames = list(filter(lambda columnName: 'Violation' in columnName, nyc_data_raw.columns))
with ProgressBar():
display(nyc_data_raw.drop(violationColumnNames, axis=1).head())

图 5.6 列表 5.6 的输出
在 列表 5.6 中,我们在列列表生成方面变得有些复杂。我们决定删除任何列名中包含“Violation”一词的列。在第一行,我们定义了一个匿名函数来检查每个列名中是否存在“Violation”,并将其应用于 nyc_data_raw.columns 列表(该列表包含 nyc_data_raw DataFrame 的所有列名)使用 filter 函数。然后,我们将匹配我们的筛选条件的列名列表传递给 nyc_data_raw DataFrame 的 drop 方法。总的来说,该操作将从结果 DataFrame 中删除 13 列。
现在你已经看到了两种从 Dask DataFrame 中选择子集列的方法,你可能想知道,我应该何时使用 drop 和何时使用列选择器?从性能角度来看,它们是等效的,所以这实际上归结为你是否打算删除比想要保留的列更多的列。如果你打算删除比想要保留的列更多的列(例如,你想要保留 2 列并删除 42 列),使用列选择器将更方便。相反,如果你打算保留比想要删除的列更多的列(例如,你想要保留 42 列并删除 2 列),使用 drop 方法将更方便。
5.1.3 在 DataFrame 中重命名列
现在,关于列导航的最后一件事是重命名列。有时你可能正在处理数据,其标题中的列名描述性/友好性不强,而你想要对其进行清理。幸运的是,在纽约市停车罚单数据集中,我们确实有相当不错的列名,但我们将查看一个示例,说明如何在将来需要时重命名列。在下面的列表中,我们使用 rename 方法将车牌 ID 列的名称更改为车牌,并且可以看到这一结果在 图 5.7 中。
列表 5.7 重命名列
nyc_data_renamed = nyc_data_raw.rename(columns={'Plate ID':'License Plate'})
nyc_data_renamed

图 5.7 列表 5.7 的输出
columns 参数简单地接受一个字典,其中键是旧列名,值是新列名。Dask 将进行一对一的交换,返回具有新列名的 DataFrame。未在字典中指定的列将不会被重命名或删除。请注意,这些操作不会更改磁盘上的源数据,只会更改 Dask 在内存中持有的数据。在本章的后面部分,我们将介绍如何将修改后的数据写回磁盘。
5.1.4 从 DataFrame 中选择行
接下来,我们将看看如何跨行轴选择数据。在本章的后面部分,我们将讨论搜索和过滤行,这是在行轴上导航的更常见方式。但你也可能遇到你知道要检索的行范围的情况,在这种情况下,通过索引选择数据是获取数据的一种适当方式。这通常发生在你的数据按日期或时间索引时。请注意,索引不需要是唯一的,因此你可以使用索引来选择数据块。例如,你可能想获取 2015 年 4 月到 2015 年 11 月之间发生的所有行。就像在关系型数据库中的聚集索引一样,通过索引选择数据比搜索和过滤方法提供了性能提升。这主要是因为 Dask 按索引顺序存储和划分数据。因此,当寻求特定信息时,Dask 不必扫描整个数据集以确保返回你请求的所有数据。在下面的列表中,我们使用 loc 方法指定要检索的行索引,输出显示在 图 5.8 中。
列表 5.8 通过索引获取单行
with ProgressBar():
display(nyc_data_raw.loc[56].head(1))
由于 DataFrame 还未按特定列进行索引,因此我们选择的索引是 DataFrame 的默认顺序数字索引(从 0 开始)。这意味着如果我们从第一行开始计数,我们已经检索了 DataFrame 中的第 56 行。loc 在语法上与列选择器相似,因为它使用方括号来接受参数。然而,与列选择器不同的是,它不接受值列表。你可以传递单个值或使用 Python 的标准切片符号传递值范围。

图 5.8 列表 5.8 的输出
列表 5.9 通过索引获取行的连续切片
with ProgressBar():
display(nyc_data_raw.loc[100:200].head(100))
列表 5.9 展示了使用切片返回 100 到 200 之间的行。这与在纯 Python 中切片列表或数组时的表示法相同。使用这种切片符号访问行的一个影响是返回的行将是连续的。正如我们已经看到的,drop 函数在行轴上不起作用,因此没有方法可以仅选择例如第 1、3 和 5 行,而不使用过滤。然而,你可以从 Dask 中检索你想要的切片,并使用 Pandas 进行最终的过滤。
列表 5.10 使用 Dask 和 Pandas 过滤行的切片
with ProgressBar():
some_rows = nyc_data_raw.loc[100:200].head(100)
some_rows.drop(range(100, 200, 2))
在列表 5.10 中,我们正在使用我们的 Dask DataFrame (nyc_data_raw) 获取索引 100 到 200 之间的行切片。使用 head 方法触发了 Dask 的计算,并将结果作为 Pandas DataFrame 返回。由于您正在选择一小部分数据,您也可以使用 collect 方法,但使用 head 方法是一个好习惯,以避免意外检索到太多数据。我们将此结果存储到一个名为 some_rows 的变量中。然后,我们使用 some_rows(它是一个 Pandas DataFrame)上的 drop 方法来删除每一行并显示结果。drop 方法在 Pandas 中实现了对行轴的操作,因此如果您需要从 DataFrame 中删除行,将您的 Dask 数据子集降低到 Pandas 是一个好主意。然而,请注意,如果您试图拉下的切片太大,无法适应您的计算机内存,操作将因内存不足错误而失败。因此,此方法仅适用于您正在处理相当小的 Dask DataFrame 的情况。否则,您将需要依赖我们将在本章稍后部分介绍的更高级的过滤方法。
现在您对数据的导航越来越得心应手,我们将进入数据清理过程中的一个重要步骤:在我们的数据集中查找和修复缺失值。
5.2 处理缺失值
经常会遇到由于数据收集过程中的缺陷、随着时间的推移而变化的需求或数据处理和存储问题而导致缺失值的数据库集。无论原因如何,您都需要决定如何消除这些数据质量问题。在修复缺失值时,您有三个选项可供选择:
-
从您的数据集中删除具有缺失数据的行/列。
-
将缺失值分配一个默认值。
-
补充缺失值。
例如,假设您有一个包含各种人身高测量的数据集,其中一些身高测量值缺失。根据您分析的目标,您可以选择丢弃缺失身高测量的记录,或者通过找到您拥有的测量值的算术平均值来假设这些人的身高约为平均水平。不幸的是,没有一种“万能药”的方法来选择处理缺失值最佳方法。这很大程度上取决于缺失数据的上下文和领域。一个好的经验法则是与将解释和使用您分析的利益相关者合作,共同制定一个在您试图解决的问题的上下文中最有意义的共识方法。然而,为了给您一些选择,我们将在本节中介绍如何执行所有三种方法。
5.2.1 在 DataFrame 中计算缺失值数量
我们首先来看看纽约停车罚单数据中哪些列有缺失值。
列表 5.11 按列计算缺失值的百分比
missing_values = nyc_data_raw.isnull().sum()
with ProgressBar():
percent_missing = ((missing_values / nyc_data_raw.index.size) * 100).compute()
percent_missing
# Produces the following output:
Summons Number 0.000000
Plate ID 0.020867
Registration State 0.000000
Plate Type 0.000000
Issue Date 0.000000
Violation Code 0.000000
Vehicle Body Type 0.564922
Vehicle Make 0.650526
Issuing Agency 0.000000
Street Code1 0.000000
Street Code2 0.000000
Street Code3 0.000000
Vehicle Expiration Date 0.000002
Violation Location 15.142846
Violation Precinct 0.000002
Issuer Precinct 0.000002
Issuer Code 0.000002
Issuer Command 15.018851
Issuer Squad 15.022566
Violation Time 0.019207
Time First Observed 90.040886
Violation County 10.154892
Violation In Front Of Or Opposite 15.953282
House Number 16.932473
Street Name 0.054894
Intersecting Street 72.571929
Date First Observed 0.000007
Law Section 0.000007
Sub Division 0.012412
Violation Legal Code 84.970398
Days Parking In Effect 23.225424
From Hours In Effect 44.821011
To Hours In Effect 44.821004
Vehicle Color 1.152299
Unregistered Vehicle? 88.484122
Vehicle Year 0.000012
Meter Number 81.115883
Feet From Curb 0.000012
Violation Post Code 26.532350
Violation Description 11.523098
No Standing or Stopping Violation 99.999998
Hydrant Violation 99.999998
Double Parking Violation 99.999998
dtype: float64
列表 5.11 看起来应该有点熟悉——我们在第二章的 2017 年数据上做了同样的事情。为了回顾这里发生的事情,第一行创建了一个新的 Series,其中包含每列缺失值的计数。isnull方法扫描每一行,如果找到缺失值则返回True,如果没有找到则返回False。sum方法计算所有True值的总数,从而给出每列缺失行的总数。然后,我们用nyc_data_raw.index.size除以 DataFrame 中的行数,并将每个值乘以 100。调用compute方法触发计算,并将结果存储为名为percent_missing的 Pandas Series。
5.2.2 删除包含缺失值的列
现在我们知道了我们要处理的内容,我们将开始删除任何超过 50%值缺失的列。
列表 5.12 删除超过 50%缺失值的列
columns_to_drop = list(percent_missing[percent_missing >= 50].index)
nyc_data_clean_stage1 = nyc_data_raw.drop(columns_to_drop, axis=1)
在列表 5.12 中,我们首先过滤percent_missing序列,以找到具有 50%或更多缺失值的列的名称。这产生了一个看起来像这样的列表:
['Time First Observed',
'Intersecting Street',
'Violation Legal Code',
'Unregistered Vehicle?',
'Meter Number',
'No Standing or Stopping Violation',
'Hydrant Violation',
'Double Parking Violation']
然后,我们使用上一节中学到的drop方法从 DataFrame 中删除列,并将结果保存为名为nyc_data_clean_stage1的 DataFrame。在这里,我们任意选择了 50%,但删除具有非常大量缺失数据的列是非常典型的。以“双停车违规”列为例:它的值有 99.9%是缺失的。保留这样的稀疏列不太可能给我们带来很多信息,因此我们将它从数据集中删除。
5.2.3 插补缺失值
当你的列中只有少量缺失数据时,删除包含缺失数据的行更为合适。不过,在我们这样做之前,我们将对“车辆颜色”列进行插补。插补意味着我们将使用我们已有的数据来合理猜测缺失数据可能是什么。在这种情况下,我们将找到数据集中出现频率最高的颜色。虽然这个假设可能并不总是成立,但使用数据集中出现频率最高的值可以最大化你选择正确的概率。
列表 5.13 插补缺失值
with ProgressBar():
count_of_vehicle_colors = nyc_data_clean_stage1['Vehicle Color'].value_counts().compute()
most_common_color = count_of_vehicle_colors.sort_values(ascending=False).index[0] ①
nyc_data_clean_stage2 = nyc_data_clean_stage1.fillna({'Vehicle Color': most_common_color}) ②
列表 5.13 旨在通过假设它们是数据集中最常见的颜色来填充“车辆颜色”列中的缺失值。使用分类变量的最常见元素或连续变量的算术平均值来填充缺失值是一种常见的处理缺失值的方法,可以最小化对数据统计分布的影响。在列表 5.13 的第一行,我们使用列选择器选择“车辆颜色”列,并使用value_counts方法,该方法计算数据的唯一出现次数。以下是count_of_vehicle_colors的内容:
GY 6280314
WH 6074770
WHITE 5624960
BK 5121030
BLACK 2758479
BL 2193035
GREY 1668739
RD 1383881
SILVE 1253287
...
MATH 1
MARY 1
$RY 1
Name: Vehicle Color, Length: 5744, dtype: int64
如您所见,value_counts 的结果给我们一个包含每个颜色在索引中以及该颜色在数据中出现的次数的 Series。在 列表 5.13 的第二行中,我们将所有车辆颜色按出现频率从高到低排序,并获取出现频率最高的颜色的名称。如您所见,GY(灰色)是最常见的颜色代码,出现次数超过 620 万次。在 列表 5.13 的最后一行中,我们使用 fillna 方法用 GY 替换缺失的颜色。fillna 接受一个键值对字典,其中每个你想要填充的列的名称用作键,而你想要填充缺失值出现次数的内容用作值。在字典中未指定的列将不会被修改。
5.2.4 删除具有缺失数据的行
现在我们已经填充了车辆颜色列中的缺失值,我们将通过删除那些列中缺失值的行来处理其他低缺失值列。
列表 5.14 删除具有缺失数据的行
rows_to_drop = list(percent_missing[(percent_missing > 0) & (percent_missing < 5)].index)
nyc_data_clean_stage3 = nyc_data_clean_stage2.dropna(subset=rows_to_drop) ①
列表 5.14 从查找所有具有缺失值但缺失值不超过 5% 的列开始。我们将这个结果放入一个名为 rows_to_drop 的列表中。列表的内容如下:
['Plate ID',
'Vehicle Body Type',
'Vehicle Make',
'Vehicle Expiration Date',
'Violation Precinct',
'Issuer Precinct',
'Issuer Code',
'Violation Time',
'Street Name',
'Date First Observed',
'Law Section',
'Sub Division',
'Vehicle Color',
'Vehicle Year',
'Feet From Curb']
注意,我们不会删除这些列!我们只是会删除 DataFrame 中具有这些列缺失值的任何行。另外请注意,车辆颜色出现了。然而,因为我们将要应用我们的删除函数到 nyc_data_clean_stage2,所以不会有任何行因为缺失车辆颜色而被删除,因为它们已经被填充了。为了执行实际的删除,我们在 DataFrame 上使用 dropna 方法。如果我们不指定任何参数,dropna 将删除所有具有任何缺失值的行,所以请谨慎使用!subset 参数允许我们指定 Dask 将检查缺失值的列。如果一行在未指定的列中有缺失值,Dask 不会删除它们。
5.2.5 用默认值填充多个具有缺失值的列
现在我们几乎完成了。我们最后要做的就是用默认值填充我们剩余的具有缺失数据的列。我们需要确保的是,我们为列设置的默认值适合该列的数据类型。让我们检查我们还有哪些列需要清理以及它们的类型。
列表 5.15 查找剩余列的数据类型
remaining_columns_to_clean = list(percent_missing[(percent_missing >= 5) & (percent_missing < 50)].index)
nyc_data_raw.dtypes[remaining_columns_to_clean]
我们在 列表 5.15 中要做的第一件事,就像之前的某些列表一样,是找到我们仍然需要清理的列。对于任何具有超过 5% 缺失值且少于 50% 缺失值的列,我们将用默认值填充缺失值。这些列的列表存储在 remaining_columns_to_clean 变量中,我们使用这个变量与 nyc_data_raw DataFrame 的 dtypes 参数一起找到每列的数据类型。以下是输出结果:
Violation Location object
Issuer Command object
Issuer Squad object
Violation County object
Violation In Front Of Or Opposite object
House Number object
Days Parking In Effect object
From Hours In Effect object
To Hours In Effect object
Violation Post Code object
Violation Description object
dtype: object
如你所见,我们剩下要清理的所有列都是字符串类型。你可能想知道为什么它们显示为 object 而不是 np.str。这只是外观上的问题——Dask 只会明确显示数值数据类型(int、float 等)。任何非数值数据类型都会显示为 object。我们将用字符串 “Unknown” 填充每个列,以表示该值缺失。我们将再次使用 fillna 来填充值,因此我们需要为每个列准备一个包含值的字典。
列表 5.16 构建 fillna 的值字典
unknown_default_dict = dict(map(lambda columnName: (columnName, 'Unknown'), remaining_columns_to_clean))
列表 5.16 展示了如何构建这个字典。我们只是简单地从 remaining_columns_to_clean 列表中取出每个值,并输出一个包含列名和字符串 “Unknown.” 的元组。最后,我们将元组列表转换为字典,得到一个看起来像这样的对象:
{'Days Parking In Effect ': 'Unknown',
'From Hours In Effect': 'Unknown',
'House Number': 'Unknown',
'Issuer Command': 'Unknown',
'Issuer Squad': 'Unknown',
'To Hours In Effect': 'Unknown',
'Violation County': 'Unknown',
'Violation Description': 'Unknown',
'Violation In Front Of Or Opposite': 'Unknown',
'Violation Location': 'Unknown',
'Violation Post Code': 'Unknown'}
现在我们有了代表每个要填充的列及其填充值的字典,我们可以将其传递给 fillna。
列表 5.17 使用默认值填充 DataFrame
nyc_data_clean_stage4 = nyc_data_clean_stage3.fillna(unknown_default_dict)
简单明了。我们现在已经构建了最终的 DataFrame,nyc_data_clean_stage4,它是从 nyc_data_raw 开始,逐个应用四个缺失值处理技术到各个列中构建起来的。现在是我们检查工作的时候了。
列表 5.18 检查填充/删除操作的成功情况
with ProgressBar():
print(nyc_data_clean_stage4.isnull().sum().compute())
nyc_data_clean_stage4.persist()
# Produces the following output:
Summons Number 0
Plate ID 0
Registration State 0
Plate Type 0
Issue Date 0
Violation Code 0
Vehicle Body Type 0
Vehicle Make 0
Issuing Agency 0
Street Code1 0
Street Code2 0
Street Code3 0
Vehicle Expiration Date 0
Violation Location 0
Violation Precinct 0
Issuer Precinct 0
Issuer Code 0
Issuer Command 0
Issuer Squad 0
Violation Time 0
Violation County 0
Violation In Front Of Or Opposite 0
House Number 0
Street Name 0
Date First Observed 0
Law Section 0
Sub Division 0
Days Parking In Effect 0
From Hours In Effect 0
To Hours In Effect 0
Vehicle Color 0
Vehicle Year 0
Feet From Curb 0
Violation Post Code 0
Violation Description 0
dtype: int64
在 列表 5.18 中,我们启动了计算过程,并得到了在应用所有转换后剩余缺失值的数量。看起来我们一切都搞定了!如果你在阅读代码的同时运行它,你可能已经注意到计算花费了一些时间。现在是一个保存 DataFrame 的好时机。记住,保存 DataFrame 将会预先计算你到目前为止所做的工作,并将其存储在内存中的处理状态中。这将确保我们在继续分析时不需要重新计算所有这些转换。列表 5.18 的最后一行回顾了如何做到这一点。现在我们已经处理完所有缺失值,我们将探讨一些清理看起来有误的值的方法。
5.3 数据重编码
与缺失值一样,在您的数据集中也可能存在一些实例,数据不是缺失的,但其有效性值得怀疑。例如,如果我们遇到了纽约市停车罚单数据集中一辆声称是 Rocky Road 颜色的车辆,这可能会引起一些怀疑。更有可能的是,停车执法官员在写罚单时心里想的是当地冰淇淋店的当日口味,而不是手头的工作!我们需要有一种方法来清理这些类型的数据异常,一种方法是将这些值重新编码为更可能的选择(例如最频繁的值或算术平均值),或者将异常数据放入其他类别。就像填充缺失数据的方法一样,与您分析的使用者讨论这一点并就识别和处理异常数据达成一致的计划是值得的。
Dask 提供了两种方法来重新编码值。
列表 5.19 获取“车牌类型”列的值计数
with ProgressBar():
license_plate_types = nyc_data_clean_stage4['Plate Type'].value_counts().compute()
license_plate_types
列表 5.19 再次使用了您在上一个章节中学到的value_counts方法。这里我们使用它来获取过去四年中记录的所有车牌类型的唯一计数。车牌类型列记录了所讨论的车辆是否为乘用车、商用车辆等。以下是计算结果的简略输出:
PAS 30452502
COM 7966914
OMT 1389341
SRF 394656
OMS 368952
...
SNO 2
Name: Plate Type, Length: 90, dtype: int64
如您所见,绝大多数车牌类型是 PAS(乘用车)。与 COM(商用车辆)相结合,这两种车牌类型占整个 DataFrame 的超过 92%(大约 38M 行中的 41M 行)。然而,我们也可以看到有 90 种不同的车牌类型(长度:90)!让我们折叠“车牌类型”列,这样我们只有三种类型:PAS、COM 和其他。
列表 5.20 重新编码“车牌类型”列
condition = nyc_data_clean_stage4['Plate Type'].isin(['PAS', 'COM'])
plate_type_masked = nyc_data_clean_stage4['Plate Type'].where(condition, 'Other')
nyc_data_recode_stage1 = nyc_data_clean_stage4.drop('Plate Type', axis=1)
nyc_data_recode_stage2 = nyc_data_recode_stage1.assign(PlateType=plate_type_masked)
nyc_data_recode_stage3 = nyc_data_recode_stage2.rename(columns={'PlateType':'Plate Type'})
在列表 5.20 中,我们有很多事情要做。首先,我们需要构建一个布尔条件,我们将用它来与每一行进行比较。为了构建条件,我们使用isin方法。如果它检查的值包含在作为参数传递的对象列表中,则此方法将返回True。否则,它将返回False。当应用于整个“车牌类型”列时,它将返回一个包含True和False值的 Series。在下一行中,我们将True/False值的 Series 传递给where方法,并将其应用于“车牌类型”列。where方法保留所有True行的现有值,并用第二个参数中传递的值替换任何False行。这意味着任何没有 PAS 或 COM 车牌类型的行,其车牌类型将被替换为其他。这导致了一个新的 Series,我们将其存储在plate_type_masked变量中。
现在我们有了新的 Series,我们需要将其放回 DataFrame 中。为此,我们首先使用你之前见过几次的 drop 方法删除旧的 Plate Type 列。然后我们使用 assign 方法将 Series 添加到 DataFrame 中作为新列。因为 assign 方法使用 **kwargs 而不是字典来传递列名,就像许多其他基于列的方法一样,所以我们不能添加列名中包含空格的列。因此,我们创建名为“PlateType”的列,并使用本章 earlier 学习的 rename 方法将该列重命名为我们想要的名称。
现在我们查看值计数,你可以看到我们成功地将列合并了。
列表 5.21 重新编码后的值计数查看
with ProgressBar():
display(nyc_data_recode_stage3['Plate Type'].value_counts().compute())
这里是输出结果的样子:
PAS 30452502
COM 7966914
Other 3418586
Name: Plate Type, dtype: int64
现在看起来好多了!我们已经成功地将独特的车牌类别数量减少到三个。
我们可用的其他重新编码方法是 mask 方法。它的工作方式基本上与 where 方法相同,但有一个关键的区别:where 方法在传递给它的条件评估为 False 时替换值,而 mask 方法在传递给它的条件评估为 True 时替换值。为了举例说明如何使用这种方法,我们现在再次查看车辆颜色列,从检查该列的值计数开始:
GY 6280314
WH 6074770
WHITE 5624960
BK 5121030
...
MARUE 1
MARUI 1
MBWC 1
METBL 1
METBK 1
MET/O 1
MERWH 1
MERON 1
MERL 1
MERG 1
MEDS 1
MDE 1
MD-BL 1
MCNY 1
MCCT 1
MBROW 1
MARVN 1
MBR 1
MAZOO 1
MAZON 1
MAXOO 1
MAX 1
MAWE 1
MAVEN 1
MAUL 1
MAU 1
MATOO 1
MATH 1
MARY 1
$RY 1
Name: Vehicle Color, Length: 5744, dtype: int64
这个数据集包含超过 5,744 种独特颜色,但看起来有些颜色相当奇怪。在这个数据集中,超过 50% 的颜色只有单个条目,就像你在这里看到的许多条目一样。让我们通过将所有单色条目放入一个名为“其他”的类别中来减少独特颜色的数量。
列表 5.22 使用 mask 将独特颜色放入“其他”类别
single_color = list(count_of_vehicle_colors[count_of_vehicle_colors == 1].index)
condition = nyc_data_clean_stage4['Vehicle Color'].isin(single_color)
vehicle_color_masked = nyc_data_clean_stage4['Vehicle Color'].mask(condition, 'Other')
nyc_data_recode_stage4 = nyc_data_recode_stage3.drop('Vehicle Color', axis=1)
nyc_data_recode_stage5 = nyc_data_recode_stage4.assign(VehicleColor=vehicle_color_masked)
nyc_data_recode_stage6 = nyc_data_recode_stage5.rename(columns={'VehicleColor':'Vehicle Color'})
在 列表 5.22 中,我们首先通过过滤车辆颜色的值计数来获取我们数据集中只出现一次的所有颜色列表。然后,就像之前一样,我们使用 isin 方法构建一个 True/False 值的 Series。这将导致具有任何独特颜色的行返回 True,而没有独特颜色的行返回 False。我们将这个条件传递给 mask 方法,并附带 Other 作为替代值。这将返回一个 Series,其中具有任何独特颜色的所有行都将被替换为 Other,而没有独特颜色的行将保留其原始值。然后我们简单地遵循之前的过程将新列放回 DataFrame 中:删除旧列,添加新列,并将其重命名为我们想要的名称。
你可能想知道何时应该使用一种方法而不是另一种。它们在本质上都做同样的事情,并且具有相同的性能特征,但有时使用一种方法比另一种更方便。如果你有很多独特的值,但只想保留其中几个,使用where方法会更方便。相反,如果你有很多独特的值,但只想去除其中几个,使用mask方法会更方便。
现在你已经学习了一些用另一个静态值替换一个值的方法,我们将探讨一些更复杂的方法来使用函数创建派生列。
5.4 元素级操作
尽管你在上一节中学到的值重编码方法非常有用,你可能会经常使用它们,但了解如何创建由 DataFrame 中其他现有列派生的新列也是很好的。在结构化数据中,例如我们的纽约市停车罚单数据集,经常出现的一个场景是需要解析和处理日期/时间维度。在第四章中,当我们为数据集构建架构时,我们选择将日期列作为字符串导入。然而,为了正确地使用日期进行分析,我们需要将这些字符串转换为日期时间对象。Dask 允许你在读取数据时自动解析日期,但它对格式有些挑剔。另一种提供更多控制如何解析日期的方法是将日期列作为字符串导入,并在你的数据准备工作流程中手动解析它们。在本节中,我们将学习如何使用 DataFrame 上的 apply 方法来应用通用函数到我们的数据并创建派生列。更具体地说,我们将解析“发行日期”列,该列代表停车罚单的发行日期,并将该列转换为日期时间数据类型。然后我们将创建一个包含罚单发行月份和年份的新列,我们将在本章的后面再次使用它。考虑到这一点,让我们开始吧!
列表 5.23 解析“发行日期”列
from datetime import datetime
issue_date_parsed = nyc_data_recode_stage6['Issue Date'].apply(lambda x: datetime.strptime(x, "%m/%d/%Y"), meta=datetime)
nyc_data_derived_stage1 = nyc_data_recode_stage6.drop('Issue Date', axis=1)
nyc_data_derived_stage2 = nyc_data_derived_stage1.assign(IssueDate=issue_date_parsed)
nyc_data_derived_stage3 = nyc_data_derived_stage2.rename(columns={'IssueDate':'Issue Date'})
在列表 5.23 中,我们首先需要从 Python 的标准库中导入日期时间对象。然后,正如你在几个先前的例子中看到的,我们通过从我们的 DataFrame (nyc_data_recode_stage6) 中选择发行日期系列来创建一个新的 Series 对象,并使用 apply 方法执行转换。在这个特定的 apply 调用中,我们创建了一个匿名(lambda)函数,它从输入 Series 中取一个值,通过 datetime.strptime 函数运行它,并返回一个解析后的日期时间对象。datetime.strptime 函数简单地接受一个字符串作为输入,并使用指定的格式将其解析为日期时间对象。我们指定的格式是 "%m/%d/%Y",相当于 mm/dd/yyyy 日期格式。关于 apply 方法的最后一点要注意的是我们必须指定的 meta 参数。Dask 尝试推断传递给它的函数的输出类型,但最好明确指定数据类型。在这种情况下,类型推断将失败,因此我们需要传递一个明确的日期时间数据类型。接下来的三行代码现在应该非常熟悉:drop、assign、rename——我们之前学到用来向 DataFrame 添加列的模式。让我们看看发生了什么。
列表 5.24 检查日期解析的结果
with ProgressBar():
display(nyc_data_derived_stage3['Issue Date'].head())
查看该列,我们得到以下输出:
0 2013-08-04
1 2013-08-04
2 2013-08-05
3 2013-08-05
4 2013-08-08
Name: Issue Date, dtype: datetime64[ns]
列不再是字符串类型——这正是我们想要的!现在让我们使用我们新的日期时间列来提取月份和年份。
列表 5.25 提取月份和年份
issue_date_month_year = nyc_data_derived_stage3['Issue Date'].apply(lambda dt: dt.strftime("%Y%m"), meta=int)
nyc_data_derived_stage4 = nyc_data_derived_stage3.assign(IssueMonthYear=issue_date_month_year)
nyc_data_derived_stage5 = nyc_data_derived_stage4.rename(columns={'IssueMonthYear':'Citation Issued Month Year'})
这次,在列表 5.25 中,我们再次基于 DataFrame 中的发行日期列创建一个新的 Series。然而,我们通过 apply 传递的函数现在使用 Python 日期时间对象的 strftime 方法从日期时间中提取月份和年份,并返回一个格式化的字符串。我们选择将月份/年份字符串格式化为“yyyyMM”,正如 strftime 参数所指定的。我们还指定该函数的输出类型为整数,如 meta=int 参数所示。最后,我们像往常一样遵循熟悉的 assign-rename 模式将列添加到 DataFrame 中。然而,我们不需要删除任何列,因为我们不想用这个新列替换现有的列。我们只需将其添加到 DataFrame 中其他列的旁边。现在让我们看看这个新列的内容。
列表 5.26 检查新派生列
with ProgressBar():
display(nyc_data_derived_stage5['Citation Issued Month Year'].head())
查看该列,我们得到以下输出:
0 201308
1 201308
2 201308
3 201308
4 201308
Name: Citation Issued Month Year, dtype: object
完美!这正是我们想要的:一个漂亮的月份/年份字符串表示,表示引用被发行。现在我们已经创建了此列,我们最终将用月份/年份替换我们的顺序数字索引!这将使我们能够轻松地按月份/年份查找引用,并在接下来的章节中做其他一些很酷的事情,比如查看月度票据引用的起伏。
5.5 过滤和重新索引 DataFrame
在本章的早期,你学习了如何使用loc方法通过索引切片查找值。然而,我们还有一些更复杂的方法可以使用布尔表达式来搜索和过滤数据。让我们看看如何找到 10 月份的所有引用。
列表 5.27 查找 10 月份发生的全部引用
months = ['201310','201410','201510','201610','201710']
condition = nyc_data_derived_stage5['Citation Issued Month Year'].isin(months)
october_citations = nyc_data_derived_stage5[condition]
with ProgressBar():
display(october_citations.head())
在列表 5.27 中,我们首先创建了一个我们想要搜索的月份-年份组合列表(2013 年至 2017 年的 10 月)。然后我们使用熟悉的isin方法创建一个布尔序列,对于匹配months列表中月份-年份组合的每一行返回True,对于不匹配的每一行返回False。然后这个布尔序列被传递到选择器中。当结果被计算出来时,你会得到一个只包含 10 月份发生的引用的 DataFrame。
任何创建布尔序列的布尔表达式都可以这样使用。例如,如果我们不想选择某些月份,也许我们想要找到所有在给定日期之后发生的引用。我们可以使用 Python 内置的不等式运算符来完成这个任务。
列表 5.28 查找 2016 年 4 月 25 日之后的全部引用
bound_date = '2016-4-25'
condition = nyc_data_derived_stage5['Issue Date'] > bound_date
citations_after_bound = nyc_data_derived_stage5[condition]
with ProgressBar():
display(citations_after_bound.head())
在列表 5.28 中,我们使用大于运算符来查找所有发行日期大于 2016-04-25 的记录。这些布尔过滤表达式也可以通过 AND(&)和 OR(|)运算符连接起来,创建相当复杂的过滤器!我们将在下一个代码列表中查看如何做到这一点,其中我们还将为我们的 DataFrame 创建一个自定义索引。
到目前为止,我们一直依赖 Dask 默认的数值索引来处理我们的数据集。到目前为止,这已经很好地为我们服务了,但我们已经到达了一个阶段,忽视使用更合适的索引的好处可能会引起一些严重的性能问题。这一点在我们想要合并多个 DataFrame 时尤为重要,这正是本章下一节我们将要讨论的内容。虽然可以合并索引不匹配的 DataFrame,但 Dask 必须扫描两个 DataFrame 中用于连接两个 DataFrame 的每个可能的唯一键的组合,这使得整个过程相当缓慢。当我们连接具有相同索引的、按索引顺序排序和分区的两个 DataFrame 时,连接操作要快得多。因此,为了准备我们的数据以便与其他数据集合并,我们将调整索引和分区以与另一个数据集对齐。
在 DataFrame 上设置索引将按指定列对整个数据集进行排序。虽然排序过程可能相当慢,但你可以将排序 DataFrame 的结果持久化,甚至可以将你的数据以排序的 Parquet 文件的形式写回磁盘,这样你只需要排序一次。要设置 DataFrame 上的索引,我们使用set_index方法。
列表 5.29 在 DataFrame 上设置索引
with ProgressBar():
condition = (nyc_data_derived_stage5['Issue Date'] > '2014-01-01') & (nyc_data_derived_stage5['Issue Date'] <= '2017-12-31') ①
nyc_data_filtered = nyc_data_derived_stage5[condition]
nyc_data_new_index = nyc_data_filtered.set_index('Citation Issued Month Year') ②
在代码列表 5.29 中,我们正在使用本章前一部分创建的月份-年份列对 DataFrame 进行排序。这将返回一个新的按该列排序的 DataFrame,使我们能够更快地用于搜索、过滤和连接。如果你正在处理在存储之前已经排序的数据集,你可以传递可选参数sorted=True来告诉 Dask 数据已经排序。此外,你还有机会调整分区,类似于你之前学到的repartition选项。你可以使用npartitions参数指定一个分区数,以均匀分割数据,或者你可以使用divisions参数手动指定分区边界。由于我们已经按月份/年份排序了数据,让我们重新分区数据,以便每个分区包含一个月的数据。以下列表演示了如何进行此操作。
列表 5.30 按月份/年份重新分区数据
years = ['2014', '2015', '2016', '2017']
months = ['01','02','03','04','05','06','07','08','09','10','11','12']
divisions = [year + month for year in years for month in months] ①
with ProgressBar():
nyc_data_new_index.repartition(divisions=divisions) \
.to_parquet('nyc_data_date_index', compression='snappy') ②
nyc_data_new_index = dd.read_parquet('nyc_data_date_index') ③
在代码列表 5.30 中,我们首先生成一个月份/年份键的列表,该列表用于定义我们的分区方案(201401、201402、201403 等等)。接下来,我们将分区列表传递给repartition方法,将其应用于我们新索引的 DataFrame。最后,我们将结果写入 Parquet 文件,以避免在后续计算需要时重复排序数据,并将排序后的数据读入一个新的 DataFrame,称为nyc_data_new_index。现在我们已经为我们的 DataFrame 设置了索引,让我们通过讨论如何使用索引来合并 DataFrame 来结束本章。
5.6 连接和拼接 DataFrame
如果你之前使用过关系型数据库管理系统(RDBMS),例如 SQL Server,你很可能已经对连接和并集操作的力量有了认识。无论你是经验丰富的数据库管理员(DBA)还是刚开始接触数据工程,深入理解这些操作都是非常重要的,因为它们在分布式环境中提供了许多潜在的性能陷阱。首先,让我们简要回顾一下连接操作是如何工作的。图 5.9 展示了连接操作的视觉结果。
在连接操作中,两个数据对象(例如表和 DataFrame)通过将左侧对象中的列添加到右侧对象的列中合并成一个单一的对象。当我们把 Person 表和 Pet 表连接起来时,结果对象将 Pet 表的列添加到 Person 表列的右侧。使用合并后的表,我们可以确定对象之间的关系,例如杰克是我家那个永远饿着的棕色虎斑猫。这两个对象通过键逻辑上连接,或者是一个表中的列,用于在另一个表中查找值。

图 5.9 连接操作通过将右侧表的列添加到左侧表的列中来合并两个数据集。

图 5.10 我们可以判断杰克是我的猫,因为他的所有者 ID 是一个指向我的 Person ID 的关键。
在图 5.10 中,你可以看到这两个表之间的关键关系。杰克有一个所有者 ID 为 1000,这对应于我的 Person ID 为 1000。因此,如果你想获取关于杰克的额外信息,比如他的所有者是谁,你可以使用这种关系来查找我的信息。这种关系模型是复杂结构化数据集在现实世界中存储的主要方式。由于人、地点、事物和事件通常彼此之间有一定程度的关系,这种关系模型是结构化和组织相关数据集的直观方式。让我们再次仔细看看那个合并的表。

图 5.11 显示 Person 和 Pet 表之间的所有关键关系

图 5.12 纽约市平均月度温度
注意在图 5.11 中,莎拉·罗宾逊没有出现在合并的表中。碰巧的是,她也没有宠物。我们在这里看到的是所谓的内连接。这意味着只有两个对象之间有关系的记录会被放入合并的表中。没有关系的记录会被丢弃。如果合并这两个表的目的是为了更多地了解每只宠物的所有者,那么包括没有宠物的那些人就没有意义了。要执行内连接,你必须将how=inner作为join方法的参数。让我们看看这个操作的例子。
5.6.1 连接两个 DataFrame
返回到我们的纽约市停车罚单数据示例,我从美国国家海洋和大气管理局(NOAA)收集了一些纽约市的平均月度温度数据,并将这些数据以及代码笔记本一起包含在内。由于我们已经按月份/年份对停车罚单数据进行了索引,因此让我们添加在罚单发放月份的平均月度温度。也许我们会看到停车罚单在温暖的月份发生的趋势,因为那时停车执法可以上街。图 5.12 显示了温度数据的一个样本。
由于平均温度数据和停车罚单数据按相同的值(月份/年份的字符串表示)索引,这两个数据集是索引对齐的,因此将它们连接起来将是一个相当快速的操作!下一个列表显示了这看起来是什么样子。
列表 5.31 将纽约市停车罚单数据与 NOAA 天气数据合并
import pandas as pd
nyc_temps = pd.read_csv('nyc-temp-data.csv')
nyc_temps_indexed = nyc_temps.set_index(nyc_temps.monthYear.astype(str))
nyc_data_with_temps = nyc_data_new_index.join(nyc_temps_indexed, how='inner') ①
with ProgressBar():
display(nyc_data_with_temps.head(15))
在 列表 5.31 中,我们首先使用 Pandas 读取其他数据集。我选择使用 Pandas 读取此文件,因为它非常小(只有几 KB)。还值得演示 Pandas DataFrame 可以与 Dask DataFrame 连接。当然,Dask DataFrame 也可以以完全相同的方式与其他 Dask DataFrame 连接,因此您在这里有一定的灵活性。接下来,我将 nyc_temps DataFrame 的索引设置为使其与 Dask DataFrame 索引对齐。最后,我们在 nyc_data_new_index DataFrame 上调用 join 方法,并将温度 DataFrame 作为第一个参数传入。我们还指定 how=inner 来表示这是一个内连接。图 5.13 显示了 列表 5.31 的输出。

图 5.13 列表 5.31 的输出
如您所见,Temp 列被添加到原始 DataFrame 的右侧。当我们进入下一章时,我们将关注这一点。因为天气数据覆盖了整个停车罚单数据的时段,我们在连接过程中没有丢失任何行。而且,因为 DataFrame 是索引对齐的,所以这是一个非常快速的操作。虽然可以连接索引不对齐的 DataFrame,但这可能会对性能产生严重影响,因此在本书中不值得详细讨论。我强烈建议不要这样做。
如果您不想丢弃无关的记录,那么您将想要执行一个 外连接。

图 5.14 外连接的结果保留了没有任何关系的记录。
在 图 5.14 中,您可以看到,由于外连接,有主人的宠物仍然保持连接,但现在 Sarah 出现在我们的连接表中。这是因为外连接不会丢弃无关记录。相反,来自无关表的列将包含缺失值。您可以在 图 5.14 中看到,由于 Sarah 没有任何宠物,关于她的宠物信息是 NULL,这代表缺失/未知数据。Dask 的默认行为是执行外连接,所以除非您明确指定,否则连接两个表将产生类似的结果。
5.6.2 连接两个 DataFrame
将数据集组合的另一种方式是沿着行轴。在关系型数据库管理系统(RDBMS)中,这被称为并操作,但在 Dask 中被称为 连接 DataFrame。
图 5.15 展示了拼接 Person 和 More People 表的结果。与通过增加列数来添加更多数据的不同,你可以看到拼接是通过增加行数来添加更多数据的。在两个表中具有相同列名的列是对齐的,并且行被合并。你还可以看到当两个表没有完全相同的列时会发生什么。在这种情况下,Favorite Food 列在两个表中没有重叠,所以来自 Person 表的人的 Favorite Food 值被分配了一个缺失值。让我们看看在 Dask 中这看起来像什么。

图 5.15 拼接 Person 和 More People 表的结果
列表 5.32 拼接两个 DataFrame
fy16 = dd.read_csv('nyc-parking-tickets/Parking_Violations_Issued_-_Fiscal_Year_2016.csv', dtype=dtypes, usecols=dtypes.keys())
fy17 = dd.read_csv('nyc-parking-tickets/Parking_Violations_Issued_-_Fiscal_Year_2017.csv', dtype=dtypes, usecols=dtypes.keys())
fy1617 = fy16.append(fy17)
with ProgressBar():
print(fy16['Summons Number'].count().compute())
with ProgressBar():
print(fy17['Summons Number'].count().compute())
with ProgressBar():
print(fy1617['Summons Number'].count().compute())
在列表 5.32 中,我们暂时回到原始数据。因为我们不需要一个共同的索引,所以我们将从两个来源拼接原始数据。作为第四章中我们使用的方案构建和组合加载(使用文件路径中的*.csv)的替代方案,我们可以单独加载每个文件,并使用append方法将它们拼接起来。在语法上,这非常简单,append方法没有额外的参数。你可以看到fy16包含 10,626,899 行,fy17包含 10,803,028 行,而fy1617总共包含 21,429,927 行。
5.7 将数据写入文本文件和 Parquet 文件
现在我们已经投入了大量工作来清理数据集,这是一个保存进度的好时机。虽然不时使用persist方法对你的 DataFrame 是一个好的主意,以最大化性能,但其持久性只是临时的。如果你关闭笔记本服务器并结束你的 Python 会话,持久化的 DataFrame 将从内存中清除,这意味着当你准备好重新开始与数据一起工作时,你必须重新运行所有计算。
将数据从 Dask 写出相当简单,但有一个注意事项:由于 Dask 在进行计算时将数据划分为分区,其默认行为是每个分区写一个文件。如果你将数据写入分布式文件系统,或者你打算使用另一个分布式系统(如 Spark 或 Hive)来消费数据,这并不是一个问题,但如果你想要保存一个可以导入到其他数据分析工具(如 Tableau 或 Excel)的单个文件,你必须在保存之前使用repartition方法将所有数据折叠到一个单独的分区中。
在本节中,我们将探讨如何以你上一章学习到的两种格式写入数据:分隔符文本文件和 Parquet。
5.7.1 将数据写入分隔符文本文件
首先,我们将看看如何将数据写回到分隔符文本文件中。
列表 5.33 写入 CSV 文件
with ProgressBar():
if not os.path.exists('nyc-final-csv'): ①
os.makedirs('nyc-final-csv')
nyc_data_with_temps.repartition(npartitions=1).to_csv('nyc-final-csv/part*.csv') ②
列表 5.33 展示了如何将本章前几节中创建的合并数据集保存到单个 CSV 文件中。要注意的一件事是数据的文件名:part*.csv。通配符 * 将由 Dask 自动填充,表示与该文件对应的分区号。由于我们将所有数据合并到一个单独的分区中,因此只会写入一个 CSV 文件,其名称为 part0.csv。生成单个 CSV 文件可能对导出数据以在其他应用程序中使用很有用,但 Dask 是一个分布式库。从性能角度来看,保持数据拆分为多个文件,这些文件可以并行读取,更有意义。事实上,Dask 的默认行为是将每个分区保存到单独的文件中。接下来,我们将查看可以在 to_csv 方法中设置的几个其他重要选项,并将数据写入多个 CSV 文件。
在这两种方法中默认设置都做了一些关于输出文件形状的假设。具体来说,默认情况下,to_csv 方法将创建文件,这些文件
-
使用逗号(
,)作为列分隔符 -
将缺失值(
np.nan)保存为空字符串("") -
包含标题行
-
将索引作为列包含
-
不使用压缩
列表 5.34 写入具有自定义选项的分隔文本文件
with ProgressBar():
if not os.path.exists('nyc-final-csv-compressed'):
os.makedirs('nyc-final-csv-compressed')
nyc_data_with_temps.to_csv(
filename='nyc-final-csv-compressed/*',
compression='gzip',
sep='|',
na_rep='NULL',
header=False,
index=False)
列表 5.34 展示了如何更改和自定义这些假设。这段特定的代码将 data DataFrame 写入 48 个文件,这些文件将使用 gzip 进行压缩,将管道(|)用作列分隔符而不是逗号,将任何缺失值作为 NULL 写入,并且不会写入标题行或索引列。你可以调整这些选项中的任何一项以满足你的需求。
5.7.2 写入 Parquet 文件
写入 Parquet 与写入分隔文本文件非常相似。关键区别在于,我们不是为单个文件名指定方案,而是将 Parquet 简单地保存到目录中。由于 Parquet 最好由分布式系统使用,因此调整分区(就像我们在保存分隔文本文件时做的那样)并不值得。Parquet 的选项集非常简单。
列表 5.35 写入 DataFrame 到 Parquet
with ProgressBar():
nyc_data_with_temps.to_parquet('nyc_final', compression='snappy')
列表 5.35 展示了使用 snappy 压缩编解码器在本地文件系统上写入 Parquet 数据的方法。也可以简单地通过遵循上一章中已经学到的路径机制将数据保存到 HDFS 或 S3。与非分区文本文件一样,Dask 将为每个分区写入一个 Parquet 文件。
就这样了。在本章中,我们介绍了许多数据操作技术,以及 Dask DataFrame API 的很大一部分。我希望你现在对操作 DataFrame 的能力更有信心。我们已经清理了数据,准备开始分析它。由于你已经保存了 DataFrame,你可以休息一下,喝杯咖啡,准备进入有趣的部分:数据分析!
摘要
-
从 DataFrame 中选择列使用方括号([…])表示法。您可以通过将列名列表传递到列选择器括号中来选择多个列。
-
默认情况下,
head方法显示 DataFrame 的前 10 行。您也可以指定要查看的行数。 -
可以使用
drop方法从 DataFrame 中删除列。然而,由于 DataFrame 是不可变的,列不会被从原始 DataFrame 中删除。 -
可以使用
dropna方法从 DataFrame 中删除空值。 -
使用 drop-assign-rename 模式在 DataFrame 中替换列,例如在解析或重新编码列中的值时。
-
可以使用
apply方法在 DataFrame 上执行元素级转换函数。 -
支持布尔运算符(如 >, <, =)用于过滤 DataFrame。如果您的过滤条件需要多个输入值,您可以使用类似 NumPy 风格的布尔函数,如
isin。 -
可以使用
merge方法通过关系连接两个 DataFrame。您甚至可以将 Pandas DataFrame 与 Dask DataFrame 进行合并! -
可以使用
append方法将 DataFrame 连接(合并)。
6
总结和分析 DataFrame
本章内容涵盖
-
为 Dask Series 生成描述性统计
-
使用 Dask 内置的聚合函数聚合/分组数据
-
创建您自己的自定义聚合函数
-
使用滚动窗口函数分析时间序列数据
在上一章的结尾,我们得到了一个可以开始深入分析和研究的数据集。然而,我们没有对数据中可能出现的每一个问题进行彻底搜索。实际上,数据清洗和准备过程可能需要更长的时间来完成。数据科学家中有一个常见的说法,数据清洗可能占用项目总时间的 80% 或更多。通过您在上一章中学到的技能,您已经具备了应对在野外遇到的所有最常见数据质量问题的良好基础。作为一个友好的提醒,图 6.1 展示了我们的工作流程进展——我们几乎已经到达了中点!

图 6.1 使用 Python 和 Dask 进行数据科学的流程
现在,我们将把注意力转向任何数据科学项目中最喜欢的部分——探索性数据分析。探索性数据分析的目标是了解您数据的“形状”,在您的数据集中找到有趣的模式和相关性,并识别出对预测目标变量可能有用的数据集中显著关系。与上一章一样,我们将强调在 Dask 分布式范式下进行数据分析时必要的差异和特殊考虑。
6.1 描述性统计
在第五章末尾创建的最终数据集中,我们大约有 4100 万张停车罚单——这是一个大量的观测值!可能有趣的是知道纽约市街道上停放的车辆的平均年龄。是新车比旧车多吗?最老的非法停放的车辆有多老——我们是谈论 T 型车还是雷鸟?使用描述性统计,我们将回答以下问题:
使用纽约市停车罚单数据,纽约市街道上非法停放的车辆的平均年龄是多少?我们可以从车辆的年龄中推断出什么?
6.1.1 描述性统计是什么?
在开始编写代码之前,我们将简要概述如何理解我们数据的形状。这通常由七个用于描述它的数学属性定义:
-
最小的值(也称为最小值)
-
最大的值(也称为最大值)
-
所有数据点的平均值(也称为均值)
-
最小值和最大值之间的中点(也称为中位数)
-
数据从平均值(也称为标准差)的偏差程度
-
最常出现的观测值(也称为众数)
-
数据点在中心点的左右两侧的平衡程度(也称为偏度)
你无疑之前听说过这些术语中的一些,因为这些概念通常作为任何基础统计学课程的基石来教授。这些描述性统计虽然简单,但是非常强大的描述各种数据的方式,并告诉我们关于我们数据的重要信息。作为一个复习,这里有一个描述性统计的视觉指南。

图 6.2 描述性统计的视觉指南
图 6.2 展示了一个假设变量的直方图,其中进行了 100,000 次观测。观测到的值沿着 X 轴分布,相对频率(每个值被观测到的频率占所有观测值的百分比)沿着 Y 轴绘制。你可以从中得到的信息是,我们观测到了一系列的值。有时我们观测到 0 的值,有时是 5.2,有时是-3.48,以及许多介于它们之间的值。由于我们知道这个假设变量的观测值并不总是保持不变,所以我们称它为随机分布变量。为了应对这个变量的随机性,设定一些关于我们可能观测到的范围和最可能观测到的值的期望将是有用的。这正是描述性统计试图实现的目标!
回到图 6.2,看看最小值和最大值。正如它们的名称所暗示的,它们是所做观察范围边界点。没有观察到低于最小值(-10)的观察值,同样,也没有观察到高于最大值(10)的观察值。这告诉我们,未来观察到这个范围之外的任何观察值的可能性非常小。接下来,看看平均值。这是分布的“质心”,这意味着如果我们进行随机观察,值最有可能接近这个点。你可以看到概率是 0.16,这意味着大约 16%的时间我们可以期望观察到值为 0。但其他 80%的时间可能发生什么呢?这就是标准差发挥作用的地方。标准差越高,我们观察到远离平均值的值的可能性就越大。

图 6.3 标准差的比较
你可以在图 6.3 中看到这种行为。当标准差较小时,随着你远离平均值,概率会急剧下降,这意味着远离平均值的值不太可能被观察到。相反,当标准差较大时,下降趋势会更加平滑,这表明远离平均值的值更有可能被观察到。在极端情况下,标准差为 0 表示该值是恒定的,并且不是一个随机分布的变量。如果我们观察到车辆年龄的小标准差,我们可以得出结论,许多非法停放在纽约的车辆年龄大致相同——或者说,另一种说法是,观察到的车辆年龄变化很小。如果我们观察到大的标准差,这意味着存在高度多样化的新旧车辆混合。在图 6.3 中,请注意,这两个分布都是对称的。这意味着观察到值为 1 的概率与观察到值为-1 的概率相等,依此类推。概率下降的速度不会因我们远离曲线最高点(代表最常观察到的值,或众数)的方向而有所不同。这种对称性(或潜在的不对称性)就是偏度所描述的。
在图 6.4 中,你可以看到偏度差异对分布形状的影响。如图 6.4 上部中央所示,当偏度为 0 时,分布是对称的。从模式向任一方向的移动都会导致概率的相同下降。这也使得这个分布的均值和模式相等。相反,当偏度为负,如图 6.4 下左所示,大于模式的值的概率下降非常陡峭,而小于模式的值的概率下降则更为平缓。这意味着小于模式的值比大于模式的值更有可能被观察到(但模式仍然是观察到的最可能值)。此外,请注意,在这种情况下,均值位于其原始值的左侧。与之前的均值为 0 不同,现在它大约在 -2.5 左右。正偏度,如图 6.4 下右所示,是负偏度的镜像。大于模式的值比小于模式的值更有可能被观察到。均值也位于 0 的右侧。用分析车辆年龄的话来说,如果我们观察到负偏度,这表明比平均年龄更多的车辆是较新的。相反,正偏度表明比平均年龄更多的车辆是较老的。通常,当偏度大于 1 或小于 -1 时,我们会确定分布是显著偏斜的,并且远离对称。

偏度视觉比较
6.1.2 使用 Dask 计算描述性统计
现在你已经对如何理解和解释这些描述性统计有了很好的了解,让我们来看看如何使用 Dask 来计算这些值。为此,我们首先需要计算每辆车在收到传票时的年龄。数据中包含了传票日期和汽车的型号年份,因此我们将使用这些信息来创建一个派生列。和往常一样,我们将从加载上一章中产生的数据开始。
列表 6.1 加载分析数据
import dask.dataframe as dd
import pyarrow
from dask.diagnostics import ProgressBar
nyc_data = dd.read_parquet('nyc_final', engine='pyarrow')
列表 6.1 中的所有内容都应该看起来很熟悉;我们只是导入所需的库,然后读取在第五章末生成的 Parquet 文件。由于我们之前没有查看车辆年份列以确保没有奇怪的数据,让我们先做这件事。
列表 6.2 检查车辆年份列的异常
with ProgressBar():
vehicle_age_by_year = nyc_data['Vehicle Year'].value_counts().compute()
vehicle_age_by_year
# Produces the following (abbreviated) output
0.0 8597125
2013.0 2847241
2014.0 2733114
2015.0 2423991
...
2054.0 61
2058.0 58
2041.0 56
2059.0 55
如列表 6.2 所示,我们的值计数显示了一些据说在公元 0 年制造的车辆以及远至未来的车辆。除非有时光旅行的可能性或其他时空连续体中的异常,否则这些很可能是错误数据。我们将过滤掉它们,以避免将这些错误数据引入我们的统计分析中。
列表 6.3 过滤掉错误数据
with ProgressBar():
condition = (nyc_data['Vehicle Year'] > 0) & (nyc_data['Vehicle Year'] <= 2018) ①
vehicle_age_by_year = nyc_data[condition]['Vehicle Year'].value_counts().compute().sort_index() ②
vehicle_age_by_year ③
# Produces the following abbreviated output
1970.0 775
1971.0 981
1972.0 971
...
2014.0 2733114
2015.0 2423991
2016.0 1280707
2017.0 297496
2018.0 2491
Name: Vehicle Year, dtype: int64
在 列表 6.3 中,我们使用你在第五章中学到的相同布尔过滤来过滤掉在年份 0 或 2018 年之后制造的任何车辆。我选择 2018 年作为我的上限而不是 2017 年,因为汽车制造商通常领先一个车型年。由于这个数据集跨越 2017 年,因此很可能(如果不是所有的话)2018 款车型的观测值都是合法的。现在的输出看起来好多了!
现在,让我们在过滤后的数据中创建派生列。为此,我们将应用一个自定义函数,该函数从日期列中减去车辆年份列,并将结果添加到 DataFrame 中。我们将在 图 6.5 中列出的四个步骤中执行此操作。

图 6.5 在罚单发行时计算每辆车的年龄
现在,我们将用代码实现这四个步骤。
列表 6.4 在罚单日期计算车辆年龄
nyc_data_filtered = nyc_data[condition] ①
def age_calculation(row):
return int(row['Issue Date'].year - row['Vehicle Year']) ②
vehicle_age = nyc_data_filtered.apply(age_calculation, axis=1, meta=('Vehicle Age', 'int')) ③
nyc_data_vehicle_age_stg1 = nyc_data_filtered.assign(VehicleAge=vehicle_age)
nyc_data_vehicle_age_stg2 = nyc_data_vehicle_age_stg1.rename(columns={'VehicleAge':'Vehicle Age'}) ④
nyc_data_with_vehicle_age = nyc_data_vehicle_age_stg2[nyc_data_vehicle_age_stg2['Vehicle Age'] >= 0] ⑤
列表 6.4 也应该看起来非常熟悉。在第一行,我们将过滤条件应用于我们的数据,从而去除具有无效车辆年份的观测值。接下来,我们创建年龄计算函数。这个函数将每个 DataFrame 行作为输入,从“发行日期”列中获取年份,并找出罚单发行年份与车辆制造年份之间的差异。因为 row['Issue Date'] 代表一个 datetime 对象,我们可以通过其 year 属性来访问其年份值。该函数在第三行应用于 DataFrame 的每一行,返回包含每辆车年龄的 Series。提醒一下,apply 方法中的 meta 参数接受一个元组,其中第一个元素是新的 Series 的名称,第二个元素是数据类型。接下来的两行使用你在第五章中学到的 assign-rename 模式向 DataFrame 添加一个列并将其重命名为友好的名称。在最后一行,我们应用另一个过滤条件以去除任何导致无效年龄计算的行。例如,如果罚单是在 2014 年撰写的,而车辆年份被记录为 2018 年,这将导致无效的车辆年龄为 -4。
现在我们已经准备好计算描述性统计了!然而,在运行计算之前,我们应该解决一个问题。这些计算(如平均值和标准差)都需要完全扫描整个数据集,因此它们可能需要很长时间才能完成。例如,计算平均值需要将 DataFrame 中的所有值相加,然后将总和除以 DataFrame 中的行数。由于需要对日期时间列(通常日期时间操作较慢)进行对象操作,计算车辆的年龄也是相当复杂的。这是一个使用 persist 方法保存昂贵计算结果的内存中的好机会。然而,我们将把中间结果保存为 Parquet 文件,因为我们在后面的章节中还会使用这些数据。通过将数据保存到磁盘,你可以在以后再次访问数据时无需重新计算,而且你不需要无限期地保持 Jupyter notebook 服务器运行,直到再次需要数据。作为一个简短的提醒,我们需要传递给 to_parquet 方法的两个参数是文件名和我们要用来写入数据的 Parquet 库。与其他示例一样,我们将坚持使用 PyArrow。
列表 6.5 将中间结果保存到 Parquet
with ProgressBar():
files = nyc_data_with_vehicle_age.to_parquet('nyc_data_vehicleAge', engine='pyarrow')
nyc_data_with_vehicle_age = dd.read_parquet('nyc_data_vehicleAge', engine='pyarrow')
一旦这两行代码执行完成(在我的系统上大约花费了 45 分钟),我们就能更快速、更高效地计算描述性统计。为了方便起见,Dask 提供了内置的描述性统计函数,这样你就不必编写自己的算法。我们将回顾本节中提到的五种描述性统计:平均值、标准差、最小值、最大值和偏度。
列表 6.6 计算描述性统计
from dask.array import stats as dask_stats
with ProgressBar():
mean = nyc_data_with_vehicle_age['Vehicle Age'].mean().compute()
stdev = nyc_data_with_vehicle_age['Vehicle Age'].std().compute()
minimum = nyc_data_with_vehicle_age['Vehicle Age'].min().compute()
maximum = nyc_data_with_vehicle_age['Vehicle Age'].max().compute()
skewness = float(dask_stats.skew(nyc_data_with_vehicle_age['Vehicle Age'].values).compute()) ①
如你在列表 6.6 中看到的,对于平均值、标准差、最小值和最大值,你可以简单地作为车辆年龄序列的内置方法来调用它们。这个集合的例外是计算偏度。正如你可能期望的那样,没有 skew 方法。然而,Dask 在 dask.array 包中包含了许多统计测试,我们还没有探讨过(第九章将深入探讨 Dask Array 函数)。为了计算这个示例的偏度,我们必须将车辆年龄从 Dask 序列对象转换为 Dask 数组对象,因为 dask.array 中的偏度函数需要一个 Dask 数组作为输入。为此,我们可以简单地使用车辆年龄序列的 values 属性。然后我们可以将其输入到 skew 函数中,以计算偏度。检查我们计算的结果,我们发现列出的值在表 6.1 中。
表 6.1 车辆年龄列的描述性统计
| 统计量 | 车辆年龄 |
|---|---|
| 平均值 | 6.74 |
| 标准差 | 5.66 |
| 最小值 | 0 |
| 最大值 | 47 |
| 偏度 | 1.01 |
多么有趣!平均而言,被罚款的车辆大约是七年车龄。有一些全新的车辆(用 0 年的最小年龄表示),最老的车辆是 47 岁。标准差为 5.66 表明,平均而言,这个数据集中的车辆年龄与 6.74 年的平均年龄相差+/- 5.66 年。最后,数据呈正偏态,这意味着 6.74 年以下的车辆比 6.74 年以上的车辆更常见。
基于对汽车的一些基本直觉,所有这些数字都应该是有意义的。当你考虑到许多超过 12 年的车辆开始面临其可靠寿命的终结时,预计随着这些车辆更有可能发生故障和报废,道路上超过这个年龄的车辆数量将会有显著下降。鉴于新车价格昂贵且在其寿命的前几年内迅速贬值,购买三到五年车龄的二手车辆在经济上更为合理。这有助于解释平均车辆年龄为 6.74 年的现象,因为买家通过购买这个年龄段的车辆,可以面临最轻微的折旧,同时还能拥有一个可靠车辆长达五年或更长时间。同样,尽管观察到一些极老的车辆,但我们可以看出最大年龄远远高于平均值的标准差,这表明在纽约市街道上看到一辆 47 岁的车辆是非常罕见的。
6.1.3 使用 describe 方法进行描述性统计
如果不想为每个统计量编写代码,Dask 还为你提供了一个计算描述性统计的快捷方式。
列表 6.7 计算一系列描述性统计量
with ProgressBar():
descriptive_stats = nyc_data_with_vehicle_age['Vehicle Age'].describe().compute() ①
descriptive_stats.round(2) ②
# Produces the following output
count 28777416.00
mean 6.74
std 5.66
min 0.00
25% 4.00
50% 8.00
75% 13.00
max 47.00
dtype: float64
在列表 6.7 中,你可以看到describe方法生成了一个包含各种常见描述性统计量的 Series。你得到了非空值的计数,以及平均值、标准差、最小值和最大值。你还得到了第 25 百分位数、第 75 百分位数和中位数,这些对于理解数据的分布也很有用。使用describe方法的一个优点是,它实际上比分别调用四个compute方法来获取平均值、标准差等更有效率。这是因为当你一次性请求多个聚合函数时,Dask 可以应用一定程度的代码优化。
现在你已经学会了如何生成描述性统计,你可以使用这些方法来理解任何数据集中的数值变量。能够量化并描述变量的随机行为是一个良好的开端,但探索性数据分析的另一个重要角度是理解那些感知到的随机性是否可以真正解释。为此,我们需要查看我们数据集中变量之间的关系。这是我们探索性数据分析的第二个目标:寻找有趣的模式和相关性。我们将在下一部分进行探讨。
6.2 内置聚合函数
你可能还记得在第五章中,我们将一些温度数据与纽约市停车违章数据集相结合,为此,我们创建了一个包含每个传票签发月份和年份的列。让我们使用这些数据来回答以下问题:
使用纽约市停车违章数据,每个月签发了多少停车传票?平均温度与签发传票的数量是否相关?
6.2.1 什么是相关性?
当我们谈论数据中的模式和关系时,我们通常更具体地谈论两个变量的相关性。相关性量化了变量相对于彼此的移动情况。它可以帮助我们回答像“当天气变暖时,签发的传票数量是否更高,而在天气变冷时是否更低?”这样的问题。这可能是有趣的:也许纽约市停车管理局在恶劣天气时不会派出那么多官员巡逻。相关性将告诉我们温度和签发传票数量之间关系的强度和方向。

图 6.6 相关性的视觉指南
图 6.6 展示了我们所说的关系的强度和方向。散点图 A 展示了正相关:当 X 轴上的变量增加(向右移动)时,Y 轴上的变量也倾向于增加(向上移动)。这是一个正相关——当一个变量增加时,另一个变量也增加。这也是一个强相关,因为点都相对接近红线。一个非常明确的模式很容易被发现。散点图 B 展示了不相关的变量。当 X 变量增加时,Y 的值有时增加,有时减少,有时保持不变。在这里我们没有发现可辨别的模式,因此这些数据是不相关的。最后,散点图 C 展示了强烈的负相关。当 X 变量增加时,Y 变量减少。用我们想要研究的引用与温度之间的相关性来表述,如果两者呈正相关,这意味着我们通常会在较暖的月份观察到更多的引用发布,而在较冷的月份发布的引用较少。如果两者呈负相关,我们会观察到相反的情况:在较冷的月份发布更多引用,而在较暖的月份发布较少引用。如果两者不相关,那么我们不会看到任何可辨别的模式。有时我们会看到在温暖的月份有大量的引用,而有时在外界很热的时候看到很少的引用发布。我们也会有时看到在寒冷的月份有大量的引用发布,而有时在寒冷的月份发布的引用很少。
6.2.2 使用 Dask DataFrames 计算相关性
现在我们来看看如何在 Dask 中执行这些计算。如前所述,我们首先需要计算每个月份的引用数量。在这样做之前,我们将创建一个自定义排序函数来帮助按时间顺序显示结果。因为我们创建的第五章中的月份-年份列是字符串,仅按该列排序不会按时间顺序返回结果。为了解决这个问题,我们的自定义排序函数将按正确的顺序将一个连续的整数映射到每个月份,并在删除之前按数字列对数据进行排序。
列表 6.8 对月份-年份列的定制排序
import pandas as pd
years = ['2014', '2015', '2016', '2017']
months = ['01','02','03','04','05','06','07','08','09','10','11','12'] ①
years_months = [year + month for year in years for month in months] ②
sort_order = pd.Series(range(len(years_months)), index=years_months, name='custom_sort') ③
def sort_by_months(dataframe, order): ④
return dataframe.join(order).sort_values('custom_sort').drop('custom_sort', axis=1)
列表 6.8 中包含了很多内容。让我们逐行分析它。首先,我们将为 2014 年至 2017 年的所有月份构建一个月份-年份值列表。为此,我们构建了两个列表,一个包含月份,另一个包含年份。在第 5 行,我们使用列表推导来计算 months 列表和 years 列表的笛卡尔积。这将创建月份和年份的每一种可能的组合。由于我们设置列表推导的方式,月份-年份值的列表将按照正确的时序顺序排列。接下来,我们将列表转换为 Pandas Series。我们使用月份-年份值作为索引,以便将其与其他具有相同索引的 DataFrames 连接,并使用 range 函数创建一个连续的整数值,以便正确排序连接的数据。最后,我们定义了一个名为 sortByMonths 的快速函数,它将接受任何索引对齐的 DataFrame 作为输入,将其排序映射连接到它,使用我们为每个月份-年份映射的整数值按时间顺序排序,并删除数字列。这将导致一个按月份-年份时间顺序排序的 DataFrame。
在 Dask 中使用聚合函数
现在我们已经详细阐述了排序逻辑,让我们看看如何按月份和年份计算引用次数。为此,我们将使用一个聚合函数。根据其名称,聚合函数会将原始数据组合(或聚合)成某种分组,并对该组应用一个函数。如果您在 SQL 中使用过 GROUP BY 语句,您可能已经熟悉了聚合函数。Dask 中提供了许多相同的函数:计数、求和、查找最小/最大值等,按组进行。实际上,我们在上一节中用于描述性统计的操作(min、max、mean 等)在技术上也是聚合函数!唯一的区别是我们将这些函数应用于整个未分组的数据集。例如,我们查看整体车辆的平均年龄——但我们也可以按车辆类型或车牌州查看平均车辆年龄。同样,我们可以使用 count 函数来计算整个数据集中发出的所有引用次数,或者我们可以按某种分组(如月份-年份)进行计数。不出所料,定义聚合函数分组的函数是 groupby。从代码的角度来看,它的使用非常简单和简洁,但了解幕后发生的事情很重要。当您使用定义的分组调用聚合函数时,会发生一个称为拆分-应用-组合的算法。让我们看看它是如何工作的。

图 6.7 聚合函数的拆分-应用-组合算法示例
为了简单起见,我们在图 6.7 顶部表格中有四行数据,展示了宠物及其所有者 ID 的列表。如果我们想统计每个所有者拥有的宠物数量,我们将按所有者 ID 进行分组,并对每个组应用count函数。在后台发生的情况是原始表被分割成分区,其中每个分区只包含单个所有者拥有的宠物。原始表被分割成三个分区。接下来,我们对每个分区应用聚合函数。count函数将简单地找出每个分区中有多少行。我们得到了左分区 1 个计数,中间分区 2 个计数,右分区 1 个计数。为了重新组装结果,我们需要将每个分区的结果合并起来。在这里,每个分区的结果简单地连接起来生成最终输出。
根据你到目前为止关于洗牌性能和分区所学的知识,你可能想知道这些分割-应用-合并操作可以有多高效。由于我们必须将数据分割成具有唯一分区的分组列,如果你没有选择也用作分区列的分组列,那么你正确地担心这个操作将导致大量的洗牌。如果你在存储为压缩 Parquet 格式的数据上工作,这些操作可以稍微高效一些,但最终,最佳实践是只按用于分区数据的列进行分组。幸运的是,我们已经保存了按月-年分区的准备好的纽约市停车罚单数据,所以使用该列进行分组应该相当快!
列表 6.9 按月-年统计引用次数
with ProgressBar():
nyc_data_by_month = nyc_data.groupby('monthYear') ①
citations_per_month = nyc_data_by_month['Summons Number'].count().compute() ②
sort_by_months(citations_per_month.to_frame(), sort_order) ③
在列表 6.9 中,我们使用groupby方法定义我们想要按其分组数据的列。接下来,我们选择一个列应用我们的count函数并计算它。使用count函数时,通常不需要指定哪个列。只需注意,Dask 的count函数将只计算非空值,所以如果你对一个包含空值的列应用计数,你将不会得到真正的行数。如果你没有指定列,你将得到一个对每个列都有计数的 DataFrame,这并不是我们想要的。最后,我们将citationsPerMonth结果(一个 Pandas Series)转换为 DataFrame,使用to_frame方法,并对其应用我们的自定义排序函数。简化的输出可以在图 6.8 中看到。

图 6.8 按月-年引用的简略计数
如果你运行代码并查看完整输出,你会注意到 2017 年 6 月之后的引用次数远低于之前的月份。在撰写本文时,这个数据集 2017 年的数据并不完整。在下面的代码中,我们将从我们的相关性计算中过滤掉它;否则,它可能会对我们的结果产生负面影响。我们还需要将平均月温度返回到我们的结果 DataFrame 中,因为我们想将引用次数与平均温度进行比较。
列表 6.10 计算引用次数与温度的相关性
with ProgressBar():
condition = ~nyc_data['monthYear'].isin(['201707','201708','201709','201710','201711','201712']) ①
nyc_data_filtered = nyc_data[condition] ②
citations_and_temps = nyc_data_filtered.groupby('monthYear').agg({'Summons Number': 'count', 'Temp': 'mean'}) ③
correlation_matrix = citations_and_temps.corr().compute() ④
correlation_matrix ⑤
列表 6.10 展示了如何计算温度和引用次数之间的相关性。首先,我们构建过滤条件以去除缺失数据的月份。为此,我们将我们不想要的月份列表传递给isin方法。这个布尔表达式通常会过滤数据,使我们仅获取包含在isin列表中的行。然而,由于我们在表达式中加上了否定运算符(~),这个过滤器将返回所有不包含在isin列表中的月份。在构建表达式后,我们以同样的方式将其应用于数据,就像你现在已经看到很多次的那样。
在第 3 行,我们按照之前的做法,通过monthYear对数据进行分组,但这次我们将对分组后的数据使用agg方法。agg方法允许你一次性对相同的数据分组应用多个聚合操作。要使用它,你只需传入一个包含键对应列名和值对应聚合函数名称的字典。在这里,我们正在将count函数应用于Summons Number列,将mean函数应用于Temp列。
你可能想知道为什么我们在Temp列上应用mean函数,而Temp列已经包含了该月的平均温度。这是因为我们在每个原始记录上标记了温度,但在我们的结果中,我们只想为每个月提供一个温度值。由于一系列常数数的平均值只是常数本身,我们使用mean只是简单地通过平均温度传递给结果。
最后,我们使用 corr 方法计算变量之间的相关性。相关矩阵的输出可以在图 6.9 中看到。它显示计数和温度之间的相关性为 0.14051。这表明存在正相关,因为相关系数是正的,而且它是一个 弱 相关,因为相关系数小于 0.5。我们可以解释这意味着在平均温度较高的月份,通常比平均温度较低的月份发出更多的罚单。然而,弱相关性表明,大量的变化仍然不能被这两个变量很好地解释。换句话说,如果我们观察到两个平均温度完全相同的不同月份,它们很可能仍然会有非常不同的罚单数量。这意味着数据集中可能还有其他变量,我们可以使用这些变量来进一步解释一些这种变化。

图 6.9 计数和温度的相关矩阵
6.3 自定义聚合函数
虽然相关性有助于理解两个连续数值变量之间的关系,但你可能还会遇到你想要分析的分类变量。例如,在第一部分中,我们研究了发出罚单时车辆的平均年龄,发现平均车辆年龄为 6.74 年。但所有类型的车辆都一样吗?让我们通过回答以下问题来为这项分析增加另一个维度:
在纽约市停车罚单数据中,私人拥有车辆的平均年龄是否与商业拥有车辆的相同?
6.3.1 使用 t 检验测试分类变量
为了回答这个问题,我们将查看数据集中的两个不同变量:平均车辆年龄和车辆类型。尽管我们对如何随着关注点的改变从一种车型转移到另一种车型时平均年龄的变化感兴趣,但相关性在这里并不适用。相关性只能用来描述两个连续变量相对于彼此“移动”的方式。车辆类型不是一个连续变量——它要么是 PAS(私人拥有乘客车辆),要么是 COM(商业拥有车辆)。说随着车辆类型的增加或减少,平均年龄也会增加或减少是很奇怪的。我们可能简单地通过按车辆类型分组数据并计算平均值来回答这个问题,但这会带来它自己的问题:如果平均值不同,你怎么能确定这种差异不是仅仅由于随机机会造成的呢?我们可以转向另一种统计检验,称为双样本 t 检验,以帮助回答这个问题。
统计假设检验 101
双样本 t 检验属于统计假设检验这一类统计检验。统计假设检验有助于回答关于数据某些方面的预定义假设。在每一次统计假设检验中,我们首先对数据进行一个声明。这个声明,被称为零假设,默认被认为是真实陈述。检验试图提供足够的证据来反驳这一点。如果证据足够有说服力,你可能会拒绝零假设作为真实陈述。这通过一个概率来衡量,即证据的重要性是否由随机机会引起。如果这个概率,称为p 值,足够低,那么反对零假设的证据就足够强,可以予以拒绝。图 6.10 展示了一个表示假设检验决策过程的流程图。

图 6.10 统计假设检验的过程
双样本 t 检验的零假设是“两个类别之间不存在均值差异。”该检验旨在确定是否存在足够的证据来拒绝这一陈述。如果我们找到足够的证据来拒绝零假设,我们就可以自信地说,基于车辆类型,平均车辆年龄很可能存在差异。否则,我们可以认为这两种类型的车辆在平均年龄上并没有真正的差异。
统计假设检验的假设
与许多统计假设检验一样,双样本 t 检验通常对我们将要测试的潜在数据做出一些假设。这些假设取决于我们将使用哪种双样本 t 检验。两种最常见的双样本 t 检验是 Student 的 t 检验和 Welch 的 t 检验,分别以开发每种方法的统计学家命名(尽管“Student”实际上是统计学家、Guinness 酿酒厂员工威廉·塞利·高塞特采用的化名)。
Student 的 T-Test 的一个重要假设是每个被测试组的方差相等。方差,就像标准差一样,与观测值从平均值偏离的程度有关。方差越高,典型的观测值往往离平均值越远,方差越小,典型的观测值往往离平均值越近。这也意味着方差较大的分布有更高的概率包含接近分布最小/最大点的观测值。想想这在车辆年龄的背景下意味着什么:如果我们考虑我们的两组,私有车辆和商用车辆,并且它们都有相同的平均车辆年龄,但商用车辆的方差要高得多,这意味着我们更有可能遇到比私有车辆更新和更旧的商用车辆。这可能只是我们运气好,尽管商用车辆的典型年龄差异很大,但商用车辆的平均值在我们的样本中与私有车辆的平均值相同。如果我们使用 Student 的 T-Test 来比较具有非常不同方差的组之间的均值,我们计算出的值将帮助我们决定是否拒绝或未能拒绝零假设,这个值将变得不可靠。这意味着我们更有可能计算出会导致我们错误地拒绝零假设的值,从而得出错误的结论。
对于组方差不同的情况,我们可以使用 Welch 的 T-Test。Welch 的 T-Test 的公式略有不同,有助于我们避免得出错误的结论。因此,在我们决定使用 Welch 的 T-Test 还是 Student 的 T-Test 来回答我们的问题之前,我们应该检查私有车辆和商用车辆的方差是否相同。幸运的是,统计假设检验可以帮助我们检查这一点——我们只需要选择正确的一个!
6.3.2 使用自定义聚合实现布朗-福斯泰测试
帮助我们检查方差相等的测试系列也附带一些假设。如果数据是正态分布的,即对称且大致呈“钟形”,我们可以使用称为 Bartlett 方差相等的测试。然而,在第 6.1 节中我们发现车辆年龄的偏度为 1.012,这意味着它不是对称分布的,因此我们不能使用 Bartlett 测试,否则有得出错误结论的风险。一个没有这种假设的良好替代测试称为布朗-福斯泰方差相等的测试。由于我们无法可靠地使用 Bartlett 测试来处理这些数据,我们将使用布朗-福斯泰测试来帮助我们决定是否使用 Student 的 T-Test 或 Welch 的 T-Test。从开始到结束的整个测试过程可以在图 6.11 中看到。

图 6.11 我们将使用的过程来确定平均车辆年龄是否与车辆类型有任何关系
首先,我们将开始声明零假设和备择假设。布朗-福斯泰测试的零假设是各组方差相等。备择假设是各组方差不相等。这个测试将帮助我们决定是否有足够的证据来说明组方差不同,在这种情况下,我们需要使用 Welch 的 T 检验,或者如果没有足够的证据,我们将使用 Student 的 T 检验。

图 6.12 布朗-福斯泰方差齐性检验
图 6.12 展示了布朗-福斯泰方程。虽然它看起来可能很复杂,但我们将把这个方程分解成更小、更易于管理的部分;然后我们将组装最终结果。由于布朗-福斯泰测试涉及大量的分组和聚合操作,这是学习 Dask 自定义聚合函数的绝佳机会!我们将采取以下五个步骤来计算布朗-福斯泰方程:
-
计算左侧分数。
-
计算右侧分数的分母。
-
计算右侧分数的分子。
-
将右侧分数的分子除以分母来计算右侧分数的值。
-
将左侧分数乘以右侧分数。
在第 6.1 节中,你创建了一个包含计算出的车辆年龄额外列的 Parquet 文件。我们将首先再次读取这个文件。
列表 6.11 设置车辆年龄数据集
nyc_data_with_vehicle_age = dd.read_parquet('nyc_data_vehicleAge', engine='pyarrow')
nyc_data_filtered = nyc_data_with_vehicle_age[nyc_data_with_vehicle_age ['Plate Type'].isin(['PAS','COM'])] ①
我们想要过滤数据,只包含带有 PAS 型车牌或 COM 型车牌的车辆的原因是,我们之前将该列重新编码,以包含既不是 PAS 型也不是 COM 型的其他车辆。双样本 t 检验只能用于测试两组之间的均值差异,因此我们在继续之前将过滤掉其他类别。在应用过滤器后,我们将使用你之前学过的简单聚合函数计算方程的第一部分。图 6.13 展示了我们在计算中将要做什么。

图 6.13 布朗-福斯泰测试的第一部分
方程的第一部分,称为“自由度”,计算起来非常简单。我们需要计算我们过滤后的数据集中引用的总数,以及我们正在测试的不同组数(对于双样本 t 检验,这应该是始终为 2 的!)。我们将保存这个值,稍后乘以另一个值以获得布朗-福斯泰测试的最终结果。
列表 6.12 计算布朗-福斯泰方程的左侧分数
with ProgressBar():
N = nyc_data_filtered['Vehicle Age'].count().compute() ①
p = nyc_data_filtered['Plate Type'].unique().count().compute() ②
brownForsytheLeft = (N - p) / (p - 1) ③
列表 6.12 中的所有内容都应该看起来很熟悉。变量 N 代表数据集中的观测总数,变量 p 代表组数。要找到 N 和 p 的值,我们只需计算观测总数,并计算唯一组数。然后我们将使用 N 和 p 的值来计算方程的左侧分数。
使用分位数方法计算中位数
现在,我们将开始通过计算分母(参见 图 6.14)来处理方程的右侧分数。如您所见,我们将并行计算每个组——私家车和商用车辆——的相同值集,然后汇总结果。我们将从计算每种车辆类型的平均年龄开始。

图 6.14 计算右侧分母的过程
列表 6.13 计算每种车辆类型的平均年龄
with ProgressBar():
passenger_vehicles = nyc_data_filtered[nyc_data_filtered['Plate Type'] == 'PAS'] ①
commercial_vehicles = nyc_data_filtered[nyc_data_filtered['Plate Type'] == 'COM']
median_PAS = passenger_vehicles['Vehicle Age'].quantile(0.5).compute() ②
median_COM = commercial_vehicles['Vehicle Age'].quantile(0.5).compute()
与 Pandas 和 NumPy 不同,Dask 在 DataFrame 或 Series 对象上没有显式的中位数方法。相反,您必须使用 quantile 方法来计算“车辆年龄”列的 0.5 分位数,这相当于第 50 个百分位数,或中位数。接下来,我们将创建一个新的列,使用这些中位数从每辆车的年龄中减去其对应组的平均年龄。对于私人拥有(PAS)车辆,我们将从每辆车的年龄中减去所有 PAS 车辆的平均年龄。同样,对于商用(COM)车辆,我们将从每辆车的年龄中减去所有 COM 车辆的平均年龄。为此,我们将定义一个函数来应用条件减法逻辑。
列表 6.14 一个用于计算绝对中位数偏差的函数
def absolute_deviation_from_median(row):
if row['Plate Type'] == 'PAS':
return abs(row['Vehicle Age'] - median_PAS)
else:
return abs(row['Vehicle Age'] - median_COM)
列表 6.14 中的这个函数非常简单:如果一辆车是 PAS 类型,我们从车辆的年龄中减去 PAS 车辆的平均年龄。否则,我们从车辆的年龄中减去 COM 车辆的平均年龄。我们将使用这个函数与之前多次使用过的相同 apply 方法,这将导致一个包含车辆年龄与其对应组平均年龄之间绝对差异的列。
列表 6.15 创建一个列来计算绝对中位数差异
absolute_deviation = nyc_data_filtered.apply(absolute_deviation_from_median, axis=1, meta=('x', 'float32')) ①
nyc_data_age_type_test_stg1 = nyc_data_filtered.assign(MedianDifferences = absolute_deviation)
nyc_data_age_type_test = nyc_data_age_type_test_stg1.rename(columns={'MedianDifferences':'Median Difference'}) ②
在 列表 6.15 中,apply 函数用于创建一个包含计算结果的新 Series;然后我们将该列分配给现有的 DataFrame 并重命名它。现在我们在计算右侧分母的过程中已经完成了一半。让我们检查一下我们的进度。图 6.15 显示了我们到目前为止的进展。

图 6.15 计算布朗-福赛斯方程右侧分母的进展
好了!接下来,我们需要计算每个组的平均中位数差异。我们可以简单地使用我们之前已经看到几次的 groupby/mean 调用来完成这个操作。
列表 6.16 计算中值差异的组均值
with ProgressBar():
group_means = nyc_data_age_type_test.groupby('Plate Type')['Median Difference'].mean().compute()
这个计算的结果,group_means,是一个包含按板型分组的中值差异列均值的 Series。我们可以通过使用正常的过滤器表达式来访问任何组的均值。我们将在另一个条件函数中使用这个结果,该函数将每个中值差异金额减去观测值的对应板型。这将导致数据集中每个观测值的组均值方差。
列表 6.17 计算组均值方差
def group_mean_variance(row): ①
if row['Plate Type'] == 'PAS':
return (row['Median Difference'] – group_means['PAS'])**2
else:
return (row['Median Difference'] – group_means['COM'])**2
group_mean_variances = nyc_data_age_type_test.apply(group_mean_variance, axis=1, meta=('x', 'float32')) ②
nyc_data_age_type_test_gmv_stg1 = nyc_data_age_type_test.assign(GroupMeanVariances = groupMeanVariances)
nyc_data_age_type_test_gmv = nyc_data_age_type_test_gmv_stg1.rename(columns={'GroupMeanVariances':'Group Mean Variance'}) ③
最后,为了完成布朗-福塞方程右侧分母的计算,我们只需要将组均值方差列求和。我们可以通过简单的调用 sum 方法来完成这个操作。
列表 6.18 完成计算右侧分母
with ProgressBar():
brown_forsythe_right_denominator = nyc_data_age_type_test_gmv['Group Mean Variance'].sum().compute()
现在我们已经完成了分母的计算,接下来我们将计算分子。为此,我们将遵循图 6.16 中概述的过程。

图 6.16 计算布朗-福塞方程正确分子的过程
我们将首先计算中值差异列的总体均值。总体均值是“没有分组的列的均值”的另一种说法。这与组均值相反,正如你可能猜到的,组均值是组的均值——例如,PAS 车辆的平均车辆年龄是一个组均值,所有车辆的平均车辆年龄是一个总体均值。
列表 6.19 计算中值差异列的总体均值
with ProgressBar():
grand_mean = nyc_data_ageTypeTest['Median Difference'].mean().compute()
创建自定义聚合对象
现在我们已经计算出了总体均值,接下来我们将使用自定义聚合来处理过程中的下三个步骤。正如你在图 6.16 中可以看到的,我们需要每个组中的组均值和观测值的数量。而不是分别计算它们,我们可以利用 Dask DataFrame API 中的聚合对象来在相同的计算中获取这两个值。让我们看看这会是什么样子。
列表 6.20 用于计算正确分子的自定义聚合
brown_forsythe_aggregation = dd.Aggregation(
'Brown_Forsythe', ①
lambda chunk: (chunk.count(), chunk.sum()), ②
lambda chunk_count, chunk_sum: (chunk_count.sum(), chunk_sum.sum()), ③
lambda group_count, group_sum: group_count * (((group_sum / group_count) - grand_mean)**2) ④
)
现在事情开始变得有趣了!在列表 6.20 中,我们看到一个自定义聚合函数的例子。到目前为止,我们一直依赖于 Dask 的内置聚合函数,如 sum、count、mean 等,来执行聚合计算。但是,如果你需要执行一个更复杂的计算,并且这个计算需要在分组上执行,那么定义你自己的聚合函数是必要的。
Dask 定义自定义聚合函数的设施是在 dask.dataframe 包中找到的 Aggregation 类。它有三个最小参数,以及一个可选的第四个参数:
-
聚合的内部名称
-
一个应用于每个分区的函数
-
一个用于聚合每个分区结果的函数
-
(可选)一个在输出之前对聚合值执行最终转换的函数
第一个参数仅仅是聚合的一个内部名称。第二个参数接受一个函数(这可以是一个定义好的函数或匿名 lambda 函数),并将其应用于每个分区。这被称为块步骤。在列表 6.20 中,我们计算了每个块中的观测数量以及每个块值的总和,并返回一个包含这些计算值的元组。接下来,Dask 将收集每个块步骤的结果,并将定义在第三个参数中的函数应用于收集到的块结果。这被称为聚合步骤。在列表 6.20 中,我们计算了每个块的总和,从而得到了整个 DataFrame 中观测数量的总和以及整个 DataFrame 中车辆年龄的总和。但我们还没有完成这个计算。第四个也是最后的参数被称为最终化步骤,它在我们将数据返回给用户之前给我们最后一次转换数据的机会。在列表 6.20 中,我们取聚合的总和并除以聚合的计数以得到组均值,从总体均值中减去它,然后平方差值并乘以计数。这将得到我们想要求和以得到右边分子最终值的那些结果。现在我们已经定义了聚合,让我们将其应用于数据以计算值。
列表 6.21 使用自定义聚合函数
with ProgressBar():
group_variances = nyc_data_age_type_test.groupby('Plate Type').agg({'Median Differences': brown_forsythe_aggregation}).compute()
正如你在列表 6.21 中看到的,使用自定义聚合函数与使用任何内置聚合函数非常相似。你可以使用之前学到的agg方法将自定义聚合函数映射到 DataFrame 的列上,你也可以使用groupby方法来使用它们。在这里,我们使用我们定义的自定义聚合函数来计算每个板型的组方差。为了得到分子最终值,我们最后需要做的就是求和组方差。由于我们的自定义聚合函数产生一个 Series 对象,我们可以简单地使用之前多次见过的sum方法来操作它。
列表 6.22 完成右边分子的计算
brown_forsythe_right_numerator = group_variances.sum()[0]
太好了!我们现在已经拥有了完成布朗-福赛斯方程计算的各个部分。我们只需要将右边的分子除以右边的分母,然后乘以我们首先计算出的左边分数。这将得到一个称为F 统计量的结果。F 统计量将帮助我们得出是否拒绝零假设的结论。现在让我们来计算一下。
列表 6.23 计算 F 统计量
F_statistic = brown_forsythe_left * (brown_forsythe_right_numerator / brown_forsythe_right_denominator)
解读布朗-福赛斯检验的结果
由于我们已经完成了所有艰苦的工作,计算 F 统计量非常简单直接。如果一切顺利,你应该收到一个 F 统计量值为 27644.7165804。然而,我们还没有完成。这个数字是好是坏?仅凭这个统计量本身并不容易解释。为了我们对我们的发现得出结论,并决定是否拒绝或未能拒绝零假设,我们必须将这个值与测试潜在分布的 临界值 进行比较。临界值提供了一个阈值,帮助我们解释测试统计量的意义。如果测试统计量大于临界值,我们可以拒绝零假设。否则,我们未能拒绝零假设。由于布朗-福斯伊特测试产生一个 F 统计量,我们必须使用 F 分布**来找到临界值。为了从 F 分布中找到临界值,我们需要三个参数:数据自由度的两个度量以及我们希望使用的置信水平。
布朗-福斯伊特测试的自由度简单来说就是我们要测试的组数减一,以及总观测数减去我们要测试的组数。这应该看起来很熟悉——这些是我们之前计算左分数的两个部分。我们可以重用我们保存到变量 N 和 p 中的值来找到临界值。
置信水平可以是 0 到 1 之间的任何值,你可以自由选择。它本质上代表了一个概率,即测试的结果将导致正确的结论。值越高,测试的结果就越严格、越稳健。例如,如果我们选择置信水平为 0.95,那么测试错误地表示你应该拒绝零假设的可能性只有大约 5%。你可能之前听说过 p 值**;这实际上是置信水平的倒数。科学研究中普遍接受的 p 值是 0.05,因此为了保持一致,我们在这里将使用置信水平 0.95。为了计算 F 临界值,我们将使用 SciPy 中的 F 分布。
*列表 6.24 计算 F 临界值
import scipy.stats as stats
alpha = 0.05
df1 = p - 1
df2 = N - p
F_critical = stats.f.ppf(q=1-alpha, dfn=df1, dfd=df2)
列表 6.24 展示了如何计算测试的 F 临界值。stats.f 类包含 F 分布的实现,而 ppf 方法计算具有自由度 dfn 和 dfd 的 F 分布在点 q 的值。正如你所见,点 q 简单来说就是我们所选择的置信度值,而 dfn 和 dfd 使用我们在本节开头计算的两个变量。这个计算应该得到一个 F 临界值为 3.8414591786。最后,我们可以报告我们的发现并得出结论。下一个列表将打印出一个总结我们的发现并突出我们用来得出结论的相关数字的漂亮声明。
列表 6.25 报告布朗-福斯伊特测试的发现
print("Using the Brown-Forsythe Test for Equal Variance")
print("The Null Hypothesis states: the variance is constant among groups")
print("The Alternative Hypothesis states: the variance is not constant among groups")
print("At a confidence level of " + str(alpha) + ", the F statistic was " + str(F_statistic) + " and the F critical value was " + str(F_critical) + ".")
if F_statistic > F_critical:
print("We can reject the null hypothesis. Set equal_var to False.")
else:
print("We fail to reject the null hypothesis. Set equal_var to True.")
在这个特定情况下,我们被告知拒绝零假设,因为 F 统计量大于 F 临界值。因此,当我们运行双样本 t 检验来回答我们的原始问题时,我们不应该假设不同类型的车辆具有相同的方差。现在我们终于可以运行适当的 t 检验,看看在纽约市收到停车罚单的车辆的平均年龄是否根据车辆类型有显著差异!在我们继续之前,让我们回顾一下我们是从哪里来的以及我们接下来要做什么。

图 6.17 我们已经拒绝了 Brown-Forsythe 测试的零假设,所以接下来我们将运行 Welch 的 T-Test。
如您在图 6.17 中看到的,现在我们已经拒绝了 Brown-Forsythe 测试的零假设,我们将想要在数据上运行 Welch 的 T-Test 来回答我们的原始问题:“私有车辆的平均年龄是否与商用车辆的平均年龄相同?” 我们还面临一个重要的决定:虽然 Dask 确实内置了一些统计假设检验(包括双样本 t 检验),但您可能还记得在第一章中提到,当您处理的数据可以舒适地适应内存时,从 Dask 中提取数据并在内存中处理它可能更快。然而,我们将在第九章中更详细地探讨 Dask Array 库中的统计函数。对于双样本 t 检验,我们只需要两个一维数值数组:一个包含 PAS 类型车辆年龄的所有观测值,另一个包含 COM 类型车辆年龄的所有观测值。一些快速的“纸背”数学表明我们预计将有大约 4,000,000,000 个 64 位浮点数,这相当于大约 300 MB 的内存数据。这应该很容易适应内存,因此我们将选择收集数组并在本地执行我们的 t 检验计算。
列表 6.26 收集值数组
with ProgressBar():
pas = passengerVehicles['Vehicle Age'].values.compute()
com = commercialVehicles['Vehicle Age'].values.compute()
如您在列表 6.26 中看到的,在本地收集值非常简单。Dask Series 的 values 属性将暴露底层的 Dask Array,对 Dask Array 调用 compute 将返回包含结果的 NumPy 数组。
现在我们已经将数据本地化到 NumPy 数组中,我们可以运行 t 检验。
列表 6.27 运行双样本 t 检验
stats.ttest_ind(pas, com, equal_var=False)
# Provides the following output:
# Ttest_indResult(statistic=-282.4101373587319, pvalue=0.0)
非常简单——SciPy 在这里为我们做了所有繁重的工作。注意,在列表 6.27 中,我们将equal_var参数设置为 False。这样 SciPy 就知道我们已经进行了一个方差相等的测试,并发现组方差不相等。以这种方式设置参数后,SciPy 将运行 Welch 的 T-Test 而不是 Student 的 T-Test,避免了在本节之前学到的潜在问题。SciPy 还使我们更容易解释结果,因为它不仅计算了测试统计量,还计算了 p 值。对于 p 值,我们希望它小于我们选择的置信水平减一。所以如果我们再次选择置信水平为 0.95,我们正在寻找小于 0.05 的 p 值来拒绝零假设。作为提醒,t 检验的零假设是各组均值相等。由于我们看到这个测试的 p 值小于 0.05,我们可以拒绝零假设,并得出结论,有足够的证据表明根据车牌类型,平均车辆年龄是不同的。
| 零假设 | 条件 | p 值 | 拒绝? | 结论 |
|---|---|---|---|---|
| PAS 和 COM 车辆的平均年龄相同。 | p < 0.05 | 0.0 | 是 | PAS 和 COM 车辆并不有相同的平均年龄。 |
现在我们已经一起走过了这个例子,我希望你对自己的自定义聚合函数在 Dask 中的使用有了更好的理解,并且在过程中也练习了其他常见的 Dask 操作。我还希望,你开始欣赏 Dask 在实现自定义算法时所能提供的强大功能、简洁性和灵活性。我们能够用相当少的代码实现一个相当复杂的统计计算,甚至不需要深入框架的低级内部结构来完成我们需要做的事情!真是太棒了。
6.4 滚动(窗口)函数
我们将用比上一节稍微简单一点的内容来结束本章关于总结和分析 DataFrame 的内容,但对于许多分析类别来说同样重要。在数据分析中不讨论滚动函数是不完整的。如果你使用过 SQL,你可能熟悉窗口函数——滚动函数只是 Dask 中对窗口函数的称呼。
如果你不太熟悉窗口的概念,它允许你定义跨越一组连续数据集的计算,这些数据集包含相对于另一个值位置相关的变量。窗口最常见的应用是分析具有时间维度的数据,如日或小时。例如,如果我们正在分析在线商店的销售收入,我们可能想知道今天比昨天多卖(或少卖)了多少商品。这可以用数学表达式 sales[t] – sales[t–1] 来表示,其中下标 t 表示销售被测量的时间周期。由于差分方程涉及两个时间周期,我们可以说它有一个 两期窗口。如果我们将此计算应用于一系列销售观察结果,我们最终会得到一个转换后的序列,该序列表示每天与前一天之间的差异。因此,这个简单的方程就是一个窗口函数!当然,窗口函数可以更加复杂,也可以跨越更大的窗口。一个 50 天的简单移动平均,通常用于描述公开交易的金融资产的波动性和动量,是一个更复杂的窗口函数的例子,具有更大的窗口。在本节中,我们将使用滚动函数来回答以下问题:
随着时间的推移,发表引用的数量是否显示出任何趋势或周期性模式?
6.4.1 为滚动函数准备数据
Dask 中的滚动函数使用起来相当简单,但由于 Dask DataFrames 的分布式特性,需要一点智能的前瞻性才能正确使用。最重要的是,Dask 在窗口大小方面有一些限制:窗口的大小不能足够大,以至于跨越多个相邻分区。例如,如果你的数据按月份分区,你无法指定超过两个月的窗口大小(即数据关注的月份及其前后的月份)。当你考虑到这些操作可能会引起大量的数据移动时,这就有意义了。因此,你应该确保你选择的分区大小足够大,以避免这种边界问题,但请注意,更大的分区可能会开始减慢你的计算速度,尤其是在需要大量数据移动的情况下。对于每个你想解决的问题,一些常识和实验是必要的,以找到最佳平衡。数据还应进行索引对齐,以确保其按正确顺序排序。Dask 使用索引来确定哪些行彼此相邻,因此确保正确的排序对于正确执行任何数据计算至关重要。现在让我们看看使用滚动函数的一个例子!
列表 6.28 为滚动函数准备数据
nyc_data = dd.read_parquet('nyc_final', engine='pyarrow')
with ProgressBar():
condition = ~nyc_data['monthYear'].isin(['201707','201708','201709','201710','201711','201712'])
nyc_data_filtered = nyc_data[condition]
citations_by_month = nyc_data_filtered.groupby(nyc_data_filtered.index)['Summons Number'].count()
首先,在列表 6.28 中,我们将准备一些数据。我们将回到纽约市停车罚单数据集,查看每月发布的罚单的移动平均值。我们想要找出的是,在平滑掉一些波动性之后,我们是否能在数据中找到任何可辨别的趋势。通过将每个月与一定数量的前几个月平均,给定月份中的个别低谷和高峰将不那么明显,这可能会揭示在原始数据中难以看到的潜在趋势。
在 6.1 节中,我们注意到我们的数据集在 2017 年 6 月之后观察值急剧下降,我们选择丢弃该月之后的任何观察值。我们在这里再次过滤掉它们。然后,我们将按月计算引用次数。
6.4.2 使用滚动方法应用窗口函数
使用代表每月引用次数的citationsByMonth对象,我们可以在计算结果之前应用滚动函数转换。
列表 6.29 计算每月引用的滚动平均值
with ProgressBar():
three_month_SMA = citations_by_month.rolling(3).mean().compute()

图 6.18 窗口函数的简略输出
在列表 6.29 中,你可以看到内置的滚动函数是多么简单!在应用看似的聚合函数之前,我们已链式调用了一个rolling方法来告诉 Dask 我们想要在一个三期的滚动窗口中计算平均值。由于本例中的周期是月份,Dask 将将三个月的滚动窗口的月度引用次数平均在一起。例如,对于 2017 年 3 月,Dask 将计算 2017 年 3 月、2017 年 2 月和 2017 年 1 月的计数平均值。这意味着,默认情况下,你指定的周期数 n 将代表一个包括当前周期(3 月)及其之前的 n – 1 个周期(2 月和 1 月)的窗口。让我们看看这会对输出产生什么影响,这可以在图 6.18 中看到。
注意,前两个周期是NaN(缺失)值。这是因为 2014 年 2 月的计算应包括 2014 年 1 月和 2013 年 12 月,但我们的数据集没有 2013 年 12 月的数据。对于有缺失数据的时间段,Dask 不会计算部分值,而是返回一个NaN值来表示真实值目前未知。当使用滚动函数时,由于早期窗口中缺失值的性质,结果将始终比输入数据集少 n – 1 行。
如果您想在计算中包含尾随和领先期,可以通过设置滚动方法的center参数来实现。这将导致 Dask 计算一个窗口,该窗口包括当前值之前和之后的n/2个周期。例如,如果我们使用了一个三周期的中心窗口,对于 2017 年 3 月,我们的平均值将包括 2017 年 2 月、3 月和 4 月的计数——一个过去周期和一个未来周期。
列表 6.30 使用中心窗口
citations_by_month.rolling(3, center=True).mean().head()
列表 6.30 展示了中心化,这将产生图 6.19 中所示的输出。

图 6.19 列表 6.30 的输出,显示月度和停车罚单的平均数量
如图中所示,使用中心化我们只丢失第一行而不是前两行。这是否合适再次取决于您试图解决的问题。您还可以做很多比滚动平均值更多的事情。实际上,每个内置的聚合函数都可以作为滚动函数使用,例如sum和count。您还可以通过使用apply或map_overlap以与正常 DataFrame 或 Series 相同的方式实现自己的自定义滚动函数。
现在您已经有一些工具可以用来在 Dask 中数值描述和分析数据,这是一个很好的机会将我们的注意力转向数据分析的另一个同样重要的方面:可视化。如果您查看列表 6.30 代码的不缩写结果,您会看到趋势在数值上并不明确。在某些高点和一些低点之间,2017 年 6 月的罚单数量最终并没有远低于 2014 年 6 月的罚单数量。在这些时候,通过视觉识别趋势和模式比仅仅盯着数字要容易得多。通过可视化理解描述性统计和相关性也可能更加直观。因此,在下一章中,我们将继续寻找每月发放的罚单趋势,但我们将利用可视化的力量来尝试使我们的工作更加容易!
摘要
-
Dask DataFrame 拥有许多有用的统计方法,例如
mean、min、max等。更多统计方法可以在 dask.array.stats 包中找到。 -
通过使用
describe方法,可以为 DataFrame 或 Series 生成基本描述性统计。 -
聚合函数使用分割-应用-组合算法并行处理数据。在 DataFrame 的排序列上聚合将产生最佳性能。
-
相关性分析比较两个连续变量,而 t 检验比较一个连续变量与一个分类变量之间的关系。
-
您可以使用
Aggregate对象来定义自己的聚合函数。 -
您可以使用滚动函数来分析时间索引上的趋势,例如移动平均。为了获得最佳性能,您应该按时间周期对数据进行分区。** **# 7
使用 Seaborn 可视化 DataFrame
本章涵盖
-
使用准备-收集-绘图-减少模式来克服可视化大型数据集的挑战
-
使用
seaborn.scatterplot和seaborn.regplot可视化连续关系 -
使用 Seaborn 可视化连续数据组
seaborn.violinplot -
使用
seaborn.heatmap可视化分类数据中的模式
在上一章中,我们通过查看描述性统计和一些其他数值属性对纽约市停车罚单数据进行了基本分析。虽然用数字描述数据是精确的,但结果可能有些难以解释,并且通常不是直观的。另一方面,我们人类在检测和理解视觉信息中的模式方面非常擅长。将可视化纳入我们的分析中可以帮助我们更好地理解数据集的整体构成以及不同变量之间是如何相互作用的。
例如,考虑我们在上一章中探讨的平均温度和发出的罚单数量之间的关系。我们计算了大约 0.14 的皮尔逊相关系数。我们得出结论,这两个变量之间存在弱正相关,这意味着我们应该预计随着平均温度的上升,发出的罚单数量会略有增加。鉴于我们的发现,我们应该预计全球气候变化将成为纽约市的盈利现象吗?或者关系的性质取决于我们正在查看的值范围?简单的相关系数无法传达所有这些信息。我们稍后会回到这个问题。现在,为了真正强调可视化在数据分析中的重要性,让我们看看统计学中的一个经典问题,即安斯康姆四重奏。安斯康姆四重奏是 1973 年由一位名叫弗朗西斯·安斯康姆的英国统计学家提出的假设数据集,他对他的领域对可视化的缺乏欣赏感到沮丧。他想证明仅用数值方法并不总是能讲全故事:组成他的四重奏的四个数据集具有相同的均值、方差、相关系数、回归线和确定系数。图 7.1 显示了组成安斯康姆四重奏的四个数据集。

图 7.1 安斯康姆四重奏强调了使用可视化进行有效数据分析的重要性。
如果我们仅依赖数值方法,我们可能会得出结论,这四个数据集完全相同。然而,从图形上看,它们讲述了一个不同的故事。其中两个数据集,X[3]和 X[4],有极端的异常值。数据集 X[4]似乎有一个未定义的斜率,而数据集 X[2]似乎是一个非线性,可能是抛物线函数。因此,在通过可视化了解更多关于数据的信息后,我们可能使用的适当方法来数值描述和分析这四个数据集将完全不同。
注意到安斯康姆四重奏是一个非常小的数据集,因此将其作为一组散点图进行可视化既简单又直接。然而,由于数据量和种类的多样性,可视化大型数据集可能会很棘手。由于有各种各样的可视化可供选择,针对不同类型的数据,我们不可能在这本书中涵盖所有类型的可视化。因此,我们将介绍一些通用的模式和策略,您可以将它们扩展以生成许多种类的可视化,并且我们将介绍一些对分析结构化数据有用的常见可视化。一如既往地,图 7.2 展示了我们迄今为止所取得的成果以及我们接下来将关注的重点。

图 7.2 使用 Python 和 Dask 进行数据科学的工作流程
在本章中,我们将继续进行探索性分析和假设的制定与测试,但重点是使用可视化深入分析。我们还将结合您在前面章节中学到的数据处理技术,例如聚合,以及新的技术如抽样。所有用于准备数据以进行可视化的计算都将使用 Dask 的 DataFrame API 执行。
7.1 准备-减少-收集-绘图模式
在可视化大型数据集时,我们面临一些挑战。从技术角度来看,绘制大量数据是意料之中的计算密集型操作,可能需要大量的内存。到目前为止,我们已经能够通过使用 Dask 在多个工作者之间分配工作来应对计算和内存密集型操作。但是,我们最终需要将所有我们想要在屏幕上绘制的想要的数据收集回单个线程以在屏幕上渲染。这意味着,如果我们想要绘制的数据集的大小超过了客户端机器上的内存,我们就无法绘制它。在下一章中,我们将探讨一个名为 Datashader 的库,它以新颖的方式克服了这些挑战。然而,Datashader 不支持我们将在本章讨论的一些可视化,因此我们需要解决技术问题。
在绘制大型数据集时,我们还需要注意的另一件事是,可视化的价值来自于快速直观地从数据中获取洞察力的能力。然而,面对大量数据时,可能会很容易感到不知所措。看看图 7.3 中的散点图。

图 7.3 非常密集的散点图示例
在散点图中几乎无法判断发生了什么,因为图上已经绘制了如此多的单独点。它看起来就像随机噪声!应用一些颜色编码可以使看到数据中的几个不同区域成为可能,但仍然很难看到这些区域开始和结束的位置,以及它们相互重叠的地方。这是可视化大型数据集的一个典型问题:在如此多的数据中,单个数据点不再对单独分析有用。相反,我们需要从数据中提炼出广泛的模式和行为。我们可以通过多种方式做到这一点,包括聚类、聚合或抽样。这三种技术中的任何一种都可以用来使数据更容易理解。

图 7.4 应用聚类等降维技术可以使数据更容易解释。
图 7.4 显示了不同的数据集,该数据集仍然有相当多的点,但它也应用了聚类技术。这导致数据被清晰地分为三个不同的区域。尽管图上仍然有大量点,但由于可以概念上将我们的分析细分为解释三个不同组的行为,因此解释数据可能更容易。
为了克服绘制大型数据集的技术和概念挑战,我们将在本章的剩余部分使用准备-降维-收集-绘图模式来从 Dask DataFrame 生成可视化。最终,该模式的目的是将原始的大型数据集转换为更小的子集,该子集针对我们想要生成的可视化需求,并且尽可能多地使用 Dask 来完成工作。

图 7.5 使用 Dask 可视化大型数据集的准备-降维-收集-绘图模式
图 7.5 展示了逐步的过程。我们首先从“准备”步骤开始,确定针对我们想要回答的问题,哪种可视化方式是合适的。我们是否对两个连续变量之间的关系感兴趣?选择散点图。如果我们想查看按类别划分的项目计数,条形图会是一个不错的选择。一旦我们确定了要生成的可视化类型,就要考虑需要在坐标轴上显示什么。这将决定我们需要从 DataFrame 中选择的列。此外,我们还需要考虑是否需要过滤数据。也许我们想要将分析集中在某个特定类别上。为此,我们需要编写一个过滤条件,这在第五章中已有介绍,以仅从该类别选择数据。
“减少”步骤完全是关于选择一个合适的方法。通常,你有两个选择:将数据聚合到对我们试图回答的问题有意义的组中,例如按月份对所有的观察值求和或平均,或者对数据进行随机抽样。选择聚合数据通常是试图回答的问题的自然结果。例如,在纽约市停车罚单数据集中,我们开始时有大约 1500 万个罚单。由于我们想知道每月发出了多少罚单,我们能够通过按月份分组将 1500 万个数据点减少到不到 50 个数据点,然后计算罚单的数量。如果我们想以每日或每小时的水平查看这些数据,我们仍然可以将原始的 1500 万个罚单减少到更少的数据点。如果无法以任何有意义的方式聚合数据,或者这不适合我们试图回答的问题,可以使用抽样来集中在预定的数据点上。然而,由于随机抽样依赖于随机机会,随机抽样的数据点可能无法真实反映数据中的潜在行为。这种情况在抽取样本非常小的时候尤其可能发生,因此在使用随机抽样时应该谨慎!
过程中的第三步“收集”是执行计算并将结果转换为一个单一的 Pandas DataFrame 的地方。从那里,我们可以使用减少后的数据与任何绘图包一起使用。最后一步“绘图”是调用可视化绘图方法并设置显示选项(如标题、颜色、大小等)的地方。这一步没有分布,因为我们已经在“收集”步骤中将所有数据集中到了一个地方。
现在,让我们看看使用此模式来可视化纽约市停车罚单数据集中的一些变量的几个示例。在这些示例中,我们将使用 Seaborn 来生成可视化。Seaborn 是 Python 开放数据科学栈的一部分,是一个基于 Matplotlib 的数据可视化库,Matplotlib 是另一个流行的可视化库。Seaborn 提供了一个很好的 Matplotlib 包装器,让你可以轻松地生成常见的统计数据可视化,如回归图、箱线图和散点图。如果你还没有安装 Seaborn,你可以在附录中找到安装说明。
7.2 使用散点图和 regplot 可视化连续关系
现在,我们将通过回到我们在上一章中查看的月平均温度和月度引用数量之间的相关性分析,来查看使用 prepare-reduce-collect-plot 模式的示例。给定 0.14 的皮尔逊相关系数,我们不会期望两者之间有太大的关系,但让我们看看皮尔逊相关系数是否真的讲述了整个故事。
7.2.1 使用 Dask 和 Seaborn 创建散点图
首先,我们将像往常一样开始导入相关模块,并加载我们在第五章末保存的数据。
列表 7.1 导入模块和数据
import dask.dataframe as dd
import pyarrow
from dask.diagnostics import ProgressBar
import os
import seaborn
import matplotlib.pyplot as plt
os.chdir('/Users/jesse/Documents')
nyc_data = dd.read_parquet('nyc_final', engine='pyarrow')
我们将在这里继续导入 Seaborn 和 Matplotlib,因为我们很快将使用它们来设置最终图表的一些显示选项。如果这是你第一次使用 Seaborn,请注意:你经常会看到 Matplotlib 和 Seaborn 代码一起调用。由于 Seaborn 依赖于 Matplotlib 的绘图引擎(pyplot),控制渲染的可视化方面(如图大小和坐标轴限制)是通过 pyplot API 直接完成的。
要查看月平均温度和引用数量之间的关系,我们需要获取按月份/年份分组的平均月温度和引用次数的计数。由于引用次数可能比平均温度大几个数量级,温度将被放在 x 轴上,而引用次数将被放在 y 轴上。我们还将过滤掉 2017 年 6 月之后的数据,因为这些月份在此数据集中并未完全报告。现在我们已经确定了必要的数据,我们将生成准备和聚合数据的代码。
列表 7.2 准备数据
row_filter = ~nyc_data['Citation Issued Month Year'].isin(['07-2017','08-2017','09-2017','10-2017','11-2017','12-2017'])
nyc_data_filtered = nyc_data[row_filter]
citationsAndTemps = nyc_data_filtered.groupby('Citation Issued Month Year').agg({'Summons Number': 'count', 'Temp': 'mean'})
列表 7.2 应该看起来很熟悉,因为我们之前已经生成过这些数据来计算相关系数。和之前一样,我们只是过滤数据并对其应用 agg 方法(聚合)来计算按月份分组的引用次数和温度平均值。现在我们已准备好收集和绘制数据。
列表 7.3 收集和绘制数据
seaborn.set(style="whitegrid")
f, ax = plt.subplots(figsize=(10, 10))
seaborn.despine(f, left=True, bottom=True)
with ProgressBar():
seaborn.scatterplot(x="Temp", y="Summons Number",
data=citationsAndTemps.compute(), ax=ax)
plt.ylim(ymin=0)
plt.xlim(xmin=0)
在 列表 7.3 中,我们首先设置了即将生成的散点图样式设置。whitegrid 样式生成一个看起来干净的图形,具有白色背景和灰色 x 和 y 网格线。接下来,我们使用 plt.subplots 函数创建一个空白图形并指定我们想要的尺寸。这样做是为了覆盖默认的 pyplot 图形大小,当在高分辨率屏幕上显示时可能会有些小。接下来调用 seaborn.despine 是对我们即将生成的图形的另一种美学修改。这仅仅移除了图形周围的边框框,所以我们只能看到绘图区域中的网格线。接下来,在 ProgressBar 上下文中(因为我们将从 Dask 移动数据到本地 Python 进程),我们调用 seaborn.scatterplot 函数来绘制图形。它所需参数相当简单:分别传递给 x 和 y 轴的变量名作为字符串,并传递一个 DataFrame 来绘制。在这个例子中,我们还传递了我们创建的自定义坐标轴对象,以确保散点图看起来是按照配置的方式。然而,此参数是可选的,如果你不传递坐标轴对象,它将使用默认值进行绘制。在最后两行中,我们将 y 和 x 轴的最小值分别设置为 0。这些方法在 seaborn.scatterplot 调用之后被调用,是因为 Matplotlib 将能够自动计算 x 和 y 的最大值。如果你在绘图之前调用这些方法,Matplotlib 还没有机会看到数据并计算最大值。因此,你需要传递一个显式的最大值,否则图形将无法正确显示。经过一些计算后,你应该会得到一个看起来像 图 7.6 的散点图。

图 7.6 平均温度与引用次数的散点图
图 7.6 确实显示了两个变量之间存在正相关关系。当我们从 30 度移动到 60 度时,引用次数通常会增加。点彼此之间相当分散,因此表明相关性较弱。
7.2.2 在散点图中添加线性回归线
我们可以通过绘制回归图而不是散点图来让 Seaborn 尝试帮助我们更好地看到数据的渐变模式。为此,我们将使用 regplot 函数。它所需参数与 scatterplot 函数相同,因此两者很容易互换。
列表 7.4 使用 regplot 在散点图中添加回归线
seaborn.set(style="whitegrid")
f, ax = plt.subplots(figsize=(10, 10))
seaborn.despine(f, left=True, bottom=True)
with ProgressBar():
seaborn.regplot(x="Temp", y="Summons Number",
data=citationsAndTemps.compute(), ax=ax,
robust=True)
plt.ylim(ymin=0)
plt.xlim(xmin=0)
在列表 7.4 中,你可以看到唯一改变的是将regplot的调用替换为scatterplot。我们还添加了可选参数robust。这个参数告诉 Seaborn 生成一个稳健的回归。稳健回归最小化了异常值对回归方程的影响。这意味着 y 轴上远离其他点的点不会很大程度地拉动我们试图通过这些点绘制的线(向上或向下)。这是好事,因为这些点不太可能经常出现,所以我们应将其视为异常而不是可能再次发生的观察结果。例如,看看图 7.6 中代表大约 140 万次引用的点。这发生在平均气温仅为约 30 华氏度的非常冷的月份。我们可以看到,其他同样寒冷的月份累积的引用量只有大约 70 万。在一个月内发放 140 万次引用,肯定发生了某些特殊情况,因为这看起来很不寻常。如果我们允许异常数据点影响我们的回归线,它会导致我们高估在寒冷月份可以发放的引用数量。这个数据集中似乎有几个异常值,所以使用稳健回归是个好主意。运行代码时,你会得到一个看起来像图 7.7 的回归图。
如你所见,一条线大致通过点的中心质量绘制。然而,请记住,回归是一种统计估计。线的绘制可能受到了异常观察(称为异常值)的影响。围绕线的阴影区域表示置信区间,这意味着有 95%的几率最佳拟合线位于该区域内部。线本身是根据这里提供的数据的最佳“猜测”。

图 7.7 平均温度与引用计数散点图,并添加了回归线
7.2.3 在散点图上添加非线性回归线
然而,它看起来并不特别适合。在更仔细地检查散点图后,似乎引用的数量实际上在 60 度左右开始逐渐减少。这从直观上是有道理的:也许在更极端的天气条件下,派出的执法官员巡逻次数更少。这将是一个很好的机会跟进管理层,看看我们的假设是否正确,或者是否存在其他解释来解释引用数量的下降。无论如何,这种关系似乎并不呈线性。相反,一个非线性方程,如抛物线,可能更适合数据。

图 7.8 关系似乎是非线性的;关系在 60 度左右改变方向。
图 7.8 展示了为什么将抛物线拟合到数据可能会得到更准确的拟合。正如你所看到的,从 20 度到大约 60 度,关系似乎呈正相关:随着温度的升高,撰写的引用数量也增加。然而,在 60 度左右,关系似乎发生了方向上的反转。当温度从 60 度升高时,引用的数量似乎总体上有所减少。Seaborn 通过一些参数调整支持回归图中的非线性曲线拟合。
列表 7.5 将非线性曲线拟合到数据集
seaborn.set(style="whitegrid")
f, ax = plt.subplots(figsize=(10, 10))
seaborn.despine(f, left=True, bottom=True)
with ProgressBar():
seaborn.regplot(x="Temp", y="Summons Number",
data=citationsAndTemps.compute(), ax=ax,
order=2)
plt.ylim(ymin=0)
plt.xlim(xmin=0)
在列表 7.5 中,robust参数被替换为order参数。order参数决定了拟合非线性曲线时使用多少项。由于数据看起来大致呈抛物线形状,因此使用 2 阶(你可能还记得高中代数中抛物线有两个项:一个x²项和一个x项)。这产生了一个看起来像图 7.9 的回归图。

图 7.9 平均温度与引用计数拟合的非线性曲线散点图
图 7.9 看起来比之前的线性回归更适合数据。我们还学到了一些,如果我们只看皮尔逊相关系数,我们可能会错过的东西!接下来,我们将看看如何可视化分类数据中的关系。
7.3 使用提琴图可视化分类关系
纽约市停车罚单数据充满了许多分类变量,这为展示一个非常有用的可视化分析分类关系的工具提供了极好的机会:提琴图。一个提琴图的例子可以在图 7.10 中看到。

图 7.10 展示了分类数据的相对分布的提琴图
提琴图与箱线图类似,因为它们都是变量几个统计属性的视觉表示,包括均值、中位数、25 百分位数、75 百分位数、最小值和最大值。但提琴图还把直方图纳入图中,因此你可以确定数据的分布情况以及最频繁出现的点在哪里。像箱线图一样,提琴图用于比较组间连续变量的行为。例如,我们可能想知道车辆年龄与车辆颜色之间的关系。黑色汽车倾向于较新还是较旧?你更有可能在新的红色汽车还是旧的绿色汽车中收到罚单?提琴图将帮助我们调查这些问题。
7.3.1 使用 Dask 和 Seaborn 创建提琴图
正如我们在前面的例子中所做的那样,我们将遵循准备-减少-收集-绘图的模式来生成 violinplot。我们需要的数据是每个违章记录上记录的车辆年龄和车辆颜色。然而,在这种情况下,没有逻辑方法可以将数据预聚合到更小的组中。为了生成描述性统计和直方图,我们需要原始观测值。因此,我们将转向抽样来帮助我们减少数据集。
为了举例说明,我们将我们的分析缩小到最常见的六种车辆颜色:黑色、白色、灰色、红色、蓝色和绿色。这将允许我们不需要先进行抽样就生成 violinplot,这样我们就可以比较使用整个数据集和使用数据随机样本的 violinplot 看起来像什么。
列表 7.6 读取和筛选数据
nyc_data_withVehicleAge = dd.read_parquet('nyc_data_vehicleAge', engine='pyarrow')
row_filter = nyc_data_withVehicleAge['Vehicle Color'].isin(['BLACK','WHITE','GREY','RED','GREEN','BLUE'])
column_filter = ['Vehicle Age','Vehicle Color']
ages_and_colors = nyc_data_withVehicleAge[row_filter][column_filter]
对于这个例子,我们也在重用我们在第六章中生成的一些数据。这是那个我们计算每个违章记录的车辆年份和违章日期之间的差异的例子,以确定车辆在收到违章通知时的年龄。在列表 7.6 中,我们读取数据,选择相关列,并筛选出最常见车辆颜色。接下来,我们将快速计数以确定我们有多少个观测值。
列表 7.7 计数观测值
with ProgressBar():
print(ages_and_colors.count().compute())
# Produces the output:
# Vehicle Age 4972085
# Vehicle Color 4972085
# dtype: int64
有 4,972,085 个观测值,我们应该能够通过随机抽取 1%的观测值(不重复抽取)来获得一个相当有代表性的样本。首先,我们将看到所有 4.97 百万个点的 violinplot 是什么样的。
列表 7.8 创建 violinplot
seaborn.set(style="whitegrid")
f, ax = plt.subplots(figsize=(10, 10))
seaborn.despine(f, left=True, bottom=True)
group_order = ["RED", "GREEN", "BLUE", "BLACK", "WHITE", "GREY"]
with ProgressBar():
seaborn.violinplot(x="Vehicle Color", y="Vehicle Age", data=ages_and_colors.compute(), order=group_order, palette=group_order, ax=ax)
再次,我们首先设置图和坐标轴,就像之前一样。然后我们将颜色放入一个列表中,这样我们就可以告诉 Seaborn 如何在 violinplot 上排列组。然后,在ProgressBar上下文中,我们调用seaborn.violinplot函数来生成 violinplot。参数看起来应该很熟悉,因为它们与scatterplot和regplot相同。您还可以看到我们传递了定义的颜色列表。order参数允许您指定从左到右显示组的自定义顺序。否则,选择是随机的。如果您计划比较同一组中多个 violinplot 的实例,使用一致的排序顺序是很好的。我们还在palette参数中使用相同的列表,以确保 violinplot 的颜色与它们代表的车辆颜色相匹配(红色 violinplot 将是红色的,依此类推)。经过一些计算,您将得到一个看起来像图 7.11 的图表。

图 7.11 车辆颜色与车辆年龄的 violinplot
通过查看图 7.11 中的小提琴图,我们可以看到红色、蓝色、白色和灰色车辆的年龄中位数(用白色点表示)大致相同,而黑色车辆倾向于较新,绿色车辆倾向于较老。所有车辆颜色的最大年龄大致相同,但旧红色和绿色车辆比其他颜色的实例更多,这在红色和绿色图表的上部区域用较粗的线表示。较宽的区域表示观察到的数量更多,较窄的区域表示观察到的数量更少。白色图表的尖锐度看起来特别有趣,因为它看起来像奇数年龄的白色车辆比偶数年龄的车辆更不常见。这可能值得进一步研究以了解原因。
7.3.2 从 Dask DataFrame 中随机采样数据
现在我们将这个图表与数据的一个随机样本的图表进行比较。我们将保持绘图代码不变,但我们将从我们的过滤 DataFrame 中获取 1% 的随机样本。
列表 7.9 采样过滤 DataFrame
sample = ages_and_colors.sample(frac=0.01)
seaborn.set(style="whitegrid")
f, ax = plt.subplots(figsize=(10, 10))
seaborn.despine(f, left=True, bottom=True)
with ProgressBar():
seaborn.violinplot(x="Vehicle Color", y="Vehicle Age", data=sample.compute(), order=group_order, palette=group_order, ax=ax)
从 Dask DataFrame 中采样相当简单;在任意 DataFrame 上使用 sample 方法,并指定你想要采样的数据百分比,你将得到一个大小大约为指定百分比的过滤 DataFrame。默认情况下,采样是不带替换的。这意味着一旦从 DataFrame 中选择出一个车辆记录,该车辆记录就不能在同一个样本中再次被选中。这确保了你的样本中的所有观察都是唯一的。如果你想带替换地采样,你可以使用布尔 replace 参数来指定。请注意,你只能以百分比的形式指定样本大小——无法指定返回样本中的确切项目数。因此,你需要计算总体大小,并计算给出你想要样本大小的总体百分比。在这个例子中,49,000 已经足够大,所以我们已经进行了不带替换的 1% 样本。这将导致一个看起来像图 7.12 的图表。

图 7.12 随机样本的小提琴图
与图 7.11 相比,图 7.12 看起来非常相似。我们看到一般模式持续存在:黑色车辆倾向于较新,绿色车辆倾向于较老,并且比其他颜色的车辆,道路上红绿色较老的车辆更多。分布的形状大致相同,但它们失去了一些细节。白色和红色分布的形状比总体中的形状要少锯齿状。然而,总的来说,采样使我们能够从数据中得出类似的见解,而无需处理整个集合。
7.4 使用热图可视化两个分类关系
如您所见,小提琴图在您有一个分类变量时,有助于理解您数据的行为。然而,在我们所使用的纽约市停车罚单数据集中,拥有许多分类变量并不罕见。如果我们想看到分类变量如何相互作用,热图是一个非常实用的工具。虽然您可以使用热图来可视化任意两个分类变量之间的关系,但通常在时间维度的不同维度上使用热图更为常见。例如,我们在上一章中查看按月发布的引用趋势,并发现较暖和的月份比较冷的月份发布的引用更多,但也许还有另一个时间维度也有类似的模式。周可能是值得探索的:也许工作日比周末(或反之)发布的引用更多。
让我们看看周效应是否与年月效应相互作用。为此,我们需要获取每个引用所写的周和年月。然后,我们将按年月和周对引用进行聚合。这应该会自然地减少用于可视化的数据点数量到 84(12 个月乘以 7 天)。
列表 7.10 提取周和年月
from datetime import datetime
nyc_data_filtered = nyc_data[nyc_data['Issue Date'] < datetime(2017,1,1)]
day_of_week = nyc_data['Issue Date'].apply(lambda x: x.strftime("%A"), meta=str) ①
month_of_year = nyc_data['Issue Date'].apply(lambda x: x.strftime("%B"), meta=str)
首先,我们将对“发布日期”列应用 strftime 函数,以分别提取周和年月,就像我们在第五章中之前对数据进行函数应用一样。
列表 7.11 将列重新添加到 DataFrame 中
nyc_data_with_dates_raw = nyc_data_filtered.assign(DayOfWeek = day_of_week).assign(MonthOfYear = month_of_year)
column_map = {'DayOfWeek': 'Day of Week', 'MonthOfYear': 'Month of Year'}
nyc_data_with_dates = nyc_data_with_dates_raw.rename(columns=column_map)
接下来,我们使用之前学到的 drop-assign-rename 模式的 assign-rename 部分,将列重新添加到 DataFrame 中。
列表 7.12 按年月和周统计引用次数
with ProgressBar():
summons_by_mydw = nyc_data_with_dates.groupby(['Day of Week', 'Month of Year'])['Summons Number'].count().compute()
现在我们使用 groupby 方法按周和月来统计引用次数。
列表 7.13 将结果转换为交叉表
heatmap_data = summons_by_mydw.reset_index().pivot("Month of Year", "Day of Week", "Summons Number")
在 Dask 完成计算聚合后,我们需要将数据转换成交叉表,以便在 DataFrame 中有 12 行(每个月一行)和 7 列(每周一天),我们将使用 pivot 方法。首先必须重置索引,因为年月和周将最初是结果 DataFrame 的索引,因此需要将它们移回单独的列,以便在 pivot 调用中引用它们。最后,我们可以生成热图。
列表 7.14 创建热图
months = ['January','February','March','April','May','June','July','August','September','October','November','December']
weekdays = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']
f, ax = plt.subplots(figsize=(10, 10))
seaborn.heatmap(heatmap_data.loc[months,weekdays], annot=True, fmt="d", linewidths=1, ax=ax)
在调用heatmap函数之前,我们将月份和工作日按适当的顺序放入单独的列表中。正如我们在前面的例子中所看到的,任何基于命名时间维度的排序都会导致字母排序,因为 Pandas 不知道月份或工作日有任何特殊含义。在调用heatmap时,我们使用 DataFrame 上的loc方法来选择正确的行和列。或者,我们也可以使用之前演示的日期排序方法对 DataFrame 进行排序。annot参数告诉 Seaborn 在每个单元格中放置实际值,这样我们就可以确切地看到 7 月星期三发布的引用数量。fmt参数告诉 Seaborn 将内容格式化为数字而不是字符串,而linewidths参数调整了热图中每个单元格之间的间距。这个函数调用应该生成一个类似于图 7.13 的热图。热图非常容易阅读。浅色区域表示引用数量少,深色区域表示引用数量多。我们还用月份/工作日的实际引用数量注释了热图。

图 7.13 按周几和年月发布的引用热图
图 7.13 中的热图立即显示,周末发布的引用数量往往比工作日少,尤其是周日特别低。我们可能看到了周末值班执法官员较少的影响。12 月的周日似乎在任何月份/工作日组合中发布的引用数量最少,而 1 月的周四似乎发布的引用数量最多。这些可能是值得进一步探索的异常值。
希望你现在已经很好地理解了如何应用准备-减少-收集-绘图模式来为数据分析生成可视化。正如本章前面所述,这个模式也可以扩展到其他库。虽然 Seaborn 可以创建各种有用且吸引人的可视化,但任何可以接受 Pandas DataFrame 或 NumPy 数组作为输入的绘图库都可以在这个模式中轻松互换。
在下一章中,我们将探讨如何生成交互式可视化仪表板,这些仪表板对于数据探索和向最终用户报告都很有用。
摘要
-
故事可能“不止于”数值分析所能解释的内容——可视化数据总是值得的。
-
准备-减少-收集-绘图模式可以用来从大型数据集中创建可视化。你可以使用任何支持 Pandas 的数据可视化库来应用这种方法。
-
如果你的问题有意义,你可以通过聚合(例如,每月引用数)来减少数据。
-
在样本量足够大的情况下,随机抽样也可以是一种很好的方法来直观地近似数据的形状。
-
可以使用散点图来可视化两个连续变量之间的关系。
-
Regplots 可以用来绘制线性回归以及非线性回归。
-
可以使用小提琴图来可视化连续变量在分类变量上的分布。
-
可以使用热图来可视化两个分类变量。
8
使用 Datashader 可视化位置数据
本章涵盖
-
在下采样不适用的情况下,使用 Datashader 可视化大量数据点
-
使用 Datashader 和 Bokeh 绘制交互式热图
在上一章中,我们探讨了使用可视化从数据中获取洞察力的几种方法。然而,我们查看的每一种方法都依赖于找到解决方案来减少用于绘图的所用数据的大小。无论是通过随机抽样、过滤还是聚合数据,我们都使用了这些下采样技术来克服 Seaborn 和 Matplotlib 固有的限制。虽然我们已经表明这些技术是有用的,但下采样可能会使我们错过数据中的模式,因为我们正在丢弃数据。当处理高维数据,如位置数据时,这个问题最为明显。
想象一下,如果我们想使用纽约市停车罚单数据集来找出纽约市司机最有可能被罚款的地点。我们可以通过找到所有发出的罚单的经纬度平均值来实现这一点。然而,这只会告诉我们停车罚单的“平均”位置,而这个位置甚至可能不在城市街道上!整个城市可能有多个热点区域,但仅使用平均值我们无法得知这一点。我们可以尝试使用某种聚类算法,例如 k-Means,来识别多个热点区域,但这仍然有几个问题:中心点可能仍然不在任何城市街道上,而且我们必须手动选择要输入聚类算法的簇数量。如果我们对数据没有良好的理解,我们如何知道应该使用多少个簇?在这种情况下,唯一获得真正准确理解数据的方法是使用所有数据。但是,如果 Seaborn 和 Matplotlib 等绘图库无法很好地处理数百万甚至数十亿数据点的数据集,我们如何在不进行任何类型下采样的情况下可视化这些规模的数据集?这正是 Datashader 大放异彩的地方。在我们开始之前,简要看一下图 8.1,它展示了我们通过工作流程所取得的进展。

图 8.1 使用 Python 和 Dask 进行数据科学的工作流程
在本章中,我们将通过查看另一种分析数据的方法来完善我们的工作流程中的探索性分析和假设形成与测试步骤。与上一章不同,我们在那里使用 prepare-reduce-collect-plot 模式在绘图之前对数据进行下采样,然后使用 Seaborn 绘制,我们将探讨如何直接使用 Datashader 绘制存储在 Dask DataFrames 中的数据。具体来说,我们将探讨如何使用 Datashader 在地图上绘制基于地理的数据。
8.1 什么是 Datashader 以及它是如何工作的?
Datashader 是 Python 开放数据科学栈中的一个相对较新的库,它被创建出来用于生成非常大数据集的有意义可视化。与我们在 Seaborn 中的工作不同,我们当时需要使用 Dask 在绘图之前将 Pandas DataFrame 下采样并具体化,而 Datashader 的绘图方法可以直接接受 Dask 对象,并充分利用分布式计算。Datashader 可以生成任何基于网格的可视化:散点图、折线图、热图等等。让我们一步步了解 Datashader 使用五步流程渲染图像的过程,以理解它是如何工作的。
在我们深入之前,让我们获取一些可以处理的数据。不幸的是,纽约市开放数据(NYC OpenData)没有发布纽约市停车罚单数据集中每个停车罚单发出的确切纬度/经度坐标。因此,我们将转向纽约市开放数据上可用的另一个中等规模的数据集,该数据集确实具有详细的位置数据:纽约市 311 服务调用数据库。311 服务调用是指市民向纽约市非紧急服务报告的问题,例如当街灯熄灭或道路上形成坑洼时。该数据集包含从 2010 年初至今所有报告问题的记录,并定期更新。作为本章的激励场景,我们将使用这些数据来回答以下问题:
使用纽约市 311 服务调用数据集,我们如何根据位置显示服务调用的频率,并在地图上绘制这些数据以找到常见问题区域?
下载数据的链接可以在以下位置找到:data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9。要导出 CSV 格式的数据,请点击右上角的导出按钮并选择 CSV。此外,请确保在继续之前已安装 datashader、holoviews 和 geoviews 包。Holoviews 和 Geoviews 是 Datashader 的依赖项,必须安装才能使本章中的代码正确运行。这两个库都由 Datashader 用于创建交互式地图类型可视化。安装说明可以在附录中找到。在您下载数据后,导入必要的包并加载数据。
列表 8.1 加载数据和导入
import dask.dataframe as dd
from dask.diagnostics import ProgressBar
import os
import datashader
import datashader.transfer_functions
from datashader.utils import lnglat_to_meters
import holoviews
import geoviews
from holoviews.operation.datashader import datashade
os.chdir('/Users/jesse/Documents') ①
nyc311_geo_data = dd.read_csv('311_Service_Requests_from_2010_to_Present.csv', usecols=['Latitude','Longitude']) ②
列表 8.1 包含了所有启动的标准步骤:导入本章中我们将使用的包,设置工作目录,并读取数据。唯一值得注意的事情是我们现在只引入纬度和经度列。为此,我们将使用第四章中你学到的usecols参数。目前我们不需要其他任何列。
8.1.1 Datashader 渲染管道的五个阶段
数据和包准备就绪后,让我们继续了解 Datashader 的工作原理。Datashader 用于渲染图像的五个阶段是:
-
投影
-
聚合
-
变换
-
颜色映射
-
嵌入
第一步,投影,处理在 Datashader 上绘制图像的Canvas的建立。这包括选择图像的大小(例如,800 像素宽,600 像素高),将在 x 和 y 轴上绘制的变量,以及变量的范围,这用于在 Canvas 上定位可视化。Canvas 对象的解剖结构可以在图 8.2 中看到。

图 8.2 Canvas 对象的视觉表示
要创建 Canvas 对象,我们将调用 Canvas 构造函数并传入相关参数,如下所示。这个列表没有输出,因为我们创建的 Canvas 对象只是一个用于存放我们的可视化的容器。
列表 8.2 创建 Canvas 对象
x_range = (-74.3, -73.6)
y_range = (40.4, 41)
scene = datashader.Canvas(plot_width=600, plot_height=600, x_range=x_range, y_range=y_range)
参数对于构造函数来说应该是全部自解释的:图像的宽度和高度决定了将要生成的图像大小(以像素为单位),而 x 和 y 范围参数设置了网格的界限。在这里,我们选择了大致对应纽约市周边区域的地图坐标。请记住,经度通常沿 x 轴绘制,而纬度通常沿 y 轴绘制。如果你在处理数据集时不确定使用哪个坐标范围,你可以使用第六章中学到的聚合函数计算每一列的最小值/最大值。
Datashader 绘图管道的第二步是聚合。但是等等——我们不是在几页之前就建立了 Datashader 应该使用所有数据而不进行下采样的原则吗?那么为什么 Datashader 会对数据进行聚合呢?Datashader 对聚合这个术语的使用方式与我们过去所讨论的略有不同。当我们谈论聚合时,我们指的是特定领域的聚合,例如按车辆年份分组停车罚单。在所有情况下,我们执行的聚合都是沿着数据中包含的特定维度进行的。另一方面,Datashader 将数据聚合到代表屏幕上像素的桶中。你的数据所使用的坐标系被映射到图像的像素区域,所有位于这些桶中的数据点都将应用聚合函数(如求和或平均值)。例如,如果每个像素恰好代表 1/100 度的纬度/经度,一个 100 x 100 的图像将覆盖 1 平方度的区域。在北纬 40 度,1 度的经度相当于 53 英里,1 度的纬度相当于 69 英里。这意味着如果我们生成一个覆盖纽约市周边区域的 100 x 100 图像,屏幕上的每个像素将代表大约 36.5 平方英里。这是一个相当低的分辨率,因为 36.5 平方英里区域内的所有 311 服务调用都将被聚合在一起。图 8.3 展示了 Datashader 如何执行聚合操作。

图 8.3 聚合在原始点周围绘制区域,并对这些区域执行聚合操作;这些区域中的每一个都类似于最终可视化中的单个像素。
幸运的是,Datashader 会根据你在投影步骤中指定的宽/高和范围选项为你执行所有这些映射和聚合操作。小范围和大图像将导致空间的高分辨率图像,而大范围和小图像将导致空间的低分辨率图像。根据我们在列表 8.2 中指定的范围和大小,我们可以预期我们图像中的每个像素代表大约 120,000 平方英尺,这大约是纽约市标准街区面积的一半。因此,我们现在需要做的就是告诉 Datashader 我们希望它使用哪些数据。
列表 8.3 定义聚合
aggregation = scene.points(nyc311_geo_data, 'Longitude', 'Latitude')
这个调用非常简单,因为我们不需要对数据进行任何进一步的转换。我们只是告诉 Datashader 从 nyc311_geo_data DataFrame 中获取数据,在 x 轴上绘制经度列,在 y 轴上绘制纬度列。由于我们没有指定其他聚合方法,如求和、平均值等,Datashader 将简单地计算落入每个像素中的点的数量。因此,在我们的例子中,Datashader 将计算纽约市每个 120,000 平方英尺区域内的 311 服务调用数量。
Datashader 管道的第三步是转换。在这个例子中,我们没有必要对我们的数据进行任何转换,因为我们只是进行了一个简单的计数,但重要的是要注意,我们刚才创建的aggregation对象是一个简单的 xarray 对象,它代表了 Canvas 对象中定义的像素空间。这意味着我们可以对它执行任何我们想要的数组转换,例如过滤掉位于某个百分位数的像素,将数组乘以另一个数组,或者执行任何类型的线性代数转换。在这个特定的例子中,数组是 600 x 600,包含与该特定区域发生的服务调用相关的值以及映射回原始纬度/经度坐标。

图 8.4 位于 300, 300 像素处的像素内容
在图 8.4 中,你可以看到像素 300, 300 处的值。140 的值不是该区域服务调用数量的计数。相反,该值表示与地图上所有其他区域相比,该区域发生服务调用的相对排名。0 的值表示该区域没有服务调用,该值在服务调用发生更频繁的区域增加。这个数字在下一步非常重要。
第四步是颜色映射。在这一步中,Datashader 将聚合步骤中计算的相对值映射到给定的颜色图。在这个例子中,我们使用默认的颜色图,从白色到深蓝色。底层数组中的值越高,蓝色的阴影就越深。这种颜色映射最终传达了我们想要知道的信息。图 8.5 演示了 Datashader 如何执行颜色映射步骤。

图 8.5 颜色映射将排名值转换为颜色。
第五步也是最后一步是嵌入。这是 Datashader 使用聚合步骤中计算的信息以及颜色图来渲染最终图像的地方。为了触发最终渲染,我们在聚合对象上使用shade方法。如果我们想使用不同的颜色图(例如,从红色到蓝色),可以通过cmap参数指定。嵌入在图 8.6 中演示。

图 8.6 嵌入将最终图像渲染到屏幕上。
列表 8.4 渲染最终图像
image = datashader.transfer_functions.shade(aggregation)
在列表 8.4 中调用shade方法将数据转换为图像,然后可以使用 IPython 显示。
8.1.2 创建 Datashader 可视化
回顾一下,以下是完整的代码。
列表 8.5 生成第一个 Datashader 可视化的完整代码
with ProgressBar():
x_range = (-74.3, -73.6)
y_range = (40.4, 41)
scene = datashader.Canvas(plot_width=600, plot_height=600, x_range=x_range, y_range=y_range) ①
aggregation = scene.points(nyc311_geo_data, 'Longitude', 'Latitude') ②
image = datashader.transfer_functions.shade(aggregation) ③
注意,我们像往常一样用ProgressBar上下文包裹了代码。实际上,Datashader 正在使用 Dask 为我们生成聚合,因此我们可以查看聚合的进度!如果你检查我们留下的image对象,你应该会看到类似于图 8.7 的内容。

图 8.7 Datashader 图像的输出
这非常令人兴奋!我们只用了几秒钟就绘制了大约 1600 万个数据点。如果你熟悉纽约市的地理,你应该能立即认出城市的形状。如果你不熟悉,也没关系——因为我们有坐标系统中的位置数据,我们可以将这个可视化叠加到地图上,帮助我们确定位置,我们将在下一节中这样做。你也可能好奇我们如何聚焦于城市的特定部分。图像中最左上角的岛屿是曼哈顿岛,纽约市人口最稠密的地区之一。整个岛屿被深色阴影覆盖是有道理的:更多的人与对城市服务的更多请求相关。事实上,唯一没有被深色阴影覆盖的区域是岛屿中间的白色矩形,这是纽约市著名的中央公园。也许我们只想关注下曼哈顿,并找出该城市特定部分的问题区域。在下一节中,我们将介绍如何使我们的可视化变得交互式,允许我们在城市周围平移和缩放。我们还将添加一些地图瓦片,以便我们更好地了解我们正在看的地方。在我们继续之前,让我们回顾一下 Datashader 的五步绘图阶段。每个阶段的总结可以在表 8.1 中看到。
表 8.1 Datashader 的五步绘图阶段
| 阶段 | 说明 |
|---|
| 投影聚合
变换
颜色映射
在同一“区域”(坐标/位置)内对数据进行分组
对聚合值应用数学变换
将原始值转换为屏幕上要绘制的颜色阴影
在画布上渲染最终图像 |
8.2 以交互式热图形式绘制位置数据
在我们将可视化变为交互式之前,我们应该考虑渲染每张图像所需的时间。任何平移或缩放都会动态地改变你在上一节中手动设置的 x 范围和 y 范围值。这将需要重新渲染整个图像。渲染新图像所需的时间越长,交互式功能就越不实用,因此我们希望尽可能减少处理时间。建议使用 Parquet 格式存储 Datashader 使用的数据,并使用 Snappy 压缩——这两者我们已经在第五章中介绍过了!在我们继续之前,我们将数据从 CSV 转换为 Parquet 格式。
然而,我们还有一件事要考虑。我们还想在地图上叠加我们的原始热图,这样我们就可以知道我们正在查看城市的哪些部分。另一个名为 Geoviews 的库允许你这样做。Geoviews 使用坐标数据从第三方地图服务获取 地图瓦片。
8.2.1 准备地理数据以进行地图瓦片化
地图瓦片是投影在网格上的地图块。例如,一个瓦片可能代表曼哈顿的一个平方英里,并包含该平方英里内的所有道路和地形特征。就像我们在 Datashader 中的数据一样,瓦片的大小和面积基于画布的范围和大小。这些瓦片由使用 Web API 交付必要瓦片的地图服务提供。然而,大多数地图服务不会按经纬度坐标索引地图瓦片。相反,它们使用一个称为 Web Mercator 的不同坐标系。Web Mercator 只是一个另一种网格坐标系,但为了生成正确的图像,我们需要将我们的经纬度坐标转换为 Web Mercator 坐标。幸运的是,Geoviews 有一个实用方法可以为我们完成这个转换。我们将运行转换,然后将转换后的坐标保存到 Parquet。
列表 8.6 准备地图瓦片化的数据
with ProgressBar():
web_mercator_x, web_mercator_y = lnglat_to_meters(nyc311_geo_data['Longitude'], nyc311_geo_data['Latitude']) ①
projected_coordinates = dd.concat([web_mercator_x, web_mercator_y], axis=1).dropna() ②
transformed = projected_coordinates.rename(columns={'Longitude':'x', 'Latitude': 'y'}) ③
dd.to_parquet(path='nyc311_webmercator_coords', df=transformed, compression="SNAPPY") ④
在 列表 8.6 中,我们使用 lnglat_to_meters 方法将经纬度坐标转换为 Web Mercator 坐标。这个特定的方法接受两个输入对象(X 和 Y)并输出转换后的 X 和 Y 作为两个单独的对象。它可以接受 Dask Series 对象,而无需首先收集并实体化为 Pandas Series,所以我们只需将原始 DataFrame 的经度列传递给 X 值,将原始 DataFrame 的纬度列传递给 Y 值。
我们希望将这些值保存在一个单独的 DataFrame 中,所以我们将使用在第五章中学到的 concat 方法。然而,这次,我们不会用它来合并两个 DataFrame,而是用它来沿着列轴(axis 1)进行连接。你会收到一个警告,告诉你 Dask 假设两个 Series 之间的索引是对齐的,但在这个情况下你可以忽略它,因为 web_mercator_x 和 web_mercator_y 是按照相同的顺序创建的,使得它们的索引自然对齐。我们还将使用在第四章中用过的 dropna 方法来删除任何没有有效坐标的行。最后,我们将为了方便起见将列重命名为 x 和 y,并将结果保存到 Parquet 文件中。
8.2.2 创建交互式热图
现在,我们将读取 Parquet 文件并创建交互式可视化。
列表 8.7 创建交互式可视化
nyc311_geo_data = dd.read_parquet('nyc311_webmercator_coords') ①
holoviews.extension('bokeh') ②
stamen_api_url = 'http://tile.stamen.com/terrain/{Z}/{X}/{Y}.jpg' ③
plot_options = dict(width=900, height=700, show_grid=False) ④
tile_provider = geoviews.WMTS(stamen_api_url).opts(style=dict(alpha=0.8), plot=plot_options) ⑤
points = holoviews.Points(nyc311_geo_data, ['x', 'y'])
service_calls = datashade(points, x_sampling=1, y_sampling=1, width=900, height=700) ⑥
tile_provider * service_calls ⑦
在列表 8.7 中,我们首先将我们刚刚保存的 Parquet 数据读回到我们的会话中。接下来,我们需要在 Holoviews 中激活 Bokeh 扩展。这两个包管理可视化的交互部分,但我们不需要做任何更改即可让一切正常工作。我们将使用的地图瓦片提供者是来自一家名为 Stamen 的公司,该公司维护着一个由 OpenStreetMap 项目创建的开源街道地图数据仓库。我们将 API 的 URL 存储在一个变量中,以便地图瓦片提供者对象稍后可以使用它。接下来,我们将定义一些可视化参数,指定图像区域的宽度和高度。然后,我们使用geoviews.WMTS构造函数创建瓦片提供者对象。该对象用于在图像需要更新时调用 API URL 并获取正确的地图瓦片。我们只需要传递 URL 变量。我们使用opts方法将此调用与显示选项链在一起。然后,我们使用holoviews.Points函数创建热图,该函数与您在上一节中使用的scene.points方法非常相似。此外,我们不是使用datashader.transfer_functions.shade来生成图像,而是使用 Holoviews 的datashade函数。这允许 Holoviews 在我们使用 Bokeh 小部件平移/缩放时持续更新图像。最后一行将所有内容结合起来。虽然乘法运算符看起来有点奇怪,但这正是两个层折叠在一起以生成最终图像的方式。这也会启动 Bokeh 小部件并渲染第一张图像。您应该看到类似于图 8.8 的内容。

图 8.8 交互式热图
如您所见,我们在纽约市的地图上叠加了热图,一切完美对齐!请注意,我们在地图的外边缘有纬度和经度,以及在上右角的控制按钮以启用平移和缩放。图 8.9 显示了曼哈顿最南端的放大图像。

图 8.9 放大曼哈顿南端
您可以看到,当我们放大时,图像会随着地图瓦片更新。在新缩放级别重新渲染图像应该不到一秒钟。您还可以看到,相对于我们缩放的区域,有些区域的服务调用比其他区域多。例如,许多服务调用发生在百老汇,但沿一些侧街,如国家 9/11 纪念地周围,发生的调用较少。您可以在整个城市中平移和缩放以探索问题区域。
摘要
-
Datashader 可用于生成大型数据集的精确图像,而无需降采样。
-
每个 DataShader 对象由一个画布、一个聚合和一个传输函数组成。
-
DataShader 可视化基于画布区域中的像素数量进行聚合,它们的分辨率是动态的,允许你“放大”到图表上的特定区域。
-
Holoviews、Geoviews 和 Bokeh 可以与 Datashader 一起使用,以地图瓦片的形式生成交互式可视化。
-
地图瓦片通过瓦片提供者叠加在网格上。如果你的数据有经纬度坐标,它们应首先转换为 Web Mercator 坐标。***
第三部分
扩展和部署 Dask
在第三部分,我们通过涵盖一些高级主题来完善对 Dask 的探索:非结构化数据、机器学习和将 Dask 部署到云。这些是很好的结束话题,因为现在你应该对 Dask 模式相当熟悉了。再次强调,所有章节都基于现实世界的数据集和你在任何数据科学项目中可能遇到的标准任务。
第九章讨论了如何使用 Dask Bags——标准 Python Lists 的并行化实现——和 Dask Arrays——NumPy Arrays 的并行化实现——来处理更复杂、非结构化的数据集。我们将涵盖一些高级集合主题,如通过解析存储在 JSON 格式的文本数据来映射、折叠和归约。
第十章演示了如何使用 Dask ML API 来构建并行化的 scikit-learn 模型。这对于从大型数据集中构建模型非常有用,其中训练时间可能很长,将工作扩展到多台不同的机器上可以有效地加快训练过程。
最后但同样重要的是,第十一章涵盖了两个主题:如何使用 Docker 和 AWS 在云中运行 Dask,以及如何在集群模式下运行 Dask。本章逐步介绍了 AWS 环境的配置,然后演示了在集群中执行和监控前几章中引入的代码是多么简单。
9
使用 Bags 和 Arrays 进行操作
本章内容涵盖
-
使用 Bags 读取、转换和分析非结构化数据
-
从 Bags 创建 Arrays 和 DataFrames
-
从 Bags 中提取和过滤数据
-
使用 fold 和 reduce 函数组合和分组 Bags 的元素
-
使用 NLTK(自然语言工具包)与 Bags 结合进行大型文本数据集的文本挖掘
这本书的大部分内容集中在使用 DataFrames 分析结构化数据上,但如果不提及 Dask 的另外两个高级 API:Bags 和 Arrays,我们的探索就不会完整。当你的数据不适合整齐地放入表格模型中时,Bags 和 Arrays 提供了额外的灵活性。DataFrames 仅限于两个维度(行和列),但 Arrays 可以有更多维度。Array API 还为某些线性代数、高级数学和统计操作提供了额外的功能。然而,通过使用 DataFrames 已经涵盖的大部分内容也适用于使用 Arrays——正如 Pandas 和 NumPy 有很多相似之处。实际上,你可能还记得第一章中提到的 Dask DataFrames 是并行化的 Pandas DataFrames,而 Dask Arrays 是并行化的 NumPy 数组。
与其他 Dask 数据结构不同,Bags 非常强大且灵活,因为它们是并行化的通用集合,最像 Python 内置的 List 对象。与具有预定形状和数据类型的 Arrays 和 DataFrames 不同,Bags 可以持有任何 Python 对象,无论是自定义类还是内置类型。这使得包含非常复杂的数据结构,如原始文本或嵌套 JSON 数据,并且可以轻松地导航它们。
与非结构化数据一起工作正在成为数据科学家越来越常见的做法,尤其是那些独立工作或在小型团队中没有专门数据工程师的数据科学家。以下是一个例子。

图 9.1 结构化和非结构化数据的一个比较示例
在图 9.1 中,数据以两种不同的方式呈现:上半部分展示了以行和列形式组织的结构化数据的产品评论示例,下半部分展示了原始的评论文本作为非结构化数据。如果我们只关心客户的名字、他们购买的产品以及他们是否满意,结构化数据可以让我们一目了然地获得这些信息,没有任何歧义。客户名字列中的每个值始终是客户的名字。相反,原始文本的长度、写作风格和自由形式特性使得难以确定哪些数据与分析相关,需要某种形式的解析和解释来提取相关数据。在第一篇评论中,评论者的名字(玛丽)是评论的第四个单词。然而,第二位评论者将他的名字(鲍勃)放在了他的评论的末尾。这些不一致性使得使用像 DataFrame 或 Array 这样的刚性数据结构来组织信息变得困难。相反,Bags 的灵活性在这里得到了很好的体现:与 DataFrame 或 Array 总是具有固定数量的列不同,Bag 可以包含字符串、列表或任何其他可变长度的元素。
事实上,涉及处理非结构化数据的典型用例来自分析从网络 API 抓取的文本数据,例如产品评论、Twitter 上的推文或 Yelp 和 Google 评论等服务提供的评分。因此,我们将通过一个使用 Bags 解析和准备非结构化文本数据的示例;然后我们将探讨如何从 Bags 映射和推导出结构化数据到 Arrays。

图 9.2 使用 Python 和 Dask 进行数据科学的流程
图 9.2 是我们熟悉的流程图,但它可能有点令人惊讶,因为我们已经退回到前三个任务!由于我们是从一个新的问题和数据集开始,而不是从第八章继续前进,我们将重新审视工作流程的前三个要素,这次的重点是非结构化数据。在第四章和第五章中介绍的概念很多是相同的,但我们将探讨当数据不是以 CSV 等表格格式出现时,如何技术上实现相同的结果。
作为本章的激励示例,我们将查看由斯坦福大学网络分析项目收集的来自 Amazon.com 的一组产品评论。您可以从这里下载数据:goo.gl/yDQgfH。要了解更多关于数据集是如何创建的信息,请参阅 McAuley 和 Leskovec 的论文“从业余爱好者到鉴赏家:通过在线评论建模用户专业知识的演变”(斯坦福,2013 年)。
9.1 使用 Bags 读取和解析非结构化数据
在您下载数据后,您需要做的第一件事是正确读取和解析数据,这样您就可以轻松地操作它。我们将首先介绍的场景是
使用 Amazon Fine Foods Reviews 数据集,确定其格式并将数据解析为字典的 Bag。
这个特定的数据集是一个纯文本文件。您可以用任何文本编辑器打开它,并开始理解文件的布局。Bag API 提供了一些方便的方法来读取文本文件。除了纯文本,Bag API 还配备了读取 Apache Avro 格式文件的能力,这是一种流行的 JSON 数据的二进制格式,通常以文件扩展名.avro 表示。用于读取纯文本文件的函数是read_text,它只有几个参数。在其最简单形式中,它只需要一个文件名。如果您想将多个文件读入一个 Bag 中,您可以传递一个文件名列表或一个包含通配符(如*.txt)的字符串。在这种情况下,文件名列表中的所有文件应该具有相同类型的信息;例如,随着时间的推移收集的日志数据,其中每个文件代表一天的事件记录。read_text函数还原生支持大多数压缩方案(如 GZip 和 BZip),因此您可以将数据压缩在磁盘上。在某些情况下,将数据压缩在磁盘上可以通过减少机器的输入/输出子系统负载来提供显著的性能提升,所以这通常是一个好主意。让我们看看read_text函数将产生什么。
列表 9.1 将文本数据读入 Bag
import dask.bag as bag
import os
os.chdir('/Users/jesse/Documents')
raw_data = bag.read_text('foods.txt')
raw_data
# Produces the following output:
# dask.bag<bag-fro..., npartitions=1>
如你所期望的,read_text操作会产生一个惰性对象,它只有在实际上对其执行计算类型操作时才会被评估。Bag 的元数据表明它将整个数据读作一个分区。由于这个文件的大小相当小,这可能没问题。然而,如果我们想手动增加并行性,read_text还接受一个可选的blocksize参数,允许你指定每个分区的大小(以字节为单位)。例如,要将大约 400 MB 的文件分成四个分区,我们可以指定一个 100,000,000 字节的 blocksize,这相当于 100 MB。这将导致 Dask 将文件分成四个分区。
9.1.1 从 Bag 中选择和查看数据
现在我们已经从数据中创建了一个 Bag,让我们看看数据看起来是什么样子。take方法允许我们查看 Bag 中的小部分项目,就像head方法允许我们对 DataFrames 做同样的操作。只需指定你想要查看的项目数量,Dask 就会打印出结果。
列表 9.2 查看 Bag 中的项目
raw_data.take(10)
# Produces the following output:
'''('product/productId: B001E4KFG0\n',
'review/userId: A3SGXH7AUHU8GW\n',
'review/profileName: delmartian\n',
'review/helpfulness: 1/1\n',
'review/score: 5.0\n',
'review/time: 1303862400\n',
'review/summary: Good Quality Dog Food\n',
'review/text: I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than most.\n',
'\n',
'product/productId: B00813GRG4\n')'''
如从列表 9.2 的结果中可以看出,Bag 中的每个元素当前代表文件中的一行。然而,这种结构将证明对我们的分析是有问题的。一些元素之间存在明显的关联。例如,显示的review/score元素是前一个产品 ID(B001E4KFG0)的评论评分。但由于这些元素在结构上没有关联,很难进行像计算项目B001E4KFG0的平均评论评分这样的操作。因此,我们应该通过将关联的元素组合成一个单一对象来给这些数据添加一些结构。
9.1.2 常见解析问题和解决方法
在处理文本数据时,一个常见的问题是要确保数据是以与写入时相同的字符编码进行读取的。字符编码用于将存储为二进制的原始数据映射成我们人类可以识别的符号。例如,大写字母J使用 UTF-8 编码表示为01001010。如果你使用 UTF-8 编码在文本编辑器中打开一个文本文件进行解码,文件中遇到的每个01001010都会在显示到屏幕上之前被翻译成J。
使用正确的字符编码确保数据将被正确读取,你不会看到任何乱码文本。默认情况下,read_text函数假定数据使用 UTF-8 编码。由于 Bag 本质上是惰性的,这个假设的验证不是提前进行的,这意味着只有在你对整个数据集执行函数时才会发现问题。例如,如果我们想计算 Bag 中的项目数量,我们可以使用count函数。
列表 9.3 在计数 Bag 中的项目时暴露编码错误
raw_data.count().compute()
# Raises the following exception:
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xce in position 2620: invalid continuation byte
与 DataFrame API 中的 count 函数看起来完全相同的 count 函数会引发 UnicodeDecodeError 异常。这告诉我们,文件可能不是以 UTF-8 编码的,因为它无法被解析。这些问题通常会在文本使用任何在英语字母表中未使用的字符(如重音符号、汉字、平假名和阿拉伯字母)时出现。如果你能够询问文件的制作者使用了哪种编码,你可以简单地使用 encoding 参数将编码添加到 read_text 函数中。如果你无法找出文件保存的编码,就需要进行一些试错来确定使用哪种编码。一个好的起点是尝试 cp1252 编码,这是 Windows 使用的标准编码。实际上,这个示例数据集就是使用 cp1252 编码的,因此我们可以修改 read_text 函数以使用 cp1252 并再次尝试我们的 count 操作。
列表 9.4 修改 read_text 函数的编码
raw_data = bag.read_text('foods.txt', encoding='cp1252')
raw_data.count().compute()
# Produces the following output:
# 5116093
这次,文件能够被完全解析,并且我们得知文件包含 5.11 百万行。
9.1.3 使用分隔符进行工作
解决了编码问题后,让我们看看我们如何添加所需的结构来将每条评论的属性分组在一起。由于我们正在处理的文件只是一个长字符串的文本数据,我们可以寻找文本中的模式,这些模式可能有助于将文本划分为逻辑块。图 9.3 #figure9.3 展示了一些关于文本中一些有用模式的提示。

图 9.3 一个模式使我们能够将文本分割成单个评论。
在这个特定的例子中,数据集的作者在每个审查之间放置了两个换行符(显示为\n)。我们可以使用这个模式作为分隔符来分割文本,其中每个文本块包含审查的所有属性,例如产品 ID、评分、审查文本等。我们将需要手动使用 Python 标准库中的某些函数来解析文本文件。然而,我们想要避免的是为了完成这项工作而将整个文件读入内存。尽管这个文件可以轻松地放入内存,但一旦开始处理超出你机器限制的数据集,这种读取整个文件到内存的方法将无法工作(而且这还会违背并行化的整个目的!)。因此,我们将使用 Python 的文件迭代器一次流式传输文件的一小部分,在缓冲区中搜索我们想要的分隔符,标记文件中审查开始和结束的位置,然后移动缓冲区以找到下一个审查的位置。最终,我们将得到一个包含指向每个审查开始和结束位置的指针的延迟对象列表,这些对象可以进一步解析为键值对字典。从开始到结束的完整过程在图 9.4 中的流程图中概述。

图 9.4 使用 Dask Delayed 实现的定制文本解析算法
首先,我们将定义一个函数,用于在文件的一部分中搜索指定的分隔符。使用 Python 的文件句柄系统,可以从文件的特定字节位置开始流式传输数据,并停止在特定的字节位置。例如,文件的开始是字节 0。下一个字符是字节 1,以此类推。我们不必将整个文件加载到内存中,可以一次加载一块。例如,我们可以从字节 5000 开始加载 1000 字节的数据。我们将 1000 字节的数据加载到内存中的空间称为缓冲区。我们可以解码缓冲区,从原始字节到字符串对象,然后使用 Python 中所有可用的字符串操作函数,例如find、strip、split等。而且,由于在这个例子中缓冲区空间只有 1000 字节,这大约就是我们将使用的所有内存。
我们需要一个函数来
-
接受一个文件句柄、起始位置(例如字节 5000)和缓冲区大小。
-
然后将数据读入缓冲区并搜索缓冲区中的分隔符。
-
如果找到了,它应该返回分隔符相对于起始位置的位置。
-
然而,我们还需要应对可能出现的审查内容比我们的缓冲区大小还要长的情况,这会导致无法找到分隔符。
-
如果发生这种情况,代码应该通过一次又一次地读取下一个 1000 字节来不断扩展缓冲区的搜索空间,直到找到分隔符。
这里有一个将执行此操作的函数。
列表 9.5 查找文件句柄中分隔符下一个出现位置的函数
from dask.delayed import delayed
def get_next_part(file, start_index, span_index=0, blocksize=1000):
file.seek(start_index) ①
buffer = file.read(blocksize + span_index).decode('cp1252') ②
delimiter_position = buffer.find('\n\n')
if delimiter_position == -1: ③
return get_next_part(file, start_index, span_index + blocksize)
else:
file.seek(start_index)
return start_index, delimiter_position
给定一个文件句柄和起始位置,此函数将找到分隔符的下一个出现位置。如果在当前缓冲区中没有找到分隔符,将发生递归函数调用。这会将当前缓冲区大小添加到span_index参数中。这就是如果分隔符搜索失败,窗口如何继续扩展的原因。第一次调用该函数时,span_index将为 0。默认blocksize为 1000,这意味着函数将读取起始位置之后的下一个 1000 个字节(1000 blocksize + 0 span_index)。如果查找失败,函数将在将span_index增加 1000 之后再次调用。然后它将尝试在起始位置之后的下一个 2000 个字节中再次搜索(1000 blocksize + 1000 span_index)。如果查找继续失败,搜索窗口将以 1000 字节为单位继续扩展,直到最终找到分隔符或达到文件末尾。这个过程的一个视觉示例可以在图 9.5 中看到。

图 9.5 递归分隔符搜索函数的视觉表示
要在文件中找到所有分隔符的实例,我们可以在一个循环中调用此函数,该循环将分块迭代,直到达到文件末尾。为此,我们将使用while循环。
列表 9.6 查找分隔符的所有实例
with open('foods.txt', 'rb') as file_handle:
size = file_handle.seek(0,2) – 1 ①
more_data = True ②
output = []
current_position = next_position = 0
while more_data:
if current_position >= size: ③
more_data = False
else:
current_position, next_position = get_next_part(file_handle, current_position, 0)
output.append((current_position, next_position))
current_position = current_position + next_position + 2
实际上,这段代码完成了四件事情:
-
找到每个评论的起始位置和分隔符的字节数。
-
将所有这些位置保存到列表中。
-
将评论的字节位置分配给工作进程。
-
工作人员在他们接收的字节位置处理评论数据。
在初始化几个变量后,我们从字节 0 开始进入循环。每次找到分隔符时,当前位置将向前推进到分隔符之后的位置。例如,如果第一个分隔符从字节 627 开始,第一个评论由字节 0 到 626 组成。字节 0 和 626 将被追加到输出列表中,当前位置将推进到 628。我们将next_position变量增加 2,因为分隔符是两个字节(每个‘\n’是一个字节)。因此,由于我们并不真正关心将分隔符作为最终评论对象的一部分保留,我们将跳过它们。下一次分隔符的搜索将从字节 629 开始,这应该是下一个评论的第一个字符。这个过程一直持续到文件末尾。到那时,我们有一个元组的列表。每个元组的第一个元素代表起始字节,第二个元素代表在起始字节之后要读取的字节数。元组列表看起来像这样:
[(0, 471),
(473, 390),
(865, 737),
(1604, 414),
(2020, 357),
...]
在继续之前,使用len函数检查output列表的长度。列表应包含 568,454 个元素。
现在我们已经有一个包含所有审查字节位置的列表,我们需要创建一些指令将地址列表转换为实际审查的列表。为此,我们需要创建一个函数,该函数接受起始位置和字节数作为输入,读取指定字节位置的文件,并返回一个解析的审查对象。由于有数千条审查需要解析,我们可以使用 Dask 来加速此过程。图 9.6 展示了如何将工作分配给多个工作者。

图 9.6 将解析代码映射到审查数据
实际上,地址列表将被分配给工作者;每个工作者将打开文件并在接收的字节位置解析审查。由于审查以 JSON 格式存储,我们将为每个审查创建一个字典对象来存储其属性。每个审查的属性看起来像这样:'review/userId: A3SGXH7AUHU8GW\n',因此我们可以利用每个键以 ': ' 结尾的模式将数据分割成字典的键值对。下一个列表将展示一个执行此操作的函数。
列表 9.7 将每个字节流解析成键值对字典
def get_item(filename, start_index, delimiter_position, encoding='cp1252'):
with open(filename, 'rb') as file_handle: ①
file_handle.seek(start_index) ②
text = file_handle.read(delimiter_position).decode(encoding)
elements = text.strip().split('\n') ③
key_value_pairs = [(element.split(': ')[0], element.split(': ')[1])
if len(element.split(': ')) > 1
else ('unknown', element)
for element in elements] ④
return dict(key_value_pairs)
现在我们已经有一个可以解析文件指定部分的函数,我们需要将这些指令发送给工作者,以便他们可以将解析代码应用于数据。现在我们将所有内容整合在一起,创建一个包含解析审查的 Bag。
列表 9.8 生成审查的 Bag
reviews = bag.from_sequence(output).map(lambda x: get_item('foods.txt', x[0], x[1]))
列表 9.8 中的代码做了两件事:首先,我们使用 Bag 数组的 from_sequence 函数将字节地址列表转换为 Bag。这创建了一个包含与原始列表相同字节地址列表的 Bag,但现在允许 Dask 将 Bag 的内容分发到工作者。接下来,调用 map 函数将每个字节地址元组转换为相应的审查对象。Map 实际上向工作者分配了字节地址的 Bag 和 get_item 函数中包含的指令(记住,当 Dask 以本地模式运行时,工作者是机器上的独立线程)。创建了一个新的名为 reviews 的 Bag,当它被计算时,将输出解析的审查。在 列表 9.8 中,我们通过一个 lambda 表达式传递 get_item 函数,这样我们就可以在动态输入 Bag 中每个项目的起始和结束字节地址的同时保持文件名参数固定。与之前一样,整个过程是惰性的。列表 9.8 的结果将显示创建了一个包含 101 个分区的 Bag。然而,从 Bag 中提取元素现在将产生非常不同的输出!
列表 9.9 从转换后的 Bag 中提取元素
reviews.take(2)
# Produces the following output:
'''({'product/productId': 'B001E4KFG0',
'review/userId': 'A3SGXH7AUHU8GW',
'review/profileName': 'delmartian',
'review/helpfulness': '1/1',
'review/score': '5.0',
'review/time': '1303862400',
'review/summary': 'Good Quality Dog Food',
'review/text': 'I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than most.'},
{'product/productId': 'B00813GRG4',
'review/userId': 'A1D87F6ZCVE5NK',
'review/profileName': 'dll pa',
'review/helpfulness': '0/0',
'review/score': '1.0',
'review/time': '1346976000',
'review/summary': 'Not as Advertised',
'review/text': 'Product arrived labeled as Jumbo Salted Peanuts...the peanuts were actually small sized unsalted. Not sure if this was an error or if the vendor intended to represent the product as "Jumbo".'})'''
现在转换后的 Bag 中的每个元素都是一个包含所有评论属性的字典!这将使我们的分析变得容易得多。此外,如果我们计算转换后的 Bag 中的项目数量,我们也会得到一个截然不同的结果。
列表 9.10 计算转换后的 Bag 中的项目数量
from dask.diagnostics import ProgressBar
with ProgressBar():
count = reviews.count().compute()
count
# Produces the following output:
'''
[########################################] | 100% Completed | 8.5s
568454
'''
由于我们将原始文本组装成逻辑评论,Bag 中的元素数量已经大大减少。这个计数也符合斯坦福在数据集网页上声明的评论数量,因此我们可以确信我们已经正确解析了数据,没有遇到任何更多的编码问题!现在我们的数据更容易处理了,我们将探讨一些其他使用 Bags 操作数据的方法。
9.2 转换、过滤和折叠元素
与 Python 中的列表和其他通用集合不同,Bags 不可索引,这意味着无法以直接的方式访问 Bag 中的特定元素。这可能会使数据处理稍微有些挑战,直到你习惯于从转换的角度思考数据处理。如果你熟悉函数式编程或 MapReduce 风格的编程,这种思维方式会自然而然地出现。然而,如果你有 SQL、电子表格和 Pandas 的背景,一开始可能会觉得有点反直觉。如果情况是这样,请不要担心。经过一些练习,你也能开始从转换的角度思考数据处理!
我们接下来要使用的动机场景如下:
使用亚马逊精选食品评论数据集,通过使用评论分数作为阈值来标记评论为正面或负面。
9.2.1 使用 map 方法转换元素
让我们从简单开始——首先,我们将简单地获取整个数据集的所有评论分数。为此,我们将再次使用 map 函数。与其将我们试图做的事情视为获取评论分数,不如将其视为将我们的评论 Bag 转换为评论分数 Bag 的转换。我们需要某种函数,该函数将接受一个评论(字典)对象作为输入,并输出评论分数。一个执行此操作的函数看起来像这样。
列表 9.11 从字典中提取值
def get_score(element):
score_numeric = float(element['review/score'])
return score_numeric
这只是普通的 Python。我们可以将任何字典传递给这个函数,如果它包含 review/score 键,这个函数会将值转换为浮点数并返回该值。如果我们使用这个函数映射我们的字典 Bag,它将把每个字典转换成一个包含相关评论分数的浮点数。这很简单。
列表 9.12 获取评论分数
review_scores = reviews.map(get_score)
review_scores.take(10)
# Produces the following output:
# (5.0, 1.0, 4.0, 2.0, 5.0, 4.0, 5.0, 5.0, 5.0, 5.0)
review_scores Bag 现在包含所有原始评论分数。你创建的转换可以是任何有效的 Python 函数。例如,如果我们想根据评论分数将评论标记为正面或负面,我们可以使用这样的函数。
列表 9.13 将评论标记为正面或负面
def tag_positive_negative_by_score(element):
if float(element['review/score']) > 3:
element['review/sentiment'] = 'positive'
else:
element['review/sentiment'] = 'negative'
return element
reviews.map(tag_positive_negative_by_score).take(2)
'''
Produces the following output:
({'product/productId': 'B001E4KFG0',
'review/userId': 'A3SGXH7AUHU8GW',
'review/profileName': 'delmartian',
'review/helpfulness': '1/1',
'review/score': '5.0',
'review/time': '1303862400',
'review/summary': 'Good Quality Dog Food',
'review/text': 'I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than most.',
'review/sentiment': 'positive'}, ①
{'product/productId': 'B00813GRG4',
'review/userId': 'A1D87F6ZCVE5NK',
'review/profileName': 'dll pa',
'review/helpfulness': '0/0',
'review/score': '1.0',
'review/time': '1346976000',
'review/summary': 'Not as Advertised',
'review/text': 'Product arrived labeled as Jumbo Salted Peanuts...the peanuts were actually small sized unsalted. Not sure if this was an error or if the vendor intended to represent the product as "Jumbo".',
'review/sentiment': 'negative'})''' ②
在 列表 9.13 中,我们如果评论的评分大于三星,则将其标记为正面;否则,将其标记为负面。您可以看到,当我们从转换后的包中取出一些元素时,新的 review/sentiment 元素会被显示出来。但是请注意:虽然看起来我们修改了原始数据,因为我们正在为每个字典分配新的键值对,但原始数据实际上保持不变。包,就像 DataFrames 和 Arrays 一样,是不可变对象。幕后发生的事情是每个旧字典被转换为其自身的副本,并添加了额外的键值对,从而保持原始数据不变。我们可以通过查看原始的 reviews 包来确认这一点。
列表 9.14 展示包的不可变性
reviews.take(1)
'''
Produces the following output:
({'product/productId': 'B001E4KFG0',
'review/userId': 'A3SGXH7AUHU8GW',
'review/profileName': 'delmartian',
'review/helpfulness': '1/1',
'review/score': '5.0',
'review/time': '1303862400',
'review/summary': 'Good Quality Dog Food',
'review/text': 'I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than most.'},)
'''
如您所见,review/sentiment 键无处可寻。就像在处理 DataFrames 时一样,请注意不可变性,以确保您不会遇到数据消失的问题。
9.2.2 使用 filter 方法过滤包
包的第二项重要数据操作是过滤。尽管包没有提供轻松访问特定元素的方法,比如包中的第 45 个元素,但它们确实提供了一个轻松搜索特定数据的方法。过滤表达式是返回 True 或 False 的 Python 函数。filter 方法将过滤表达式映射到包上,任何在评估过滤表达式时返回 True 的元素都会被保留。相反,任何在评估过滤表达式时返回 False 的元素都会被丢弃。例如,如果我们想找到所有关于产品 B001E4KFG0 的评论,我们可以创建一个过滤表达式来返回这些数据。
列表 9.15 搜索特定产品
specific_item = reviews.filter(lambda element: element['product/productId'] == 'B001E4KFG0')
specific_item.take(5)
'''
Produces the following output:
/anaconda3/lib/python3.6/site-packages/dask/bag/core.py:2081: UserWarning: Insufficient elements for `take`. 5 elements requested, only 1 elements available. Try passing larger `npartitions` to `take`.
"larger `npartitions` to `take`.".format(n, len(r)))
({'product/productId': 'B001E4KFG0',
'review/userId': 'A3SGXH7AUHU8GW',
'review/profileName': 'delmartian',
'review/helpfulness': '1/1',
'review/score': '5.0',
'review/time': '1303862400',
'review/summary': 'Good Quality Dog Food',
'review/text': 'I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than most.'},)
'''
列表 9.15 返回我们请求的数据,同时还有一个警告告诉我们包中的元素比我们请求的少,表明我们指定的产品只有一个评论。我们还可以轻松地进行模糊匹配搜索。例如,我们可以找到所有提到“狗”的评论。
列表 9.16 查找所有提到“狗”的评论
keyword = reviews.filter(lambda element: 'dog' in element['review/text'])
keyword.take(5)
'''
Produces the following output:
({'product/productId': 'B001E4KFG0',
'review/userId': 'A3SGXH7AUHU8GW',
'review/profileName': 'delmartian',
'review/helpfulness': '1/1',
'review/score': '5.0',
'review/time': '1303862400',
'review/summary': 'Good Quality Dog Food',
'review/text': 'I have bought several of the Vitality canned dog food products and have found them all to be of good quality. The product looks more like a stew than a processed meat and it smells better. My Labrador is finicky and she appreciates this product better than most.'},
...)
'''
而且,就像映射操作一样,过滤表达式也可以变得更加复杂。为了演示,让我们使用以下场景作为动机:
使用 Amazon Fine Foods Reviews 数据集,编写一个 filter 函数,移除其他亚马逊客户认为“有用”的评论。
亚马逊允许用户对评论的有用性进行评分。review/helpfulness 属性表示用户说评论有帮助的次数与用户为评论投票的次数之比。有用性为 1/3 表示有三个用户评估了评论,但只有一个人认为评论有帮助(这意味着其他两个人没有认为评论有帮助)。无用的评论可能要么是评论者不公平地给出了非常低的分数或非常高的分数而没有在评论中加以说明。可能最好从数据集中消除无用的评论,因为它们可能无法公平地代表产品的质量或价值。让我们通过比较有无无用评论的平均评论评分来查看无用的评论是如何影响数据的。首先,我们将创建一个过滤表达式,如果超过 75% 的投票用户认为评论有帮助,则该表达式将返回 True,从而删除低于该阈值的任何评论。
列表 9.17 一个过滤表达式,用于过滤掉无用的评论
def is_helpful(element):
helpfulness = element['review/helpfulness'].strip().split('/') ①
number_of_helpful_votes = float(helpfulness[0])
number_of_total_votes = float(helpfulness[1])
# Watch for divide by 0 errors
if number_of_total_votes > 1: ②
return number_of_helpful_votes / number_of_total_votes > 0.75
else:
return False
与在列表 9.15 和 9.16 中使用 lambda 表达式定义的简单过滤表达式不同,我们将为此过滤表达式定义一个函数。在我们能够评估发现评论有帮助的用户百分比之前,我们首先必须通过解析和转换原始的有用性评分来计算这个百分比。同样,我们可以使用普通的 Python 和局部作用域变量来完成这项工作。我们在计算周围添加了一些保护措施,以捕获任何潜在的除以零错误(注意:实际上,这意味着我们假设尚未投票的评论被认为是无用的)。如果至少有一个投票,我们返回一个布尔表达式,如果超过 75% 的用户认为评论有帮助,则该表达式将评估为 True。现在我们可以将其应用于数据以查看会发生什么。
列表 9.18 查看过滤后的数据
helpful_reviews = reviews.filter(is_helpful)
helpful_reviews.take(2)
'''
Produces the following output:
({'product/productId': 'B000UA0QIQ',
'review/userId': 'A395BORC6FGVXV',
'review/profileName': 'Karl',
'review/helpfulness': '3/3', ①
'review/score': '2.0',
'review/time': '1307923200',
'review/summary': 'Cough Medicine',
'review/text': 'If you are looking for the secret ingredient in Robitussin I believe I have found it. I got this in addition to the Root Beer Extract I ordered (which was good) and made some cherry soda. The flavor is very medicinal.'},
{'product/productId': 'B0009XLVG0',
'review/userId': 'A2725IB4YY9JEB',
'review/profileName': 'A Poeng "SparkyGoHome"',
'review/helpfulness': '4/4',
'review/score': '5.0',
'review/time': '1282867200',
'review/summary': 'My cats LOVE this "diet" food better than their regular food',
'review/text': "One of my boys needed to lose some weight and the other didn't. I put this food on the floor for the chubby guy, and the protein-rich, no by-product food up higher where only my skinny boy can jump. The higher food sits going stale. They both really go for this food. And my chubby boy has been losing about an ounce a week."})
'''
9.2.3 在“包”上计算描述性统计
如预期的那样,过滤后的“包”中的所有评论都是“有帮助的”。现在让我们看看这如何影响评论评分。
列表 9.19 比较平均评论评分
helpful_review_scores = helpful_reviews.map(get_score)
with ProgressBar():
all_mean = review_scores.mean().compute()
helpful_mean = helpful_review_scores.mean().compute()
print(f"Mean Score of All Reviews: {round(all_mean, 2)}\nMean Score of Helpful Reviews: {round(helpful_mean,2)}")
# Produces the following output:
# Mean Score of All Reviews: 4.18
# Mean Score of Helpful Reviews: 4.37
在 列表 9.19 中,我们首先通过映射 get_score 函数从过滤后的评论袋子中提取分数。然后,我们可以对包含评论分数的每个袋子调用 mean 方法。计算平均值后,输出将显示。比较平均值可以让我们看到评论的有用性与评论的情感之间是否存在任何关系。负面评论通常被视为有帮助的吗?无帮助的吗?比较平均值可以帮助我们回答这个问题。如所见,如果我们过滤掉无帮助评论,平均评论分数实际上略高于所有评论的平均分数。这很可能是由于负面评论在评论者没有很好地为负面分数辩护的情况下,往往会得到负面投票。我们可以通过查看有帮助或无帮助评论的平均长度来证实我们的怀疑。
列表 9.20 比较基于有用性的平均评论长度
def get_length(element):
return len(element['review/text'])
with ProgressBar():
review_length_helpful = helpful_reviews.map(get_length).mean().compute()
review_length_unhelpful = reviews.filter(lambda review: not is_helpful(review)).map(get_length).mean().compute()
print(f"Mean Length of Helpful Reviews: {round(review_length_helpful, 2)}\nMean Length of Unhelpful Reviews: {round(review_length_unhelpful,2)}")
# Produces the following output:
# Mean Length of Helpful Reviews: 459.36
# Mean Length of Unhelpful Reviews: 379.32
在 列表 9.20 中,我们将 map 和 filter 操作链式连接在一起以生成我们的结果。由于我们已经过滤掉了有帮助的评论,我们可以简单地映射 get_length 函数到有帮助评论的袋子上以提取每条评论的长度。然而,我们之前并没有隔离无帮助评论,所以我们做了以下操作:
-
通过使用
remove_unhelpful_reviews过滤表达式对评论袋进行过滤 -
使用
not操作符反转过滤表达式的行为(保留无帮助评论,丢弃有帮助评论) -
使用
map与get_length函数一起计算每条无帮助评论的长度 -
最后,计算了所有评论长度的平均值
看起来无帮助评论的平均长度确实比有帮助评论短。这意味着评论越长,越有可能被社区投票为有帮助。
9.2.4 使用 foldby 方法创建聚合函数
使用 Bags 的最后一个重要数据操作是 折叠。折叠是一种特殊的 reduce 操作。虽然 reduce 操作在本章中没有明确提及,但我们已经在整本书中看到了许多 reduce 操作,甚至在之前的代码列表中也是如此。正如你可能从名字猜到的,reduce 操作会将 Bag 中的项目集合减少到单个值。例如,之前代码列表中的mean方法将原始评论分数的 Bag 减少到单个值:平均值。reduce 操作通常涉及对 Bag 中的值进行某种聚合,例如求和、计数等。无论 reduce 操作做什么,它总是导致一个单一值。另一方面,折叠允许我们在聚合中添加一个分组。一个很好的例子是按评论分数计数评论的数量。与其使用 reduce 操作计数 Bag 中的所有项目,不如使用折叠操作可以让我们计数每个组中的项目数量。这意味着折叠操作将 Bag 中的元素数量减少到指定分组中存在的不同组数。在按评论分数计数评论的例子中,这将导致将我们的原始 Bag 减少到五个元素,因为有五种可能的不同的评论分数。图 9.7 展示了折叠的一个示例。
首先,我们需要定义两个函数来传递给foldby方法。这些函数被称为binop和combine函数。

图 9.7 折叠操作的示例
binop函数定义了每个组中的元素应该做什么,并且总是有两个参数:一个用于累加器,一个用于元素。累加器用于在binop函数的调用之间保持中间结果。在这个例子中,由于我们的binop函数是一个计数函数,它每次调用binop函数时都会将累加器加一。由于binop函数为组中的每个元素调用,这会导致每个组中的项目计数。如果需要访问每个元素的值,例如如果我们想求和评论分数,它可以通过binop函数的element参数访问。一个sum函数将简单地把元素加到累加器上。
combine 函数定义了在 Bag 的分区上binop函数的结果应该如何处理。例如,我们可能在几个分区中有评分是三颗星的评论。我们希望计算整个 Bag 中三颗星评论的总数,因此每个分区的中间结果应该相加。就像binop函数一样,combine函数的第一个参数是一个累加器,第二个参数是一个元素。构建这两个函数可能具有挑战性,但你可以有效地将其视为一个“按组”操作。binop函数指定了对分组数据的操作,而combine函数定义了跨分区的组应该如何处理。
现在我们来看看代码中的样子。
列表 9.21 使用foldby按评论评分计数
def count(accumulator, element): ①
return accumulator + 1
def combine(total1, total2): ②
return total1 + total2
with ProgressBar(): ③
count_of_reviews_by_score = reviews.foldby(get_score, count, 0, combine, 0).compute()
count_of_reviews_by_score
foldby方法的五个必需参数,从左到右依次是key函数、binop函数、binop累加器的初始值、combine函数以及combine累加器的初始值。key函数定义了应该按什么值进行分组。通常,key函数将只返回一个用作分组键的值。在前面的例子中,它简单地使用本章前面定义的get_score函数返回评论评分的值。列表 9.21 的输出如下。
列表 9.22 foldby操作的输出
# [(5.0, 363122), (1.0, 52268), (4.0, 80655), (2.0, 29769), (3.0, 42640)]
代码运行后留下的是一个元组列表,其中第一个元素是key,第二个元素是binop函数的结果。例如,有 363,122 条评论被评为五星级。鉴于平均评论评分较高,大多数评论给出了五星级评分并不令人惊讶。有趣的是,一星级评论的数量比两星级或三星级评论多。在这个数据集中,近 75%的所有评论都是五星级或一星级——似乎大多数评论者要么非常喜欢他们的购买,要么非常讨厌它。为了更好地了解数据,让我们深入了解一下评论评分和评论有用性的统计数据。
9.3 从 Bags 构建数组和 DataFrame
由于表格格式非常适合数值分析,因此即使你开始一个项目时使用的是非结构化数据集,随着你清理和整理数据,你可能需要将一些转换后的数据放入更结构化的格式中。因此,了解如何使用开始于 Bag 的数据构建其他类型的数据结构是很有用的。在本章中我们一直在查看的 Amazon Fine Foods 数据集中,我们有一些数值数据,例如之前计算出的评论分数和有用性百分比。为了更好地理解这些值告诉我们关于评论的信息,生成这些值的描述性统计信息将是有帮助的。正如我们在第六章中提到的,Dask 在 Dask Array API 的 stats 模块中提供了广泛的统计函数。现在,我们将看看如何将我们想要分析的 Bag 数据转换为 Dask Array,以便我们可以使用这些统计函数。首先,我们将创建一个函数,该函数将隔离评论分数并计算每个评论的有用性百分比。
列表 9.23 获取每个评论的评论分数和有用性评分的函数
def get_score_and_helpfulness(element):
score_numeric = float(element['review/score']) ①
helpfulness = element['review/helpfulness'].strip().split('/') ②
number_of_helpful_votes = float(helpfulness[0])
number_of_total_votes = float(helpfulness[1])
# Watch for divide by 0 errors
if number_of_total_votes > 0:
helpfulness_percent = number_of_helpful_votes / number_of_total_votes
else:
helpfulness_percent = 0.
return (score_numeric, helpfulness_percent) ③
列表 9.23 中的代码应该看起来很熟悉。它本质上结合了 get_score 函数和用于移除无用评论的过滤函数中的有用性分数计算。由于此函数返回两个值的元组,使用此函数映射评论的 Bag 将导致一个包含元组的 Bag。这有效地模仿了表格数据的行-列格式,因为 Bag 中的每个元组长度相同,并且每个元组的值具有相同的意义。
为了轻松地将具有适当结构的 Bag 转换为 DataFrame,Dask Bags 有一个 to_dataframe 方法。现在我们将创建一个包含评论分数和有用性值的 DataFrame。
列表 9.24 从 Bag 创建 DataFrame
scores_and_helpfulness = reviews.map(get_score_and_helpfulness).to_dataframe(meta={'Review Scores': float, 'Helpfulness Percent': float})
to_dataframe 方法接受一个参数,指定每列的名称和数据类型。这本质上与我们在第五章中介绍的 drop-assign-rename 模式多次看到的 meta 参数相同。该参数接受一个字典,其中键是列名,值是列的数据类型。在 DataFrame 中,你现在可以使用之前学到的所有关于 DataFrame 的知识来分析和可视化数据!例如,计算描述性统计与之前相同。
列表 9.25 计算描述性统计
with ProgressBar():
scores_and_helpfulness_stats = scores_and_helpfulness.describe().compute()
scores_and_helpfulness_stats
列表 9.25 生成如图 9.8 所示的输出。

图 9.8 评论分数和有用性百分比的描述性统计
回顾评分的描述性统计为我们提供了一些额外的洞察,但总体上只是告诉我们我们已经知道的事情:评论几乎都是正面的。然而,有用率的百分比却有点更有趣。平均有用率评分仅为约 41%,这表明在大多数情况下,评论者并没有发现评论是有帮助的。然而,这很可能是由于大量没有投票的评论数量较多所影响。这可能表明,亚马逊购物者通常对食品产品的评论持冷漠态度,因此当评论有帮助时不会特意去说些什么——这可能是因为口味差异很大——或者典型的亚马逊购物者确实没有发现这些评论很有帮助。也许将这些建议与对其他类型(非食品)商品的评论进行比较,看看这会不会在评论的参与度上有所差异,会很有趣。
9.4 使用 Bags 进行 NLTK 的并行文本分析
正如我们研究了如何在 Bags 中转换和过滤元素,可能已经对你有所启示:如果所有的转换函数都是普通的 Python,我们应该能够使用任何与通用集合一起工作的 Python 库——这正是 Bags 如此强大和灵活的原因!在本节中,我们将通过一些典型任务来介绍如何使用流行的文本分析库 NLTK(自然语言工具包)准备和分析文本数据。为了激发这个示例,我们将使用以下场景:
使用 NLTK 和 Dask Bags,找出亚马逊产品正负评论文本中最常提到的短语,以了解评论者在评论中经常讨论的内容。
9.4.1 双语分析的基本原理
为了了解这个数据集中的评论者都在写些什么,我们将对评论文本进行二元分析。二元是文本中相邻单词的成对。二元通常比简单地计数单个单词的频率更有用,因为它们通常提供了更多的上下文。例如,我们可能会预期正面评论中“好”这个词会非常频繁地出现,但这并不能真正帮助我们理解什么是好。二元“好味道”或“好包装”告诉我们评论者对产品持正面看法的很多信息。为了更好地理解评论的真实主题或情感,我们还需要做的一件事是移除那些不帮助传达该信息的单词。英语中许多单词为句子增添了结构,但并不传达信息。例如,像“the”、“a”和“an”这样的冠词并不提供任何上下文或信息。因为这些单词非常常见(并且对于正确的句子结构是必要的),我们在正面评论和负面评论中都同样可能找到这些单词。由于它们不提供任何信息,我们将移除它们。这些被称为停用词,在进行文本分析时,检测和移除停用词是最重要的数据准备任务之一。图 9.9 展示了几个常见停用词的示例。

图 9.9 停用词示例
我们将遵循以下步骤进行二元分析:
-
提取文本数据。
-
移除停用词。
-
创建二元。
-
计算二元频率。
-
找到前 10 个二元。
9.4.2 提取标记和过滤停用词
在我们开始之前,请确保您已经正确地在 Python 环境中设置了 NLTK。有关安装和配置 NLTK 的说明,请参阅附录。安装了 NLTK 之后,我们需要将相关模块导入到当前工作区;然后我们将创建一些函数来帮助我们进行数据准备。
列表 9.26 提取和过滤函数
from nltk.corpus import stopwords
from nltk.tokenize import RegexpTokenizer
from functools import partial
tokenizer = RegexpTokenizer(r'\w+') ①
def extract_reviews(element): ②
return element['review/text'].lower()
def filter_stopword(word, stopwords): ③
return word not in stopwords
def filter_stopwords(tokens, stopwords): ④
return list(filter(partial(filter_stopword, stopwords=stopwords), tokens))
stopword_set = set(stopwords.words('english')) ⑤
在列表 9.26 中,我们定义了一些函数来帮助从原始的 Bag 中抓取评论文本并过滤掉停用词。需要指出的一点是filter_stopwords函数内部使用了partial函数。使用partial函数允许我们在保持word参数动态的同时冻结stopwords参数的值。由于我们想要将每个单词与相同的停用词列表进行比较,因此stopwords参数的值应该保持静态。在定义了我们的数据准备函数之后,我们现在将映射到评论 Bag 上以提取和清理评论文本。
列表 9.27 提取、标记化和清理评论文本
review_text = reviews.map(extract_reviews) ①
review_text_tokens = review_text.map(tokenizer.tokenize) ②
review_text_clean = review_text_tokens.map(partial(filter_stopwords, stopwords=stopword_set)) ③
review_text_clean.take(1)
# Produces the following output:
'''
(['bought',
'several',
'vitality',
'canned',
'dog',
'food',
'products',
'found',
'good',
'quality',
'product',
'looks',
'like',
'stew',
'processed',
'meat',
'smells',
'better',
'labrador',
'finicky',
'appreciates',
'product',
'better'],)
'''
列表 9.27 中的代码应该相当直接。我们只是简单地使用 map 函数将提取、分词和过滤函数应用于评论的词袋。正如你所见,我们得到了一个包含列表的词袋,每个列表包含每个评论文本中找到的所有独特非停用词。如果我们从这个新词袋中取一个元素,我们会得到第一个评论中所有单词的列表(除了停用词)。这一点很重要:目前我们的词袋是一个嵌套集合。我们稍后会回到这一点。然而,现在我们有了每个评论的清洁单词列表,我们将把我们的标记列表词袋转换成大词袋列表。
列表 9.28 创建大词袋
def make_bigrams(tokens):
return set(nltk.bigrams(tokens))
review_bigrams = review_text_clean.map(make_bigrams)
review_bigrams.take(2)
# Produces the following (abbreviated) output:
'''
({('appreciates', 'product'),
('better', 'labrador'),
('bought', 'several'),
('canned', 'dog'),
...
('vitality', 'canned')},
{('actually', 'small'),
('arrived', 'labeled'),
...
('unsalted', 'sure'),
('vendor', 'intended')})
'''
在 列表 9.28 中,我们只是有一个映射到之前创建的词袋的另一个函数。这同样非常令人兴奋,因为这个过程完全使用 Dask 并行化。这意味着我们可以使用完全相同的代码来分析数十亿或数万亿条评论!正如你所见,我们现在有一个大词袋的列表。然而,我们仍然有嵌套的数据结构。取两个元素会产生两个大词袋的列表。我们想要找到整个词袋中最频繁的大词袋,因此我们需要消除嵌套结构。这被称为 平滑 词袋。平滑移除一个嵌套层级;例如,包含 5 个元素的列表的列表变成一个包含所有 10 个元素的单一列表。
列表 9.29 平滑大词袋
all_bigrams = review_bigrams.flatten()
all_bigrams.take(10)
# Produces the following output:
'''
(('product', 'better'),
('finicky', 'appreciates'),
('meat', 'smells'),
('looks', 'like'),
('good', 'quality'),
('vitality', 'canned'),
('like', 'stew'),
('processed', 'meat'),
('labrador', 'finicky'),
('several', 'vitality'))
'''
在 列表 9.29 中平滑词袋之后,我们现在剩下的是一个包含所有大词袋且没有嵌套的词袋。现在不再可能确定哪个大词袋来自哪个评论,但这没关系,因为这对我们的分析并不重要。我们想要做的是使用大词袋作为键来折叠这个词袋,并计算每个大词袋在数据集中出现的次数。我们可以重用本章中定义的 count 和 compute 函数。
列表 9.30 计数大词袋并找出最常见的 10 个大词袋
with ProgressBar():
top10_bigrams = all_bigrams.foldby(lambda x: x, count, 0, combine, 0).topk(10, key=lambda x: x[1]).compute()
top10_bigrams
# Produces the following output:
'''
[########################################] | 100% Completed | 11min 7.6s
[(('br', 'br'), 103258),
(('amazon', 'com'), 15142),
(('highly', 'recommend'), 14017),
(('taste', 'like'), 13251),
(('gluten', 'free'), 11641),
(('grocery', 'store'), 11627),
(('k', 'cups'), 11102),
(('much', 'better'), 10681),
(('http', 'www'), 10575),
(('www', 'amazon'), 10517)]
'''
列表 9.30 中的 foldby 函数看起来与本章前面看到的 foldby 函数完全一样。然而,我们给它链接着一个新的方法,topk,当 Bag 按降序排序时,它会获取前 k 个元素。在先前的例子中,我们通过方法的第一个参数得到了前 10 个元素。第二个参数,key 参数,定义了 Bag 应该按什么排序。折叠函数返回一个包含元组的 Bag,其中第一个元素是键,第二个元素是频率。我们想要找到最频繁的 10 个二元组,所以 Bag 应该按每个元组的第二个元素排序。因此,key 函数简单地返回每个元组的频率元素。由于key 函数非常简单,所以已经通过使用 lambda 表达式进行了简化。看一下最常见的二元组,看起来我们有一些无用的条目。例如,“amazon com”是第二频繁的二元组。这是有道理的,因为评论来自亚马逊。看起来一些 HTML 也可能泄漏到了评论中,因为“br br”是最常见的二元组。这是指 HTML 标签<br>,表示空白。这些词完全没有帮助或描述性,所以我们应该将它们添加到我们的停用词列表中,并重新运行二元组分析。
列表 9.31 添加更多停用词并重新运行分析
more_stopwords = {'br', 'amazon', 'com', 'http', 'www', 'href', 'gp'}
all_stopwords = stopword_set.union(more_stopwords) ①
filtered_bigrams = review_text_tokens.map(partial(filter_stopwords, stopwords=all_stopwords)).map(make_bigrams).flatten()
with ProgressBar():
top10_bigrams = filtered_bigrams.foldby(lambda x: x, count, 0, combine, 0).topk(10, key=lambda x: x[1]).compute()
top10_bigrams
# Produces the following output:
'''
[########################################] | 100% Completed | 11min 19.9s
[(('highly', 'recommend'), 14024),
(('taste', 'like'), 13343),
(('gluten', 'free'), 11641),
(('grocery', 'store'), 11630),
(('k', 'cups'), 11102),
(('much', 'better'), 10695),
(('tastes', 'like'), 10471),
(('great', 'product'), 9192),
(('cup', 'coffee'), 8988),
(('really', 'good'), 8897)]
'''
9.4.3 分析二元组
现在我们已经移除了额外的停用词,我们可以看到一些清晰的主题。例如,“k 杯”和“咖啡”被提及很多次。这可能是由于许多评论是针对 Keurig 咖啡机的咖啡胶囊。最常见的二元组是“强烈推荐”,这也很有道理,因为许多评论都是积极的。我们可以继续迭代我们的停用词列表,看看是否有新的模式出现(也许我们可以移除像“喜欢”和“商店”这样的词,因为它们没有提供太多信息),但也很想看看负面评论的二元组列表看起来如何。为了结束本章,我们将过滤原始评论集,只保留得到一星或两星的评论,然后看看哪些二元组是最常见的。
列表 9.32 寻找负面评论中最常见的二元组
negative_review_text = reviews.filter(lambda review: float(review['review/score']) < 3).map(extract_reviews) ①
negative_review_text_tokens = negative_review_text.map(tokenizer.tokenize) ②
negative_review_text_clean = negative_review_text_tokens.map(partial(filter_stopwords,
stopwords=all_stopwords))
negative_review_bigrams = negative_review_text_clean.map(make_bigrams)
negative_bigrams = negative_review_bigrams.flatten()
with ProgressBar():
top10_negative_bigrams = negative_bigrams.foldby(lambda x: x, count, 0, combine, 0).topk(10, key=lambda x: x[1]).compute()
top10_negative_bigrams
# Produces the following output:
'''
[########################################] | 100% Completed | 2min 25.9s
[(('taste', 'like'), 3352),
(('tastes', 'like'), 2858),
(('waste', 'money'), 2262),
(('k', 'cups'), 1892),
(('much', 'better'), 1659),
(('thought', 'would'), 1604),
(('tasted', 'like'), 1515),
(('grocery', 'store'), 1489),
(('would', 'recommend'), 1445),
(('taste', 'good'), 1408)]
'''
从 列表 9.32 获得的大词组与所有评论中的大词组有一些相似之处,但也包含一些独特的大词组,这些大词组显示了用户对产品的挫败感或失望(例如,“thought would”、“waste money”等等)。有趣的是,“taste good”是负面评论的大词组。这可能是因为评论者会说类似于“我想它会尝起来很好”或“它不好吃。”这表明数据集需要做更多的工作——可能需要更多的停用词——但现在你有了所有需要的工具来做这件事!我们将在下一章回到这个数据集,届时我们将使用 Dask 的机器学习管道构建一个情感分类器,该分类器将尝试根据文本预测评论是正面还是负面。同时,希望你已经开始欣赏 Dask Bags 在非结构化数据分析中的强大和灵活性。
摘要
-
非结构化数据,如文本,不适合使用 DataFrame 进行分析。Dask Bags 是一个更灵活的解决方案,并且对于操作非结构化数据很有用。
-
Bags 是无序的,并且没有索引的概念(与 DataFrame 不同)。要访问 Bag 的元素,可以使用
take方法。 -
map方法用于使用用户定义的函数将 Bag 的每个元素进行转换。 -
foldby函数使得在映射函数之前对 Bag 的元素进行聚合成为可能。这可以用于所有类型的聚合函数。 -
在分析文本数据时,对文本进行分词和去除停用词有助于提取文本的潜在含义。
-
大词组用于从文本中提取可能比其组成单词更有意义的短语(例如,“不好”与“不”和“好”单独使用时的区别)。
10
使用 Dask-ML 进行机器学习
本章涵盖
-
使用 Dask-ML API 构建机器学习模型
-
使用 Dask-ML API 扩展 scikit-learn
-
使用交叉验证网格搜索验证模型和调整超参数
-
使用序列化保存和发布训练好的模型
数据科学家普遍认为,80/20 规则确实适用于数据科学:也就是说,80% 的时间用于为机器学习项目准备数据,其余 20% 的时间是实际构建和测试机器学习模型。这本书也不例外!到目前为止,我们已经完成了两个不同数据集的收集、清洗和探索过程,使用了两种不同的“风味”——使用 DataFrame 和使用 Bag。现在是时候继续前进,构建我们自己的机器学习模型了!为了参考,图 10.1 展示了我们在工作流程中的进展。我们几乎到达了终点!

图 10.1 在彻底完成数据准备之后,现在是时候转向模型构建了。
在本章中,我们将查看 Dask 的最后一个主要 API:Dask-ML。正如我们看到的,Dask DataFrames 并行化 Pandas,Dask Arrays 并行化 NumPy,Dask-ML 是 scikit-learn 的并行实现。图 10.2 展示了 Dask API 与其提供的底层功能之间的关系。

图 10.2 Dask API 组件回顾
如果你之前有使用 scikit-learn 的经验,你会发现 API 非常熟悉;如果没有,这里学到的知识应该足以让你能够自己继续探索 scikit-learn!Dask-ML 是 Dask 中相对较新的功能,因此与 Dask 的其他 API 相比,它没有太多的时间来成熟。然而,它仍然提供了广泛的功能,并且设计时考虑了灵活性,使其能够解决大多数通常使用 scikit-learn 解决的问题。我们将从上一章的 Amazon Fine Foods 评论继续,并使用以下场景作为探索 Dask-ML 的背景:
使用 Amazon Fine Foods 评论数据集,使用 Dask-ML 训练一个情感分类器模型,该模型可以解释评论是正面还是负面,而无需知道评论分数。
10.1 使用 Dask-ML 构建线性模型
在我们开始构建模型之前,我们需要处理一些事情:
-
我们需要使用第九章中的代码将评论标记为正面或负面。
-
然后,我们需要将数据转换成机器学习模型可以理解的形式。
-
最后,我们需要留出一小部分数据用于测试和验证我们模型的准确性。
首先,评论需要被标记为正面或负面。我们在第九章中通过评论者提供的评论分数进行了一些代码操作来实现这一点。如果评论得到三个星或以上,我们将评论标记为正面。如果评论得到两个星或以下,我们将评论标记为负面。为了回顾,以下是用来完成这一操作的代码。
列表 10.1 根据评论分数标记评论数据
import dask.bag as bag
import os
from dask.diagnostics import ProgressBar
os.chdir('/Users/jesse/Documents') ①
raw_data = bag.read_text('foods.txt')
def get_next_part(file, start_index, span_index=0, blocksize=1024): ②
file.seek(start_index)
buffer = file.read(blocksize + span_index).decode('cp1252')
delimiter_position = buffer.find('\n\n')
if delimiter_position == -1:
return get_next_part(file, start_index, span_index + blocksize)
else:
file.seek(start_index)
return start_index, delimiter_position
def get_item(filename, start_index, delimiter_position, encoding='cp1252'): ③
with open(filename, 'rb') as file_handle:
file_handle.seek(start_index)
text = file_handle.read(delimiter_position).decode(encoding)
elements = text.strip().split('\n')
key_value_pairs = [(element.split(': ')[0], element.split(': ')[1])
if len(element.split(': ')) > 1
else ('unknown', element)
for element in elements]
return dict(key_value_pairs)
with open('foods.txt', 'rb') as file_handle: ④
size = file_handle.seek(0,2) - 1
more_data = True
output = []
current_position = next_position = 0
while more_data:
if current_position >= size:
more_data = False
else:
current_position, next_position = get_next_part(file_handle, current_position, 0)
output.append((current_position, next_position))
current_position = current_position + next_position + 2
reviews = bag.from_sequence(output).map(lambda x: get_item('foods.txt', x[0], x[1])) ⑤
def tag_positive_negative_by_score(element): ⑥
if float(element['review/score']) > 3:
element['review/sentiment'] = 'positive'
else:
element['review/sentiment'] = 'negative'
return element
tagged_reviews = reviews.map(tag_positive_negative_by_score)
10.1.1 使用二进制向量化准备数据
现在我们已经再次标记了评论,我们需要将评论文本转换为机器学习算法可以理解的形式。我们人类可以直观地理解,如果有人说一个产品是“很好”,那么这个人可能对这个产品有积极的情感。然而,计算机通常并不具备与人类相同的语言理解能力——计算机本身并不理解“很好”的含义或它如何转化为对产品的情感。然而,想想刚才说过的话:如果一个人说一个产品是“很好”,他们可能对这个产品有积极的情感。这是我们可以在数据中寻找的模式。使用“很好”这个词的评论是否比没有使用这个词的评论更有可能为正面?如果是这样,我们就可以说,评论中出现“很好”这个词会使它有更大的可能性是正面的。这正是将文本数据转换为机器可理解格式的一种常见方法,称为 二元向量化。使用二元向量化,我们取一个 语料库,即我们评论数据中出现的所有独特单词的唯一列表,并生成一个由 1 和 0 组成的向量,其中 1 表示单词的存在,而 0 表示单词的缺失。

图 10.3 二元向量化的示例
在 图 10.3 中,您可以看到在原始文本中出现的单词,如“很多”和“有趣”,在二元向量中被分配为 1,而那些在原始文本中没有出现(但在其他文本样本中出现)的单词则标记为 0。一旦文本经过二元向量化转换,我们就可以使用任何标准分类算法,例如逻辑回归,来找出单词出现与情感之间的相关性。这反过来又可以帮助我们构建一个模型,将评论分类为正面或负面,即使我们没有实际的评论评分。让我们看看如何将我们的原始评论转换为二元向量数组。
首先,我们将应用在第九章中应用的一些转换来分词文本并去除停用词(如果您是第一次运行此代码,请确保您已遵循附录中的说明正确设置 NLTK)。
列表 10.2 分词文本和去除停用词
from nltk.corpus import stopwords
from nltk.tokenize import RegexpTokenizer
from functools import partial
tokenizer = RegexpTokenizer(r'\w+')
def extract_reviews(element): ①
element['review/tokens'] = element['review/text'].lower()
return element
def tokenize_reviews(element): ②
element['review/tokens'] = tokenizer.tokenize(element['review/tokens'])
return element
def filter_stopword(word, stopwords): ③
return word not in stopwords
def filter_stopwords(element, stopwords): ④
element['review/tokens'] = list(filter(partial(filter_stopword, stopwords=stopwords), element['review/tokens']))
return element
stopword_set = set(stopwords.words('english'))
more_stopwords = {'br', 'amazon', 'com', 'http', 'www', 'href', 'gp'} ⑤
all_stopwords = stopword_set.union(more_stopwords)
review_extracted_text = tagged_reviews.map(extract_reviews) ⑥
review_tokens = review_extracted_text.map(tokenize_reviews)
review_text_clean = review_tokens.map(partial(filter_stopwords, stopwords=all_stopwords))
如您在第九章中已经看到的 列表 10.2 的代码,我们将继续前进。使用清洗和分词后的评论数据,让我们快速统计一下在评论中出现的独特单词的数量。为此,我们将重新访问 Bag API 中的几个内置函数。
列表 10.3 计算亚马逊美食评论集中的独特单词数量
def extract_tokens(element): ①
return element['review/tokens']
extracted_tokens = review_text_clean.map(extract_tokens)
unique_tokens = extracted_tokens.flatten().distinct() ②
with ProgressBar():
number_of_tokens = unique_tokens.count().compute() ③
number_of_tokens
#Produces the following output:
# 114290
这段代码看起来应该很熟悉。唯一值得注意的是,提取的标记必须被展平以获得所有单词的唯一列表。因为extract_tokens函数返回一个字符串列表的列表,我们需要使用flatten在应用distinct之前连接所有内部列表。根据我们的代码,我们的 568,454 条评论中出现了 114,290 个独特的单词。这意味着我们用二进制向量化产生的数组将是 568,454 行乘以 114,290 列,或者 649 亿个一和零。以每个布尔值一个字节计算,通过 NumPy 的数据大小,这大约是 64GB 的数据。虽然 Dask 当然能够处理这样的大型数组,但为了使这个解决方案更容易快速运行,我们将稍微缩小一下练习的范围。我们不会使用 114,290 个独特单词的整个语料库,而是使用评论数据集中使用频率最高的前 100 个单词的语料库。如果您想使用更大的或更小的语料库,可以轻松修改代码以使用前 1,000 个或前 10 个单词。如果您愿意,也可以修改代码以使用整个语料库。无论您选择的语料库大小如何,所有代码都将正常工作。当然,在实践中,最好从整个语料库开始——通过只选择前 100 个单词,我们可能会错过一些不常见但却是目标变量强预测器的模式。再次强调,我只是在为了快速运行的示例而建议缩小规模。让我们看看如何获取语料库中最常见的 100 个单词。
列表 10.4 在评论数据集中查找最常见的 100 个单词
def count(accumulator, element): ①
return accumulator + 1
def combine(total_1, total_2): ②
return total_1 + total_2
with ProgressBar(): ③
token_counts = extracted_tokens.flatten().foldby(lambda x: x, count, 0, combine, 0).compute()
top_tokens = sorted(token_counts, key=lambda x: x[1], reverse=True) ④
top_100_tokens = list(map(lambda x: x[0], top_tokens[:100])) ⑤
再次,这段代码应该看起来很熟悉,因为我们已经在上一章中查看了一些折叠的例子。和之前一样,我们使用count和combine函数来计算语料库中每个单词的出现次数。折叠的结果给我们一个元组的列表,其中每个元组的第 0 个元素是单词,第 1 个元素是该单词出现次数的计数。使用 Python 内置的sorted方法,我们根据每个元组的第 1 个元素(频率计数)进行排序,以返回一个按降序排序的元组列表。最后,我们使用map函数从排序后的元组中提取单词,以返回使用频率最高的前 100 个单词的列表。现在我们有了最终的语料库,我们可以对评论标记应用二进制向量化。我们将通过搜索每个评论是否包含语料库中的单词来实现这一点。
列表 10.5 通过应用二进制向量化生成训练数据
import numpy as np
def vectorize_tokens(element): ①
vectorized_tokens = np.where(np.isin(top_100_tokens, element['review/tokens']), 1, 0)
element['review/token_vector'] = vectorized_tokens
return element
def prep_model_data(element): ②
return {'target': 1 if element['review/sentiment'] == 'positive' else 0,
'features': element['review/token_vector']}
model_data = review_text_clean.map(vectorize_tokens).map(prep_model_data) ③
model_data.take(5)
'''
Produces the following output:
({'target': 1,
'features': array([1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0])},
...
{'target': 1,
'features': array([0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0])})
'''
代码列表 10.5 中的代码展示了另一个很好的例子,说明了我们如何将其他库如 NumPy 混合到 Dask 中。在这里,我们使用 NumPy 的 where 函数来比较语料库中的单词列表与每个评论的标记列表。这为每个评论生成一个包含 100 个一和零的向量,正如你在示例输出中所看到的。我们还对情感标签应用了二进制向量化,这是我们想要预测的目标——也称为我们的 目标。代码的结果返回一个字典包,其中每个字典对象代表一个评论,并包含其各自的二进制值。我们正在接近构建我们的模型,但有一个重要的事情阻碍了我们:我们的数据仍然在包中,它需要以数组的形式存在于 Dask-ML 中。之前,我们通过首先将其转换为 DataFrame,然后使用 DataFrame 的 values 属性直接访问底层数组来将数据从包转换为数组。我们也可以这样做,但 DataFrame 在处理大量列时通常表现不佳。相反,我们将使用在二进制向量化步骤中产生的现有 NumPy 数组,并将它们连接成一个大的 Dask 数组。换句话说,我们将通过连接将一系列数组 减少 到单个数组。图 10.4 展示了我们想要实现的可视化表示。

图 10.4 将原始数据向量化为数组包,然后连接到单个数组
实际上,我们是一行一行地从零开始构建 Dask 数组。这实际上相当快且高效,因为 Dask 的懒加载评估意味着我们主要处理元数据,直到我们真正尝试将数据在最终数组中具体化。让我们看看如何在代码中实现这一点。
代码列表 10.6 创建特征数组
from dask import array as dask_array
def stacker(partition): ①
return dask_array.concatenate([element for element in partition])
with ProgressBar(): ②
feature_arrays = model_data.pluck('features').map(lambda x: dask_array.from_array(x, 1000).reshape(1,-1)).reduction(perpartition=stacker, aggregate=stacker)
feature_array = feature_arrays.compute()
feature_array
#Produces the following output:
# dask.array<concatenate, shape=(568454, 100), dtype=int64, chunksize=(1, 100)>
列表 10.6 包含了几个我们将要展开的新方法。首先是来自 Dask 数组 API 的 concatenate 函数。它将连接或组合一系列 Dask 数组成为一个单一的 Dask 数组。由于我们最终想要将 568,454 个向量组合成一个大的数组,这正是我们想要的函数。由于数据分布在大约 100 个分区中,我们需要将每个分区的数组列表缩减成一个单一的数组,然后将 100 个分区级别的数组组合成一个最终的大的数组。这可以通过 Dask 数组的 reduction 方法来完成。这个函数与 map 函数的工作方式略有不同,因为它传递给它的函数应该接收整个分区而不是单个元素。在将 from_array 函数映射到每个元素之后,每个分区本质上是一个懒加载的 Dask 数组对象列表。这正是 dask_array.concatenate 输入所想要的。然而,传递给我们的 stacker 函数的分区对象恰好是一个生成器对象,而 dask_array.concatenate 无法处理。因此,我们必须通过列表推导式将其实体化成一个列表。你可能会想,一开始,这可能是适得其反的,因为将分区实体化成列表会带来数据。然而,分区恰好是一个懒加载的 Dask 数组对象列表,所以实际上被传递的数据只有一些元数据和跟踪到目前为止所发生计算的有向无环图(DAG)。我们可以看到,我们得到了想要的结果,因为新的数组形状表明它有 568,454 行和 100 列。特征数组的形状可以在 图 10.5 中看到。

图 10.5 特征数组的形状
由于我们已经对数据做了很多处理,现在保存我们的进度将是一个合适的时间点。在训练模型之前将数据写入也会加快速度,因为数据已经处于构建模型所需的形状。数组 API 包含一个方法,可以使用 ZARR 格式将 Dask 数组写入磁盘,ZARR 是类似于 Parquet 的列存储格式。文件格式的具体细节在这里并不重要——我们只是使用 ZARR,因为数组 API 使读写该格式变得容易。我们将快速将准备好的数据写入磁盘,并读取回来以实现快速访问。
列表 10.7 将数据写入 ZARR 并读取
with ProgressBar():
feature_array.rechunk(5000).to_zarr('sentiment_feature_array.zarr')
feature_array = dask_array.from_zarr('sentiment_feature_array.zarr')
with ProgressBar():
target_arrays = model_data.pluck('target').map(lambda x: dask_array.from_array(x, 1000).reshape(-1,1)).reduction(perpartition=stacker, aggregate=stacker)
target_arrays.compute().rechunk(5000).to_zarr('sentiment_target_array.zarr')
target_array = dask_array.from_zarr('sentiment_target_array.zarr')
列表 10.7 非常直接——因为我们已经通过在 列表 10.6 中所做的连接操作,将特征数组转换成了我们想要的形状,所以我们只需要保存它。我们重复使用连接代码在目标数组数据上,以对目标数据进行相同的处理过程。唯一值得指出的是我们决定重新分块数据。你可能已经注意到在连接之后,数组有一个块大小为 (1,100)。这意味着每个块包含一行和 100 列。ZARR 格式为每个块写入一个文件,这意味着如果我们不重新分块数据,我们将产生 568,454 个单独的文件。这会非常低效,因为从磁盘获取数据涉及到的开销很大——无论我们是在本地模式还是在大集群上运行 Dask,情况都是如此。通常,我们希望每个块的大小在 10 MB 到 1 GB 之间,以最小化 I/O 开销。在这个例子中,我选择了每个块 5,000 行的大小,因此我们最终得到大约 100 个文件,这与原始数据被分割成的 100 个分区相似。我们还遵循了将目标变量转换为数组并将其写入磁盘的相同过程。现在我们终于准备好构建我们的模型了!
10.1.2 使用 Dask-ML 构建逻辑回归模型
我们将首先使用 Dask-ML API 内置的算法:逻辑回归。逻辑回归是一种可以用来预测二元(是或否、好或坏等)结果的算法。这完美符合我们构建一个预测评论情感的模型的愿望,因为情感是离散的:它是积极的或消极的。但我们如何知道我们的模型在预测情感方面有多好?或者,换一种说法,我们如何确保我们的模型实际上在数据中学习了有用的模式?为了做到这一点,我们想要留出一部分评论,算法不允许其查看和学习。这被称为 保留集 或 测试集。如果模型在预测保留集的结果方面做得很好,我们可以合理地相信模型实际上已经学习了有用的模式,这些模式可以很好地推广到我们的问题上。否则,如果模型在保留集上表现不佳,这很可能是由于算法捕捉到了训练数据中独特的强烈模式。这被称为对训练集的 过度拟合,应该避免。Dask-ML,就像 scikit-learn 一样,有一些工具可以帮助我们随机选择一个保留集,我们可以用它来进行验证。让我们看看如何分割数据并构建一个逻辑回归模型。
列表 10.8 构建逻辑回归
from dask_ml.linear_model import LogisticRegression
from dask_ml.model_selection import train_test_split
X = feature_array
y = target_array.flatten()
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) ①
lr = LogisticRegression()
with ProgressBar():
lr.fit(X_train, y_train) ②
在列表 10.8 中,现在我们已经完成了所有艰难的数据准备工作,构建模型本身相对简单。train_test_split函数会随机为我们分割出一个保留集;然后,只需将特征(X)和目标(y)输入到LogisticRegression对象的fit方法即可。值得一提的是,我们将train_test_split函数的random_state参数设置为 42,你可能想知道为什么。这个参数的值并不重要——最重要的是你设置了它。这确保了每次在数据集上调用train_test_split函数时,数据都是相同方式分割的。当你运行并比较许多模型时,这一点很重要。由于数据固有的可变性,你可能会随机测试一个非常容易或非常难以预测的保留集。在这种情况下,你所见证的模型改进(或恶化)并不是因为你做了什么来影响模型。因此,我们想确保每次构建模型时数据都是“随机”以相同方式分割的。经过几分钟的训练,模型将准备好进行预测。然后,是时候评分模型,看看它在预测之前未见过的评论方面做得如何。
10.2 评估和调整 Dask-ML 模型
与我们为准备数据所付出的艰辛努力相比,构建模型可能看起来很简单,但我们远未完成。目标始终是产生尽可能精确的模型,为此你必须考虑许多因素。首先,算法的数量是巨大的。仅就分类而言,就有逻辑回归、支持向量机、决策树、随机森林、贝叶斯模型等等。而且,这些模型中的每一个都有几个不同的超参数,它们定义了算法对异常值和高度影响点的敏感度。在许多模型和参数的组合中,我们如何确保我们拥有最好的模型呢?答案是通过对任意模型的准确性进行系统性的实验。如果我们有一种方法来评分任意模型的准确性,那么找到最佳模型就可以客观地进行,并且我们可以使用自动化来简化任务。得分最高的模型是冠军模型,直到一个新的挑战者模型出现并击败它。然后,挑战者成为新的冠军,循环重复。这种冠军-挑战者模型在实践中效果很好,但我们必须从某个地方开始。根据定义,第一个冠军模型的优劣并不重要——它仅仅作为一个基准,用于与潜在的挑战者进行比较。因此,从简单的模型开始,例如逻辑回归,并使用所有默认值是完全可行的。
10.2.1 使用评分方法评估 Dask-ML 模型
一旦我们建立了一个基线,我们就可以将其与使用不同算法或不同超参数集的更复杂的模型进行对比。幸运的是,每个 scikit-learn 算法,以及通过扩展每个 Dask-ML 算法,都附带了一个 score 方法。score 方法根据算法类型计算一个广泛接受的评分指标。例如,当调用 score 方法时,分类算法计算分类准确度评分。这个评分表示正确分类预测的百分比,范围在 0 到 1 之间,分数越高表示越准确。一些数据科学家更喜欢使用其他评分,如 F1 准确度评分,但每种评分方法的优缺点对于这个练习来说并不重要。你应该始终选择与你的解决方案需求最匹配的评分指标,并且了解不同的评分方法是非常好的主意。既然我们已经训练了一个基线逻辑回归模型,让我们看看它的表现如何。
列表 10.9 评分逻辑回归模型
lr.score(X_test, y_test).compute()
#Produces the following output:
# 0.79629173556626676
如您所见,评分模型的代码非常简单。在我们将 X 和 y 的训练版本传递给 fit 方法时,我们将 X 和 y 的测试版本传递给 score 方法。一行代码就可以生成使用 X_test 中包含的特征的预测,并将预测与 y_test 中包含的实际值进行比较。我们的基线模型正确分类了测试集中 79.6% 的评论。这是一个不错的开始!现在,既然我们已经有了基线,我们可以尝试使用挑战者模型来超越它。在您的工作过程中,请记住,完美的分类评分不太可能实现。我们在这里的目标不是找到一个 100% 完美的模型,而是要做出有意识、可衡量的进步,并使用客观标准在时间、数据质量等约束条件下找到最好的模型。
10.2.2 使用 Dask-ML 构建朴素贝叶斯分类器
让我们看看我们的逻辑回归模型与朴素贝叶斯分类器的表现如何。朴素贝叶斯是文本分类中常用的算法,因为它是一个简单的算法,即使在小型数据集上也有相当好的预测能力。这里只有一个问题:在 Dask-ML API 中没有朴素贝叶斯分类器类。然而,我们仍然可以使用 Dask 来训练一个朴素贝叶斯分类器!不,我们不是从头开始构建算法。相反,我们可以使用 Dask-ML 的一个接口,即 scikit-learn 的增量包装器。增量包装器允许我们只要算法实现了 partial_fit 接口,就可以使用 Dask 来使用任何 scikit-learn 算法。
越来越多的 scikit-learn 算法支持这个接口,因为对大型数据集的批量学习越来越感兴趣。朴素贝叶斯算法属于支持批量学习的算法组,因此它们可以很容易地与 Dask 一起使用以并行化训练。让我们看看这会是什么样子。
列表 10.10 使用 Incremental 包装器训练朴素贝叶斯分类器
from sklearn.naive_bayes import BernoulliNB ①
from dask_ml.wrappers import Incremental
nb = BernoulliNB()
parallel_nb = Incremental(nb) ②
with ProgressBar():
parallel_nb.fit(X_train, y_train, classes=[0,1]) ③
列表 10.10 中的代码在很大程度上与 列表 10.8 中的代码相同,除了这次我们导入了一个来自 scikit-learn 的算法而不是 Dask-ML。要使用 Dask 的算法,我们只需像平常一样创建估算器对象,然后将其包装在 Incremental 包装器中。Incremental 包装器本质上是一个辅助函数,它告诉 Dask 估算器对象,以便它可以将其传递给工作节点进行训练。模型的拟合过程与平常一样,只有一个关键例外:我们必须提前指定有效的目标类别。由于我们的数据集中只有两种可能的输出,即正例和负例,分别编码为 1 和 0,我们只需将它们作为一个列表传递。代码应该只需几秒钟就能运行;然后我们可以评分模型,看看它是否优于逻辑回归。
列表 10.11 评分 Incremental 包装的模型
parallel_nb.score(X_test, y_test)
#Produces the following output: 0.78886817014389754
奇怪的是,与 Dask-ML 算法的 score 方法不同,Incremental 的 score 方法不是惰性的,所以我们不需要调用 compute。看起来朴素贝叶斯模型的表现与逻辑回归模型相似,但其评分大约比逻辑回归差 1%——这意味着逻辑回归目前仍然是我们的冠军。我们应该继续尝试其他算法作为潜在的挑战者,但我们将暂时留出时间来讨论我们应该实验的其他元素:超参数调整。
10.2.3 自动调整超参数
如前所述,大多数算法都有一些超参数,这些参数控制着算法的行为。虽然默认值通常由算法的作者选择以提供最佳的一般性能,但通常可以通过调整超参数以更好地适应训练数据来获得一些额外的准确度提升。手动调整超参数可能是一个高度重复和单调的过程,但多亏了 scikit-learn 和 Dask-ML API,我们可以自动化很多这样的工作。例如,我们可能想评估改变逻辑回归中几个超参数对结果的影响。使用名为 GridSearchCV 的“元估算器”,我们可以指示 Dask-ML 尝试许多不同的超参数组合,并以冠军挑战者的方式自动将模型相互对抗。使用起来非常简单,您将在下一代码示例中看到。
列表 10.12 使用 GridSearchCV 调整超参数
from dask_ml.model_selection import GridSearchCV
parameters = {'penalty': ['l1', 'l2'], 'C': [0.5, 1, 2]} ①
lr = LogisticRegression()
tuned_lr = GridSearchCV(lr, parameters) ②
with ProgressBar():
tuned_lr.fit(X_train, y_train)
GridSearchCV 对象的行为类似于增量包装器。就像之前一样,我们可以取任何算法,例如 Dask-ML 的逻辑回归,并将其包装在 GridSearchCV 中。我们还需要另一个元素,即包含我们想要网格搜索尝试的参数及其可能值的字典。正如列表 10.12 所示,我们正在让网格搜索改变两个参数:penalty 参数和 C 系数。与每个参数名称关联的值是要尝试的值的列表。
您可以通过在字典中包含任何参数来将其包括在网格搜索中。scikit-learn 的 API 文档列出了每个算法的所有参数及其示例值,因此您可以使用它作为选择要调整的参数的参考。需要注意的是,GridSearchCV 是一种“暴力”类型的算法——这意味着它将尝试您传递给它的所有参数组合。在列表 10.12 中,我们提供了两个惩罚参数的选择和三个 C 系数的选项。这意味着总共将构建六个模型,每个模型代表不同的参数组合。请小心不要选择太大的搜索空间,否则网格搜索完成所需的时间可能会变得过长。然而,GridSearchCV 与 Dask 的扩展性相当好。每个模型都可以在单独的工人上构建,这意味着通过部署到集群和/或增加工人的数量来减少网格搜索时间并不困难。一旦网格搜索完成,您可以看到每个模型的报告,包括其测试分数和训练所需的时间。要查看结果,我们将运行以下代码。
列表 10.13 查看 GridSearchCV 的结果
import pandas as pd
pd.DataFrame(tuned_lr.cv_results_)
完成的 GridSearchCV 对象有一个名为 cv_results_ 的属性,它是一个测试指标的字典。当以 Pandas DataFrame 的形式显示时,它更容易阅读,因此我们将它放入 DataFrame 并打印结果。它应该看起来像图 10.6。

图 10.6 GridSearchCV 的结果
在图 10.6 中,我们可以看到GridSearchCV过程中发生的一系列指标。其中最感兴趣的四个列是测试分数列。这显示了每个模型在不同数据分割上的表现情况。有趣的是,具有1的 C 系数和L2惩罚的模型表现最佳(与几个其他 C 系数组合并列)。这些恰好是逻辑回归的默认值,因此在这种情况下,超参数调整并没有找到比基线模型表现更好的模型修改。如果你对每个算法的默认值感兴趣,可以在 scikit-learn 文档中找到。默认值通常在大多数情况下都能很好地拟合,但检查超参数调整是否能挤出任何改进也从未有过害处。为了使我们的搜索全面,我们希望对每个尝试的不同算法(如朴素贝叶斯)也进行超参数调整。通过本节中介绍的技术和代码片段,你应该能够构建一个自动化的管道,尝试许多算法和超参数的组合!另外,需要注意的是,我们可以使用冠军挑战者模型来评估新数据和特征的价值。例如,如果我们把我们的语料库从 100 个单词增加到 150 个单词,模型会提高多少准确性?通过目标实验,我们可以回答所有这些问题,并最终得到我们能够生产的最佳模型。然而,目前,我们的原始逻辑回归在预测评论情感方面做得最好。我们应该期待能够向我们的模型提供之前未见过的新的评论,并且平均来说,能够以大约 80%的准确性正确预测它们是正面还是负面体验。
10.3 持久化 Dask-ML 模型
本章我们将简要讨论的最后一件事是持久化训练好的 Dask-ML 模型,以便它可以被发布或部署到其他地方。对于许多数据科学项目来说,产生的模型,例如我们的分类模型,旨在在应用程序的某个地方使用,以进行预测或推荐。尽管产生模型可能需要巨大的计算能力,但产生可以在用户界面应用程序中展示的预测通常需要较少的计算能力。
许多数据科学工作流程包括启动一个大型且强大的集群来处理数据并生成模型,将模型发布到如 Amazon S3 这样的存储库,关闭集群以节省成本,并通过成本较低、性能较弱的机器(如 Web 服务器)公开模型。这很有道理,因为预测模型的大小通常只有几千字节到几兆字节,而用于训练模型的训练数据可能达到千兆字节或更多。我们可以使用二进制序列化库来帮助我们保存从所有数据中学到的知识——即生成的预测模型——并将其持久化到磁盘,以便下次使用时无需从头开始重建。
Python 内置了一个名为 pickle 的二进制序列化库,它允许我们将内存中的任何 Python 对象保存到磁盘。稍后通过从磁盘读取并重新加载到内存中反序列化该对象,可以忠实地重建对象,使其状态与保存到磁盘时的状态一致。这也意味着一个 Python 对象可以在一台机器上创建,序列化,通过网络传输,然后由另一台机器反序列化和使用。实际上,这正是 Dask 在集群中向不同的工作节点发送数据和任务的方式!
幸运的是,这个过程也非常简单。唯一的要求是加载序列化对象的机器必须拥有该对象使用的所有 Python 库。例如,如果我们序列化了一个 Dask DataFrame,我们就不能在未安装 Dask 的机器上加载它;尝试加载文件时会遇到 ImportError。除此之外,它非常简单。对于这个例子,我们将使用一个名为 dill 的库,它是 pickle 库的包装器。Dill 对复杂数据结构(如 JSON 和嵌套字典)的支持更好,而 Python 内置的 pickle 库有时会有问题。将我们的模型写入磁盘非常简单。例如,下面是如何将朴素贝叶斯分类器写入磁盘的方法。
列表 10.14 将朴素贝叶斯分类器写入磁盘
import dill
with open('naive_bayes_model.pkl', 'wb') as file:
dill.dump(parallel_nb, file)
这就是全部内容。dump 函数将你传递给它的对象序列化,并将其写入你指定的文件句柄。在这里,我们打开了一个名为 naïve_bayes_model.pkl 的新文件的句柄,因此数据将被写入该文件。由于 pickle 文件是二进制文件,我们需要始终在文件句柄中使用 b 标志来表示文件应以二进制模式打开。读取模型文件也非常简单。
列表 10.15 从磁盘读取朴素贝叶斯分类器
with open('naive_bayes_model.pkl', 'rb') as file:
nb = dill.load(file)
nb.predict(np.random.randint(0, 2,(100, 100)))
#Produces the following output:
# array([0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1])
如 列表 10.15 所示,我们只需使用 load 函数读取文件。我们不需要使用训练模型时使用的任何数据——模型对象是完全自包含的。为了展示其生成预测的能力,我们通过生成一些随机的二进制向量输入了一些虚拟数据。不出所料,我们得到了一个预测数组。
我希望您现在已经对使用 Dask-ML 的便捷性有了认识,一旦您投入了艰苦的工作来准备数据!在下一章中,我们将通过探索如何在集群模式下使用 Dask 以及如何使用 AWS 部署 Dask 来结束我们的旅程。
摘要
-
二进制向量化用于将文本块中单词的存在与某些预测器(例如,情感)相关联。
-
机器学习使用统计和数学方法来寻找与特征(输入)相关的预测器(输出)的模式。
-
应该将数据分成训练集和测试集,以避免过拟合。
-
当试图决定使用哪种模型时,选择一些错误度量,并使用冠军挑战者方法根据您选择的度量客观地找到最佳模型。
-
您可以使用
GridSearchCV自动化选择和调整您的机器学习模型的流程。 -
训练好的机器学习模型可以使用 dill 库保存,以便稍后用于生成预测。
11
缩放和部署 Dask
本章涵盖
-
使用 Docker 和弹性容器服务在 Amazon AWS 上创建 Dask 分布式集群
-
使用 Jupyter Notebook 服务器和弹性文件系统在 Amazon AWS 中存储和访问数据科学笔记本和共享数据集
-
使用分布式客户端对象将作业提交到 Dask 集群
-
使用分布式监控仪表板监控集群上作业的执行
到目前为止,我们一直在本地模式下使用 Dask。这意味着我们要求 Dask 执行的所有操作都是在单个计算机上完成的。在本地模式下运行 Dask 对于原型设计、开发和临时探索非常有用,但我们仍然可以迅速达到单台计算机的性能限制。正如第一章中我们假设的厨师需要召集援兵以便在晚餐服务前准备好厨房一样,我们也可以配置 Dask 将工作分散到多台计算机上以更快地处理大型作业。这在时间受限的生产系统中尤为重要。因此,在生产中通常会将 Dask 扩展到集群模式运行。

图 11.1 本章将涵盖工作流程的最后几个元素:部署和监控。
图 11.1 显示我们已经到达了工作流程的最后一部分:部署和监控。虽然在设计解决方案时提前规划总是好的,但最终版本与最初设想非常接近的情况相当罕见。这就是为什么部署和监控在我们的工作流程中排在最后。一旦你对解决一个问题的必要数据有了很好的了解,数据的量,以及你或你的应用程序必须多快提供问题的答案,你就可以开始规划你需要哪些资源来托管你的最终解决方案。这些考虑通常在解决方案的原型设计阶段确定。幸运的是,Dask 旨在使从笔记本电脑上的原型到集群上的完整应用程序的过渡尽可能无缝。事实上,无论调度器是在本地模式还是集群模式下运行,对其他所有内容都是透明的。这意味着你写的任何 Dask 代码——以及由此关联的过去 10 章中我们涵盖的所有代码——都可以在笔记本电脑和任何大小的集群上运行,无需修改。
话虽如此,亲眼看到如何设置、维护和监控一个 Dask 集群仍然很有价值。在本章中,我们将指导您如何使用 Amazon AWS 设置一个集群,您可以用它作为私有沙盒。选择 AWS 进行这项练习是因为它是一个非常受欢迎的云计算平台,拥有支持性的社区、大量的学习资源和慷慨的账户层级,允许您免费实验 AWS。Dask 同样适合在 Microsoft Azure 或 Google Cloud Platform 等其他云计算平台上运行,当然,也可以在私有服务器农场上运行。虽然这项练习也将提供一些关于 AWS 和 Docker 的好手头经验,但重点将主要放在 Dask 上。我们只会涵盖您需要知道的基本 AWS 和 Docker 知识,以便将集群启动并运行。我们还将查看一些通用的故障排除步骤,如果您在 AWS 和 Docker 遇到问题,可以采取这些步骤。然而,它们各自都是庞大的主题,在这里深入探讨它们并不实用。
11.1 在 Amazon AWS 上使用 Docker 构建 Dask 集群
在我们开始之前,我们将介绍一些基本术语,并查看我们将在 AWS 中创建的架构。

图 11.2 我们 Dask 分布式集群的架构图
如您在图 11.2 中看到的,该系统有四个不同的元素:客户端、笔记本服务器、调度器和工作者。这四个元素各自扮演不同的角色:
-
调度器——通过笔记本服务器从客户端接收作业,将待做的工作分割,并协调工作者完成作业
-
工作者——从调度器接收任务并计算它们
-
Jupyter Notebook 服务器——提供一个前端,允许用户运行代码并将作业提交到集群
-
客户端——向用户显示结果
11.1.1 开始
在本地模式下,所有元素都在同一台计算机上运行,默认情况下工作进程的数量与 CPU 核心数相对应。在集群模式下,我们将配置每个元素在单独的计算机上运行。我们还将获得动态增加或减少工作进程数量的自由,这使我们能够根据需要扩展集群。但是,由于所有这些元素都将驻留在不同的计算机上,我们必须考虑一些新事物:数据必须位于一个所有工作进程都可以访问的共享位置,并且所有工作进程都必须安装正确的 Python 包。第一个考虑因素很简单——我们将设置一个共享文件系统,每个工作进程都可以访问,然后将数据放在那里。第二个考虑因素在历史上处理起来有点棘手。例如,如果我们想在集群上运行第九章中的代码,该代码使用 NLTK(自然语言工具包)过滤掉所有停用词,我们必须确保集群中的每个工作进程都已安装 NLTK,并且已下载停用词数据。如果我们集群中的工作进程数量不多,手动操作可能不成问题,但如果我们需要将集群扩展到 10,000 个工作进程,逐个配置工作进程将花费非常长的时间。这正是 Docker 变得极其有用的地方。Docker 本质上允许我们创建一个蓝图,称为镜像,它包含数据和构建一个系统相同副本的指令。这个镜像可以在容器内部启动,成为一个完全功能化的自包含系统,就像虚拟机一样。这个镜像可以部署到 Amazon Elastic Container Service (ECS)以启动数百或数千个工作进程,只需按一下按钮,所有工作进程都具有相同的配置和软件。在本章的后面部分,我们将构建一个包含运行 Dask 工作进程所需的所有软件以及所有必要的 Python 包的 Docker 镜像。我们还将为调度器和笔记本服务器做同样的事情。本节的整体目标包含在这个场景中:
设置一个包含八个弹性容器服务实例的 Amazon AWS 环境,并使用预构建的 Dask Docker 镜像部署 Dask 集群。
要跟随示例进行操作,您需要在您的机器上下载并安装 Docker 的最新版本,请访问www.docker.com/get-started。这将允许您构建我们将部署到 Amazon ECS 的镜像。您还需要根据aws.amazon.com/free上的说明创建一个 AWS 账户。请注意,这项练习旨在保持在 AWS 免费层级的限制内;然而,亚马逊要求您在激活账户之前提供支付信息,并且您必须遵循练习结束时的清理说明,以避免账户费用。如果您超出 AWS 免费层级的限制,我们使用的资源非常便宜,因此您将承担的费用非常低。您还需要一个 SSH 客户端。如果您使用的是 macOS 或基于 Unix/Linux 的操作系统,SSH 客户端应该已经预安装在您的系统上。但是,如果您使用的是 Windows,您将需要下载一个 SSH 客户端,例如 PuTTY(docs.aws.amazon.com/AWSEC2/latest/UserGuide/putty.html)。最后,按照aws.amazon.com/cli上的说明安装 AWS 命令行界面(CLI)工具。一旦您完成设置,我们将遵循七个步骤来设置集群:
-
创建一个安全密钥。
-
创建 ECS 集群。
-
配置集群的网络。
-
在弹性文件系统(EFS)中创建一个共享数据驱动器。
-
在弹性容器仓库(ECR)中为 Docker 镜像分配空间。
-
构建和部署调度器、工作节点和笔记本服务器的镜像。
-
连接到集群。
11.1.2 创建安全密钥
首先,登录到 AWS 控制台。您应该会看到一个类似于图 11.3 的屏幕。

图 11.3 AWS 控制台主页
我们需要做的第一件事是创建一个安全密钥,稍后我们将使用它来在部署 Docker 镜像时验证 AWS。为此,将鼠标悬停在右上角靠近铃铛图标处的账户名称上,如图图 11.4 所示。
点击“我的安全凭证”,您将被带到“您的安全凭证”页面。如果您收到一个警告消息弹出窗口,如图图 11.5 所示,请选择继续到安全凭证。

图 11.4 账户控制菜单

图 11.5 安全警告消息
点击访问密钥下拉区域。如果您有任何现有的密钥,可以选择删除来移除它们。然后,点击创建新访问密钥。您应该会看到一个类似于图 11.6 的对话框,其中包含您的新访问密钥和秘密访问密钥(图 11.6 中的密钥已被屏蔽以保障安全——您将在屏幕上的对话框中看到您实际生成的密钥)。点击下载密钥文件以下载包含这两个值的 CSV 文件。如果您愿意,可以截图,因为我们稍后会使用这些密钥。跟踪您的秘密访问密钥并保持其安全非常重要。如果您丢失了它(您将不得不创建一个新的密钥),并且如果它落入错误的手中,它可能会被用来危害您的 AWS 账户。把它当作密码或信用卡号码一样对待。

图 11.6 创建访问密钥对话框
现在您已经创建了一个安全密钥,下一步是创建 ECS 集群。
11.1.3 创建 ECS 集群
在云中工作的时候,通常谈论的是“计算资源”而不是物理计算机。这是因为当在云中请求服务器时,它们很少是仅用于您个人使用的专用物理机器。相反,它们是运行在由许多其他云客户共享的庞大服务器集群上的虚拟机,称为实例。然而,从所有目的和意义上讲,它们看起来像是独立的物理机器。每个实例都有自己的独立 IP 地址、文件系统空间等等。在 AWS 中,通过弹性计算云(EC2)请求计算资源——这项服务允许您创建和拆除虚拟服务器,可以用来托管任何您想要的东西。ECS 允许您在 EC2 实例上运行容器中的 Docker 镜像。对我们来说,这很有益处,因为我们不需要登录到每个 EC2 实例并手动配置它。我们可以简单地使用 EC2 实例来运行我们将在本章稍后构建的预配置 Docker 镜像的副本。
由于许多用户已经接受了使用 Docker 进行云部署的便捷性,亚马逊简化了请求 EC2 实例并为其配置 Docker 的过程,将其整合到一个设置向导中。我们稍后将逐步通过这个向导。首先,我们需要创建一个 SSH 密钥,我们将将其关联到 EC2 实例上。这将允许您使用 SSH 登录到 EC2 实例,我们在后续过程中需要这样做。首先,在 AWS 控制台中滚动到服务菜单,然后在计算部分选择 EC2。图 11.7 显示了菜单中可以找到 EC2 链接的区域。

图 11.7 导航到 EC2 仪表板。
一旦到达 EC2 仪表板,点击类似于图 11.8 的区域,上面写着“0 密钥对”。

图 11.8 导航到密钥对视图
接下来,点击如图图 11.9 所示的创建密钥对按钮。

图 11.9 选择创建密钥对。
当提示为密钥对命名时,键入dask-cluster-key并点击创建。这将创建密钥对并将其副本下载到您的下载文件夹中。它应该命名为 dask-cluster-key.pem.txt。将其重命名为 dask-cluster-key.pem 并将其放在安全的地方。这也是一个私钥文件;您应该保护它,因为它可以用来访问您的 EC2 实例。
现在我们已经创建了密钥对,我们可以创建 ECS 集群。返回 AWS 控制台顶部的左侧服务菜单,并在计算菜单下选择 ECS。一旦看到 ECS 的欢迎屏幕,点击屏幕左侧边缘 Amazon ECS 菜单下的集群菜单选项。现在您应该看到一个类似于图 11.10 的屏幕。

图 11.10 Amazon ECS 集群管理屏幕
在屏幕左上角点击蓝色创建集群按钮。这将启动 ECS 创建集群向导。当提示选择集群模板时,选择 EC2 Linux + Networking,如图图 11.11 所示。

图 11.11 ECS 创建集群向导步骤 1
点击下一步以进入配置集群屏幕。在集群名称框中为您的集群输入一个名称,例如dask-cluster。名称不能包含任何空格、大写字母或除破折号以外的特殊字符。在 EC2 实例类型下拉框中选择 t2.micro 实例类型。这是符合 AWS 免费层的实例类型。最后,在实例数量框中输入8,并在密钥对下拉框中选择您之前创建的密钥对。其余选项可以保留为默认值。在继续之前,请验证您的配置看起来类似于图 11.12。
一旦验证了配置,点击屏幕底部的完成按钮以创建集群。如果一切输入成功,您将看到启动状态屏幕。此屏幕显示集群请求和构建的进度。一旦蓝色查看集群按钮亮起,集群构建完成。点击屏幕左侧菜单中的集群。这将带您回到集群屏幕,您应该会看到您新构建的集群。您的屏幕应该类似于图 11.13。

图 11.12 集群配置设置

图 11.13 显示新构建集群的集群状态窗口
在此屏幕上需要注意的最重要的事情是屏幕右侧远端显示的容器实例数量。这显示了当前运行并加入集群的 EC2 实例数量。由于我们预定了八个 EC2 实例,您应该看到八个容器实例可用。如果您看不到八个容器实例,请等待几分钟并刷新页面。偶尔需要几分钟的时间才能让 EC2 实例完全启动并连接到集群。
11.1.4 配置集群的网络
现在集群已经启动并运行,我们需要配置集群的防火墙规则以允许我们连接到它。为此,我们需要回到 EC2 仪表板。点击“服务”菜单,在“计算”部分下选择 EC2。一旦进入 EC2 仪表板,在“网络与安全”标题下左侧菜单中选择“安全组”,如图 11.14 所示。

图 11.14 显示安全组配置的 EC2 仪表板菜单
在“安全组”页面,找到与您刚刚创建的集群相对应的安全组。组名应该是类似于 EC2ContainerService-<集群名称>-EcsSecurityGroup-xxxxxxxxx 的样子。点击安全组左侧的复选框以选择它。图 11.15 展示了安全组的示例。

图 11.15 ECS 集群的示例安全组
在屏幕的下半部分,选择“入站”选项卡并点击“编辑”按钮。图 11.16 展示了此操作的示例。

图 11.16 入站防火墙规则
首先,创建一条规则以允许从您的 IP 地址进行入站 SSH 连接。这将允许您登录到集群中的 EC2 实例。在“类型”列下选择 SSH,在“来源”列下选择“我的 IP”。您还可以为防火墙规则输入可选的描述。图 11.17 展示了此配置的示例(注意:您的 IP 地址将与图中列出的 IP 地址不同)。

图 11.17 示例 SSH 防火墙规则
需要配置的下一条规则是允许所有 EC2 实例之间进行通信。例如,Dask 调度器需要能够与工作节点通信以分发指令。再次点击“添加规则”,在“类型”列中选择“所有 TCP”,在“来源”列中选择“自定义”。在您选择“自定义”的下拉框旁边,开始键入 EC2(使用大写字母)。将出现一个下拉列表,其中列出了安全组,如图 11.18 所示。

图 11.18 从安全组创建入站规则
从下拉列表中选择 ECS 集群安全组。最后,我们还需要为 Jupyter Notebook 服务器以及 Dask 诊断页面打开端口。图 11.19 显示了创建两个附加规则的相关配置。

图 11.19 Jupyter 和 Dask 诊断的防火墙规则
要创建 Jupyter Notebook 服务器的入站规则,请点击“添加规则”,从“类型”列中选择“自定义 TCP 规则”,在“端口范围”列中输入 8888,并从“源”列中选择“我的 IP”。然后,为 Dask 诊断端口创建一个相同的规则。在“端口范围”列中,输入 8787 – 8789 而不是 8888。一旦创建了所有规则,请点击“保存”按钮。
现在规则已经保存,最好测试一下以确保一切按预期工作。为此,我们首先需要查找您运行中的 EC2 实例之一的 IP 地址或主机名。从 EC2 仪表板中,点击左侧菜单下“实例”标题下的“实例”。这将带您到 EC2 实例管理器。在屏幕上,您应该会看到一个当前运行的所有 EC2 实例的列表。这应该类似于 图 11.20。

图 11.20 所有运行中的 EC2 实例列表
在“公共 DNS (IPv4)”列中,复制一个主机名。您选择哪个都无关紧要。
在 MacOS/Linux/Unix 上使用 SSH 进行连接
根据您使用的操作系统,使用以下说明连接到 EC2 实例:
-
打开一个终端窗口,导航到您存储 dask-cluster-key.pem 文件的文件夹。
-
如果这是您第一次连接,请通过输入
chmod 400 dask-cluster-key.pem将 PEM 文件设置为只读;否则,您的 SSH 客户端可能不允许您使用密钥文件进行连接。 -
要连接,请输入
ssh -i dask-cluster-key.pem ec2-user@<hostname>;将您从 EC2 实例管理器中复制的 hostname 填入空间。 -
如果提示您添加密钥指纹,请输入
yes。 -
如果连接成功,您应该看到一个类似于 图 11.21 的登录屏幕。
-
如果连接失败,请再次检查 SSH 命令是否输入正确。如果连接问题持续存在,请检查防火墙规则以确保正确的端口已打开。

图 11.21 成功连接到 EC2 实例
在 Windows 上使用 SSH 进行连接
与 MacOS/Linux/Unix 系统不同,Windows 没有内置的 SSH 客户端。Amazon 建议使用名为 PuTTY 的免费 SSH 客户端来连接到 EC2。您可以在 docs.aws.amazon.com/AWSEC2/latest/UserGuide/putty.html 找到有关下载和安装 PuTTY 以及使用它连接到您的 EC2 实例的说明。
成功连接到您的 EC2 实例后,您可以暂时断开连接。您将在下一节的末尾重新连接,以便将数据上传到我们即将创建的共享文件系统。关于这一点,请不要费心将您的 EC2 实例的主机名或 IP 地址记录下来。EC2 实例是短暂的,这意味着它们的 IP 地址和文件系统内容仅存在于实例的生命周期内。当 EC2 实例被终止时,它们会释放它们的 IP 地址分配,并且实例在重新启动时不太可能收到相同的 IP 地址。通常,每次您需要连接到 EC2 实例时,您都应该使用 EC2 实例管理器来查找其当前的 IP 地址。同样,不要在 EC2 实例上存储您希望长期访问的数据。相反,将您希望持久化的数据放在持久文件系统上,例如我们将在下一节中创建的弹性文件系统共享。
11.1.5 在弹性文件系统中创建共享数据驱动器
在离开 EC2 实例管理器之前,您需要从 EC2 实例中获取 VPC ID。VPC ID 由 EC2 用于识别您账户中的云资源。要获取此值,请选择 EC2 实例管理器中的一个实例,并查看窗口的下半部分以找到 VPC ID 值。您应该在这个值在私有 IP 值下方找到,如图 图 11.22 所示。

图 11.22 EC2 实例管理器中的 VPC ID
复制此值,然后点击屏幕左上角的“服务”菜单。在“存储”标题下选择 EFS。您将被带到 Amazon EFS 的欢迎屏幕。点击蓝色的“创建文件系统”按钮以启动 EFS 创建向导。在第一步中,从 VPC 下拉框中选择与您从 EC2 实例中复制的 VPC ID 匹配的 VPC ID。在“创建挂载目标”部分,保留子网列的默认值。但是,清除安全组框。然后,开始键入 EC2(使用大写字母)并选择 ECS 集群的安全组 ID(它应该类似于 EC2ContainerService-

图 11.23 EFS 文件系统访问配置
点击“下一步”按钮。接受步骤 2 的默认值,然后再次点击“下一步”按钮。最后,在审查屏幕上,点击“创建文件系统”以完成。几分钟后,您应该会看到文件系统已成功创建。在离开页面之前,复制我们刚刚创建的文件系统的 DNS 名称。此值显示在“文件系统访问”标题下,如图 图 11.24 所示。

图 11.24 EFS 的 DNS 名称
现在文件系统已创建,我们需要告诉 EC2 实例在启动时挂载该文件系统,以便您可以使用它进行存储。为此,导航回 EC2 仪表板。在左侧菜单中,点击“自动扩展”下的“启动配置”。启动配置管理器应显示并看起来类似于 图 11.25。

图 11.25 启动配置管理器
选择启动配置(应该只有一个),点击“操作”按钮,然后选择“复制启动配置”。将出现复制启动配置向导。在屏幕顶部边缘点击 3. 配置详细信息,并展开高级详细信息部分。您的屏幕应该看起来类似于 图 11.26。
在“名称”字段中为您的新的启动配置提供一个独特的名称。同时,确保在“ IAM 角色”下拉框中选中了 ecsInstanceRole。否则,EC2 实例在重启后无法与 ECS 通信。在“用户数据”字段中,将 列表 11.1 的内容复制到文本框中。

图 11.26 启动配置详细信息
列表 11.1 启动配置的用户数据
Content-Type: multipart/mixed; boundary="==BOUNDARY=="
MIME-Version: 1.0
--==BOUNDARY==
Content-Type: text/cloud-boothook; charset="us-ascii"
# Install nfs-utils
cloud-init-per once yum_update yum update -y
cloud-init-per once install_nfs_utils yum install -y nfs-utils
# Create /efs folder
cloud-init-per once mkdir_efs mkdir /efs
# Mount /efs
cloud-init-per once mount_efs echo -e '**<your filesystem DNS name>**:/ /efs nfs4 nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2 0 0' >> /etc/fstab
mount -a
--==BOUNDARY==
Content-Type: text/x-shellscript; charset="us-ascii"
#!/bin/bash
echo ECS_CLUSTER=**<your ecs cluster name>** >> /etc/ecs/ecs.config
echo ECS_BACKEND_HOST= >> /etc/ecs/ecs.config
--==BOUNDARY==--
在 EFS 确认屏幕上出现

图 11.27 密钥对确认
启动配置创建完成后,点击“关闭”按钮。然后,在左侧菜单中,点击“自动扩展”下的“自动扩展组”。现在我们需要配置 EC2 实例使用新的启动配置。选择在创建 ECS 集群时自动创建的自动扩展组(应该只有一个),然后点击“编辑”按钮。您的自动扩展组管理器屏幕应该看起来类似于 图 11.28。

图 11.28 自动扩展组管理器
在编辑详细信息对话框中,将启动配置下拉菜单更改为您刚刚创建的启动配置。您的屏幕应该类似于图 11.29。此配置非常重要——它控制有多少个 EC2 实例是 ECS 集群的一部分。我们应该有与 Dask 集群中我们想要的工人数量相等的实例数量,再加上一个用于托管调度器的一个实例,以及一个用于托管笔记本服务器的另一个实例。在集群中有八个实例的情况下,我们将能够拥有六个工人、一个调度器和一台笔记本服务器。如果您想要 100 个工人,我们需要 102 个实例。完成自动扩展组的配置后,点击保存按钮。

图 11.29 自动扩展组配置
由于启动配置仅在 EC2 实例启动时运行,我们需要终止并重新启动当前运行的 EC2 实例,以便我们的配置更改生效。为此,返回到 EC2 实例管理器。然后,选择所有当前处于运行状态的实例,点击操作按钮,选择实例状态,然后选择终止。您的屏幕应该类似于图 11.30。

图 11.30 将 EC2 实例轮换以应用新的启动配置
当提示您是否确定要终止实例时,点击是,终止。几秒钟后,您应该看到实例从绿色运行状态变为琥珀色关闭状态,最终变为红色已终止状态。大约 5-10 分钟后,您应该看到八个新的 EC2 实例启动并变为绿色运行状态。实例重启后,返回到 ECS 仪表板,并确保您的 ECS 集群现在显示八个连接的 ECS 容器实例。如果您看到零个连接的 ECS 容器实例并且已经等待至少 15 分钟,请仔细检查您的启动配置是否存在配置错误。特别注意 IAM 角色必须设置为 ecsContainerInstance,以避免权限问题,防止实例与集群关联!
一旦 EC2 实例成功重启,我们需要通过上传一些数据来测试 EC2 实例与 EFS 之间的连接。为此,返回到 EC2 实例管理器,并复制正在运行的 EC2 实例的主机名或 IP 地址。然后,在第十一章文件中找到 arrays.tar 文件。打开一个终端窗口,并输入scp -i dask-cluster-key.pem arrays.tar ec2-user@``<hostname>``:/home/ec2-user。在

图 11.31 使用 SCP 上传数据
数据上传完成后,使用 SSH 连接到 EC2 实例。一旦你登录到 EC2 实例,键入 tar -xvf arrays.tar 从 TAR 文件中提取数据。你的屏幕应该类似于 图 11.32。

图 11.32 提取上传的数据
接下来,键入 rm arrays.tar 以删除 TAR 文件,然后 sudo mv * /efs 将提取的数据移动到你创建的 EFS 卷中。通过键入 cd /efs,然后 ls 验证数据是否已移动。你应该能看到两个 ZARR 文件。最后,验证数据是否对所有其他 EC2 实例都可用。为此,返回到 EC2 实例管理器,复制一个不同运行中的 EC2 实例的主机名,使用 SSH 连接到它,键入 cd /efs,然后 ls,并验证你是否仍然可以看到两个 ZARR 文件。无论何时你需要存储可以被所有你的 EC2 实例访问的附加数据,你都可以遵循将数据上传到一个实例并将它移动到 /efs 文件夹的相同模式。
11.1.6 在弹性容器仓库中为 Docker 镜像分配空间
到目前为止,我们几乎完成了集群基础设施的构建。在我们能够部署和启动 Dask 集群之前,我们需要做的最后一件事是为我们在下一节中构建的 Docker 镜像分配一些空间。这将使我们能够上传完整的镜像并在 ECS 容器内启动它们。首先,返回到 ECS 仪表板,点击左侧菜单中的仓库。这将带你到弹性容器仓库(ECR)管理员页面。点击屏幕右上角的创建仓库按钮以启动创建仓库向导。我们首先为调度器创建一个仓库。在空白的名称字段中,键入 dask-scheduler。你的屏幕应该看起来像 图 11.33。

图 11.33 创建 ECR 仓库
点击创建仓库。一旦你返回到 ECR 管理员页面,重复此过程两次。创建两个名为 dask-worker 和 dask-notebook 的仓库。一旦完成创建仓库,我们现在将构建和部署镜像到集群中。
11.1.7 为调度器、工作节点和笔记本构建和部署镜像
我们将从构建和部署调度器镜像开始,因为工作节点和笔记本镜像需要配置为指向集群的 IP 地址,以便集群能够工作。在我们开始之前,请确保 Docker 已安装并正在你的本地机器上运行。同时,请确保你的 AWS CLI 已正确配置,使用你在 11.1.1 节中创建的安全密钥。你可以在 docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html 找到配置 AWS CLI 的说明。
一旦你验证了所有配置都已设置正确,在章节 11 的文件中找到调度器文件夹,并在终端窗口中导航到它(如果你在运行 Windows,则使用 PowerShell)。在 ECR 管理器页面中,选择 dask-scheduler 存储库,然后点击查看推送命令(View Push Commands)按钮。将显示一个类似于图 11.34 的弹出窗口。

图 11.34 dask-scheduler 的 Docker 推送命令
将此对话框中的命令逐个复制并运行到你的终端或 PowerShell 窗口中。在构建过程中,你应该会看到类似于图 11.35 的内容。根据你的计算机和互联网连接速度,完成可能需要几分钟。

图 11.35 构建 dask-scheduler 图像
构建完成后,你可以通过点击 dask-scheduler 存储库来验证图像是否已上传。你应该看到一个类似于图 11.36 的页面。
现在图像已上传到 ECR,我们需要告诉 ECS 如何在容器中启动它。这是通过创建一个 ECS 任务定义来完成的。在我们离开 ECR 管理器页面之前,复制 dask-scheduler 图像的 Image URI 列中的值。我们很快就需要这个值。

图 11.36 验证图像存在于 ECR 中
要为调度器图像创建任务定义,点击 ECR 管理器页面左侧菜单中的任务定义(Task Definitions)。你将被带到 ECS 任务定义管理器页面。点击蓝色的创建新任务定义(Create a New Task Definition)按钮以启动创建任务定义向导。在第一页,选择 EC2 作为启动类型,如图图 11.37 所示。
点击下一步(Next Step)。在下一页,在任务定义名称(Task Definition Name)字段中输入dask-scheduler。将网络模式(Network Mode)下拉菜单更改为主机(Host)。你的屏幕应该类似于图 11.38。

图 11.37 选择调度器图像的启动类型兼容性

图 11.38 任务定义配置
将其他设置保留为默认值。向下滚动,直到看到卷(Volumes)标题,然后点击添加卷(Add Volume)。你会看到一个对话框出现以添加卷。在名称(Name)字段中输入efs-data,在源路径(Source Path)字段中输入/efs。你的屏幕应该看起来像图 11.39。

图 11.39 卷配置
点击“添加”按钮返回任务定义配置页面。接下来,在“容器定义”标题下点击“添加容器”按钮。在“容器名称”字段中,输入dask-scheduler。然后,将您从 ECR 管理器页面复制的镜像 URI 粘贴到“镜像”文本框中。接下来,将内存限制更改为软限制 700 MiB。最后,添加 TCP 端口 8786 和 8787 的主机和容器端口映射。您的屏幕应类似于图 11.40。

图 11.40 dask-scheduler 的容器配置
滚动到“存储和日志”部分,并配置挂载点以匹配图 11.41。
勾选标有“自动配置 CloudWatch 日志”的复选框,并保留默认设置。您的屏幕应类似于图 11.42。

图 11.41 挂载点配置

图 11.42 日志设置
最后,点击“添加”以完成将容器添加到任务定义中。现在,您的屏幕应类似于图 11.43。
点击“创建”以完成创建任务定义,并观察任务定义是否创建成功。我们现在有一个模板,我们可以用它来启动 dask-scheduler 镜像的副本!现在我们只需要启动它。为此,我们必须创建一个与任务定义绑定的 ECS 服务。首先,导航回 ECS 任务定义管理器页面。然后,选中 dask-scheduler 任务定义旁边的复选框,点击“操作”按钮,并选择“创建服务”。这将启动创建服务向导。
首先,选择“EC2”作为启动类型。接下来,在“服务名称”字段中输入dask-scheduler。然后,在“任务数量”字段中输入1,并从放置模板下拉菜单中选择“每主机一个任务”。您的屏幕应类似于图 11.44。
点击“下一步”按钮。在下一页,取消勾选“启用服务发现集成”旁边的复选框。保留其他设置作为默认设置。点击“下一步”。保留设置并点击“下一步”。最后,在审查屏幕上,点击“创建服务”。您将被带到启动状态屏幕。点击“查看服务”按钮,将被带到集群状态屏幕。现在,您的屏幕应类似于图 11.45。

图 11.43 完整容器配置

图 11.44 dask-scheduler 的服务配置

图 11.45 dask-cluster 的状态
要获取有关 dask-scheduler 服务的更多信息,请单击蓝色的 dask-scheduler 链接。这将带您到服务状态页面。几分钟之后,您应该会注意到有一个正在运行的任务,类似于图 11.46。

图 11.46 运行任务的示例
如果服务处于挂起状态,请等待几分钟并刷新页面。有时 ECS 设置镜像可能需要几分钟时间。一旦任务处于运行状态,点击任务列下的链接以查看任务详情。在任务详情页面上,您将看到一个名为 EC2 实例 ID 的字段旁边的链接。点击该链接将被带到 EC2 实例管理器。这就是 dask-scheduler 容器正在运行的 EC2 实例!公共 DNS 名称和公共 IP 可以用来从 AWS 外部连接到调度器,例如您想登录到监控仪表板时。请记下公共 DNS 名称,因为我们将在将工作节点连接到集群后打开诊断仪表板。此信息的示例显示在图 11.47 中。

图 11.47 EC2 实例的示例 IP 和 DNS 信息
现在我们已准备好部署 dask-worker 和 dask-notebook 的镜像!为此,按照您部署和启动 dask-scheduler 镜像时遵循的相同步骤,分别使用工作节点和笔记本文件夹中的 Dockerfile。然而,在创建每个任务的定义和服务时,请注意配置中的几个例外:
-
对于 dask-worker 镜像
-
在添加容器屏幕上的端口映射应设置为 8000 tcp,而不是 8786 tcp 和 8787 tcp。
-
在创建服务向导的第 1 步屏幕上的任务数量字段应设置为6,而不是 1。
-
对于 dask-notebook 镜像
-
在创建任务定义向导的第 2 步屏幕上的网络模式下拉框应设置为默认值,而不是主机模式。
-
在添加容器屏幕上的端口映射应设置为 8888 tcp,而不是 8786 tcp 和 8787 tcp。
一旦您已验证您有一个正在运行的 dask-scheduler 服务实例、一个正在运行的 dask-notebook 服务实例以及六个正在运行的 dask-worker 服务实例,我们就可以连接到集群并开始运行一些作业。
11.1.8 连接到集群
dask-notebook 映像包含一个 Jupyter Notebook 服务器,我们将使用它来与我们的 Dask 集群交互。当 Dask 以集群模式运行时,它还提供一些额外的诊断工具,这些工具显示了工作负载如何在集群中分布。这有助于查看是否有工作节点卡住,或一般跟踪作业的进度。在连接到笔记本服务器之前,我们将查看诊断页面。要访问 Dask 集群上的诊断页面,请打开网络浏览器并在地址栏中键入 http://<your scheduler hostname>:8787,将

图 11.48 Dask 集群工作节点状态
您应该在表中看到六行。这些对应于集群中的每个工作节点。您可以看到每个工作节点的当前 CPU 使用率、内存使用率和网络活动。此页面有助于监控工作节点的整体健康状况。请保持此窗口打开,因为我们将在本章后面再次回到它。
最后,我们将连接到笔记本服务器。为此,按照您查找 dask-scheduler 容器公网 DNS 名称的步骤查找 dask-notebook 容器的公网 DNS 名称。一旦您复制了笔记本服务器的公网 DNS 名称,请打开网络浏览器并在地址栏中键入 http://<your notebook hostname>:8888,将

图 11.49 Jupyter 登录界面
要查找登录令牌,请返回您的网络浏览器窗口,其中您已登录到 AWS 控制台,并转到 ECS 仪表板。导航到 dask-notebook 服务中当前运行的任务的任务详情,然后点击日志选项卡。您的屏幕应类似于 图 11.50。

图 11.50 任务日志界面
我们已将每个任务定义配置为将日志发送到 AWS CloudWatch,因此我们可以通过 ECS 仪表板访问任何运行容器的原始日志。如果您需要深入了解特定服务的幕后情况,请查看日志。默认情况下,Jupyter 启动时会将登录令牌打印到日志中。向下滚动,直到找到包含登录令牌的日志条目行。复制此值并将其粘贴到 Jupyter 登录窗口中,然后点击登录。您的屏幕应类似于 图 11.51。

图 11.51 Jupyter Lab 窗口
从这里开始,我们就可以在集群上运行代码了!在下一节中,我们将探讨如何将笔记本上传到笔记本服务器以及如何在集群上监控作业的执行。
11.2 在集群上运行和监控 Dask 作业
对于本节,我们将通过以下场景回到我们在第十章中探讨的情感分类器问题:
使用 Amazon Fine Foods 数据集,在 AWS 中的 Dask 集群上构建情感分类器模型,并监控作业的执行。
为了简洁起见,第十一章笔记本是第十章笔记本的简略摘录。它不是从原始数据到完整的情感分类器模型的完整流程,而是包含了上一节上传到 EFS 的数据中生成的 ZARR 文件。因此,第十一章笔记本只是构建分类器模型的一个简短示例,突出了在集群上运行代码所需的微小差异。在浏览完第十一章笔记本后,如果你愿意,你可以修改之前章节中的任何笔记本以在你的集群上运行。首先,将第十一章笔记本上传到你的 Jupyter Notebook 服务器。
在 Jupyter Notebook 服务器的首页上,点击文件资源管理器窗格中设置菜单下方向上的箭头图标。此按钮的位置在图 11.52 中显示。

图 11.52 将笔记本上传到 Jupyter Notebook 服务器
导航到第十一章笔记本的位置,然后点击上传。几秒钟后,你应该会在工作文件夹下方的文件资源管理器窗格中看到笔记本出现。双击笔记本以在新标签页中打开它。如前所述,这个笔记本中的代码与第十章完全相同。只有两个差异使得代码能够在集群上运行:使用分布式客户端接口,以及将 ZARR 文件存储的文件系统路径进行的小幅修改。
分布式客户端接口是使代码在集群上运行而不是在笔记本服务器上本地运行的关键。
列表 11.2 初始化分布式客户端
from dask.distributed import Client, progress
client = Client()
client
在这个情况下,我们只需要简单地初始化客户端。在这段代码执行之后,任何计算类型的 Dask 方法(例如 compute、head 等)都将发送到集群而不是在本地执行。我们可以选择性地将调度器的 IP 地址和端口传递给客户端对象,但在这个情况下是不必要的。这是因为你在上一节中对笔记本服务器镜像所做的 Dockerfile 修改是添加了一个包含调度器 URI 的环境变量。如果没有在 Client 构造函数中显式传递调度器 URI,它将读取 DASK_SCHEDULER_ADDRESS 环境变量的值。执行此代码后,你应该会看到一个类似于 图 11.53 的结果。

图 11.53 集群的客户端统计信息
这个信息显示,有六个工作节点在 Dask 集群中,正如我们预期的那样!现在我们可以运行任何 Dask 代码,并且它将在集群上执行。
对集群笔记本所做的第二个更改是在第二个单元格中。
列表 11.3 更改数据文件路径
from dask import array as da
feature_array = da.from_zarr('/data/sentiment_feature_array.zarr')
target_array = da.from_zarr('/data/sentiment_target_array.zarr')
正如你所看到的,被引用的数据文件位于 /data 文件夹中。这是因为我们在上一节的任务定义中设置的挂载点将 EC2 实例中的 /efs 文件夹暴露给容器内的 /data 文件夹。这意味着你将任何数据复制到 EC2 实例上的 /efs 文件夹,这些数据将立即在笔记本服务器和其 /data 文件夹中的工作节点上可用。如果你想分析集群上的其他数据集,使用你之前在上一节中使用的步骤,通过 SCP 将 arrays.tar 文件上传到 EFS。
最后,在你执行笔记本中剩余的单元格之前,回到 Dask 诊断窗口,点击顶部菜单中的状态链接。状态页面提供了关于 Dask 作业执行的详细信息。一旦你看到该页面,就可以执行笔记本中剩余的单元格。这将启动在集群上构建情感分类器模型的过程,你将能够在诊断页面上看到该过程的详细信息。诊断页面的一个示例显示在 图 11.54 中。

图 11.54 Dask 诊断页面的示例
状态页面的四个部分提供了关于作业在集群中进度的不同信息:
-
内存压力
-
工作节点级别的任务队列
-
任务流
-
进度
从左上角开始,在“存储的字节数”下面是内存压力信息。存储的字节数让我们知道整个集群中内存中保留的数据量。这个数字通常会随着数据处理上下波动。图表显示了每个工作者的内存压力。蓝色条表示工作者有足够的内存空间,而黄色条表示工作者可能快要用完内存,可能需要将数据溢出到磁盘。如果你有运行缓慢或随机崩溃的作业,请密切关注内存压力,以确保工作者不会用完内存。如果工作者持续承受大量内存压力,那么重新分区你的数据集并增加分区数量可能是个好主意。较小的数据块更容易放入内存,并降低需要溢出到磁盘的需求。
在内存压力图表下方是工作者级任务队列部分。这部分显示了根据调度器的当前执行计划,每个工作者当前排队等待执行的任务数量。蓝色条表示队列中可接受的任务数量,而红色条表示工作者正在等待工作。这通常发生在某个工作者依赖于其他工作者正在处理的一些数据时。通常,任务应该在工作者之间均匀分布。如果一个工作者的队列中任务数量远高于其他工作者,那么可能存在某个问题导致该工作者处理速度比其他工作者慢。检查并查看该工作者上是否运行了 Dask 之外的其他进程或是否存在其他性能问题是个好主意。
在工作者级任务队列部分右侧是作业进度部分。这部分显示了待处理任务的数量、已完成任务的数量以及是否发生错误。随着作业接近完成,进度条会逐渐填满。任何发生错误的任务将在另一个工作者上重试。
最后,在作业进度部分上方是任务流部分。这部分显示了每个工作者完成每个任务所需的时间。条形的颜色与进度条的颜色相对应。例如,如果一个pandas_read任务的进度条是绿色的,那么pandas_read任务的持续时间将在任务流中以绿色条显示。这可以用来发现慢作业中的低效之处。如果某种类型的操作耗时较长,可能可以通过重构代码使其更高效。它还可以用来帮助发现与工作者级任务队列类似的工作者的性能问题。例如,如果一个工作者通常需要 100 毫秒来完成一个pandas_read任务,而其他工作者每个任务只需要 50 毫秒,那么可能存在某个特定工作者的问题,导致其运行速度变慢。
另一种查看任务流的方法是查看底层的 DAG(有向无环图)。诊断仪表板实际上允许你在作业处理时实时查看底层的 DAG。要查看 DAG,请点击顶部菜单中的“图形”链接。图形页面的一个示例显示在图 11.55 中。

图 11.55 实时 DAG 视图
此页上的 DAG 总是从左到右读取。绿色块表示当前正在处理的任务。灰色块表示等待上游依赖的任务,例如图右侧的块。红色块表示已完成任务的块,其结果正在内存中保留。下游任务的全部上游依赖项将在下游任务完成处理之前保留在内存中。在此之后,上游任务的数据将从内存中释放,块将变为蓝色。
11.3 清理 AWS 上的 Dask 集群
我们将要讨论的最后一件事是如何清理 AWS 中的服务。如本章前面所述,AWS 使用基于使用量的计费。这意味着,例如,每当一个 EC2 实例被置于运行状态时,AWS 就开始计时该 EC2 实例已运行的时间,并按分钟计费。AWS 免费层每月包括 750 小时的 EC2 使用量。在我们的集群配置中,有八个 EC2 实例运行,这意味着每个实例运行的小时数使用了八个小时。这意味着集群可以在线保持 93 小时,即不到四天,而不会产生费用。幸运的是,你可以通过本章前面配置的自动扩展组轻松地打开和关闭集群。
要关闭 EC2 实例,只需返回 AWS 控制台的 EC2 仪表板,在左侧菜单下的自动扩展部分点击“自动扩展组”。然后,选择 ECS 集群的自动扩展组(应该只有一个),点击编辑。在期望容量字段中,将 8 改为 0 并点击保存。你的屏幕应该看起来类似于图 11.56。

图 11.56 关闭 EC2 自动扩展组
几分钟后,EC2 实例将开始关闭。你可以通过检查 EC2 实例管理器并观察现在运行的 EC2 实例现在处于关闭或终止状态来验证这一点。要稍后重新启动集群,你只需将期望容量从 0 改回 8(或你希望启动的实例数量)。
需要考虑的其他服务是 EFS 和 ECR。由于这两个都是存储服务,因此它们的计费基于所消耗的存储大小。只要你不将超过 5 GB 的总数据上传到/efs 文件夹,你就可以保持在 EFS 的免费层限制内。
不幸的是,ECR(Elastic Container Registry)的限制稍微严格一些。ECR 的免费层每月限制为 500 MB。按月存储意味着计算一个月内使用的存储平均量,如果这个平均值超过 500 MB,那么将产生可计费的使用费用。三个 Dask 集群映像的总空间大约为 2 GB,所以如果这些映像在 ECR 中保留超过大约一周,你将超出 ECR 免费层的限制。根据 ECR 的存储定价,每 GB/月 0.10 美元,存储这些映像整个月将花费 0.20 美元。如果你想避免所有可计费的费用,你必须在你完成练习后删除 ECR 仓库。你可以从 ECR 仓库管理器屏幕中这样做。然而,这不幸地意味着,如果你希望在以后的时间重新开始与集群一起工作,你必须重新部署映像并重新创建 ECS 服务。
一旦你关闭了 EC2 实例并删除了 ECR 仓库,所有基于使用的计费都将停止。
摘要
-
可以使用 Amazon AWS、Docker 和 ECS 在云中构建 Dask 集群,这让你可以根据工作负载的需求轻松调整集群的大小。
-
使用分布式任务调度器客户端将作业提交到 Dask 集群。分布式任务调度器将工作分割和组织到整个集群中,并将结果发送回最终用户。
-
分布式任务调度器有一个运行在端口 8787 的诊断页面,允许你监控作业的执行并识别集群的问题。
-
可以使用 EC2 自动扩展组快速启动和关闭集群,这让你能够控制资源成本,并在完成工作后轻松清理。
附录
软件安装
要运行代码笔记本并跟随 Python 和 Dask 数据科学 中的示例,你应该在你的系统上安装以下软件:
-
Python 2.7.14 或更高版本或 Python 3.6.5 或更高版本(强烈推荐 Python 3.6.5 或更高版本)
-
以下 Python 包:
-
IPython
-
Jupyter
-
Dask(版本 1.0.0 或更高)
-
Dask ML
-
NLTK
-
Holoviews
-
Geoviews
-
Graphviz
-
Pandas
-
NumPy
-
Matplotlib
-
Seaborn
-
Bokeh
-
PyArrow
-
SQLAlchemy
-
Dill
下载免费的 Python 发行版 Anaconda 是安装和维护所有必需 Python 包的最简单方法,可在 www.anaconda.com/download 获取。Anaconda 发行版适用于 Windows、macOS 和大多数主要的 Linux 发行版。如果你已经安装了 Anaconda,所有必需的包都将包含在安装程序中,除了 graphviz 和 pyarrow。要安装 graphviz 和 pyarrow,请按照 A.1 节中的说明操作。否则,如果你希望从头开始安装所有包,请按照 A.2 节中的说明操作。
使用 Anaconda 安装附加包
如果你已经安装了 Anaconda 发行版,你需要安装 graphviz 和 pyarrow。如果你为与示例一起工作专门设置了虚拟环境,确保在运行安装命令之前激活它。打开命令提示符或终端窗口并输入以下命令。
列表 A.1 安装 graphviz 和 pyarrow
conda install -c conda-forge pyarrow
conda install -c conda-forge dill
conda install graphviz
conda install python-graphviz
就这些!你现在已经准备好运行示例笔记本了。
不使用 Anaconda 安装包
如果你更喜欢不使用 Anaconda 这样的发行版来安装包,你可以使用 pip 来这样做。然而,强烈建议使用 Anaconda,因为当你从 pip 构建包时可能会遇到一些编译器/运行时依赖问题(例如,NumPy 历史上需要 FORTRAN 编译器来构建)。要获取适当的包,请在命令提示符或终端窗口中运行以下命令。
列表 A.2 使用 pip 从源安装包
pip install ipython jupyter dask graphviz python-graphviz pandas numpy matplotlib seaborn bokeh pyarrow sqlalchemy holoviews geoviews dask-ml nltk dill
安装完成后,你应该能够启动 Jupyter Notebook 服务器并运行所有示例笔记本。
启动 Jupyter Notebook 服务器
你可以通过打开终端或命令提示符并输入 jupyter notebook 来启动 Jupyter Notebook 服务器以运行附带的代码笔记本,然后按 Enter。几秒钟后,你的默认网页浏览器应该会打开并自动导航到 Jupyter 主页。确保你在工作时保持终端窗口打开,因为关闭窗口将导致笔记本服务器终止。
配置 NLTK
对于第九章和第十章的示例,你需要下载一些额外的 NLTK 数据。为此,请在你的终端窗口中运行以下命令。
列表 A.3 下载 NLTK 数据
python -m nltk.downloader all
如果遇到问题,请尝试以提升权限的方式运行该命令(在 Unix/Linux/MacOS 系统上使用 sudo,或在 Windows 中以管理员身份运行命令提示符)。运行此命令后,您就可以开始使用 NLTK 了。


浙公网安备 33010602011771号