精通-Python-大型数据集-全-

精通 Python 大型数据集(全)

原文:Mastering Large Datasets with Python

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分

第一部分 探讨了 map 和 reduce 计算风格。我们将介绍 map 和 reduce,以及您需要充分利用这种风格的辅助和便利函数。在本节中,我们还将涵盖并行计算的基础知识。本部分中的工具和技术对于第 1 类和第 2 类的大数据很有用:既可本地存储又可本地计算的任务,以及不可本地存储但仍然可本地计算的任务。

第一章. 简介

本章涵盖

  • 介绍 map 和 reduce 编程风格

  • 理解并行编程的好处

  • 将并行编程扩展到分布式环境

  • 云端的并行编程

本书介绍了一系列编程技术、工具和框架,用于掌握大数据集。在这本书中,我将把您正在学*的编程风格称为 map 和 reduce 风格。map 和 reduce 编程风格是一种我们可以通过围绕两个函数 mapreduce 组织我们的代码来轻松编写并行程序——可以同时做很多事情的程序。为了更好地理解为什么我们会想使用 map 和 reduce 风格,请考虑以下场景:

场景

两位年轻的程序员提出了一种如何对互联网上的页面进行排名的想法。他们希望根据链接到他们的其他互联网站点的相关性来排名页面。他们认为互联网应该就像高中一样:越是有酷的孩子谈论你,你就越重要。这两位年轻的程序员喜欢这个想法,但他们如何可能分析整个互联网呢?

一个对硅谷历史了如指掌的读者会认出这个场景是 Google.com 的起源故事。在其早期,谷歌通过推广一种称为 MapReduce 的编程方式,作为有效处理和排名整个互联网的方法。这种风格对谷歌来说是一种自然的选择,因为

  1. 谷歌的两位创始人都是数学爱好者,MapReduce 的根源在于数学。

  2. 与更传统的编程风格相比,以 map 和 reduce 为中心的编程导致简单的并行化。

mapreduce 与 MapReduce

在这本书中,我将多次提到 map 和 reduce 编程风格。实际上,这种风格是我将用来教您如何将程序扩展到笔记本电脑之外的主要手段。尽管这种风格在名称和功能上与 MapReduce 相似,但它与 MapReduce 不同,并且更通用。MapReduce 是并行和分布式计算的一个框架。map 和 reduce 风格是一种编程风格,允许程序员以最小的重写并行运行他们的工作,并将这项工作扩展到分布式工作流程,可能使用 MapReduce,也可能使用其他方法。

在这本书中,我们将解决谷歌早期阶段所面临的问题。我们将研究一种使将好主意扩展变得容易的编程风格。我们将研究一种使从个人工作到团队合作,或从笔记本电脑工作到分布式并行环境工作变得容易的编程方式。换句话说,我们将研究如何掌握大型数据集。

1.1. 你将在本书中学到什么

在这本书中,你将学*一种使并行化变得容易的编程风格。你将学*如何编写可扩展的、并行化的代码,它在一台机器上运行的效果和在成千上万台机器上运行的效果一样好。你将学*如何

  • 将大型问题分解成小块

  • 使用 mapreduce 函数

  • 在你的个人电脑上并行运行程序

  • 在分布式云环境中并行运行程序

你还将学*两个用于处理大型数据集的流行框架:Apache Hadoop 和 Apache Spark。

这本书是为那些已经能够编写工作数据转换程序,现在需要扩展这些程序的程序员而写的。他们需要能够处理更多的数据,并且更快地完成这些工作。

1.2. 为什么需要大型数据集?

你可能已经听说过关于现代计算中一系列无定形问题的对话,这些问题围绕着 大数据 的概念。大数据对不同的人意味着不同的事情。我发现大多数人使用这个短语来表示数据“感觉”很大——它难以处理或难以驾驭。

因为本书的一个目标是要让你对任何大小的数据集都感到舒适,我们将使用 大型数据集 进行工作。在我看来,大型数据集问题可以分为三种规模:

  1. 数据可以适应并处理在个人电脑上。

  2. 解决这个问题的方案可以从个人电脑上执行,但数据不能存储在个人电脑上。

  3. 解决这个问题的方案不能在个人电脑上执行,数据也不能存储在个人电脑上。

你可能已经知道如何解决第一类问题。大多数问题——尤其是那些用来教授编程的问题——都属于第一类。第二组问题稍微难一些。它们需要一种称为 并行计算 的技术,使我们能够充分利用我们的硬件。最后,我们有第三组问题。这些问题成本高昂,需要更多的资金或更多的时间来解决。为了解决这些问题,我们希望使用一种称为 分布式计算 的技术。

Dask—一种不同类型的分布式计算

Map 和 Reduce 编程风格将数据置于首位,非常适合处理数据,从小型数据转换到大型分布式数据存储。

如果你不想学*一种会使你的 Python 代码更容易阅读和扩展的编程风格,但仍然想能够管理大型数据集,那么市面上有一款适合你的工具,那就是 Dask。Dask 是一个具有类似 NumPy 和 pandas API 的 Python 分布式数据框架。如果你对此感兴趣,我推荐 Jesse Daniel 的《Python 和 Dask 数据科学》(Manning,2019;mng.bz/ANxg)。

通过这本书,你将学会一种编程风格,它允许你以相同的方式为所有三种规模的问题编写代码。你还将了解并行计算以及两个分布式计算框架(Hadoop 和 Spark),我们将探讨如何在分布式云环境中使用这些框架。

1.3. 什么是并行计算?

并行计算,我还会将其称为并行编程并行化,是一种让你的计算机同时做很多事情的方法。例如,参考你之前看到的场景,我们的年轻程序员将需要一次处理多个网页;否则,他们可能永远无法完成——有太多的网页。即使每半秒处理一个网页,他们每天也无法达到 20 万个页面。为了抓取和分析整个互联网,他们需要能够扩展他们的处理能力。并行计算将使他们能够做到这一点。

1.3.1. 理解并行计算

要理解并行编程,让我们首先谈谈标准过程式编程中会发生什么。标准过程式编程的工作流程通常看起来像这样:

  1. 程序开始运行。

  2. 程序发出一条指令。

  3. 那条指令被执行了。

  4. 步骤 2 和步骤 3 被重复执行。

  5. 程序运行结束。

这是一种直接的编程方式;然而,它限制了我们一次只能执行一条指令(图 1.1)。步骤 2 和步骤 3 需要解决,我们才能继续到步骤 4。而步骤 4 将我们带回到步骤 2 和步骤 3,使我们陷入同样的困境。

图 1.1. 过程式计算过程涉及发出指令并按顺序解决它们。

图片

在标准线性程序中,如果步骤 2 中的指令执行时间很长,那么我们就无法继续进行问题的下一个部分。想象一下我们的年轻程序员试图抓取整个互联网时这会是什么样子。他们有多少条指令会是“抓取网页 abc.com/xyz”?可能很多。更重要的是,我们知道抓取一个网页(比如亚马逊首页)根本不会以任何方式改变其他网页的内容(比如《纽约时报》首页)。

并行编程允许我们同时执行所有这些相似且独立的步骤。在并行编程中,我们的工作流程将看起来更像是这样:

  1. 程序开始运行。

  2. 程序将工作划分为指令和数据块。

  3. 每个工作块都是独立执行的。

  4. 工作块被重新组装。

  5. 程序运行结束。

通过这种方式编程,我们摆脱了之前陷入的指令执行循环(图 1.2)。现在我们可以将我们的工作分成我们想要的任何多块,只要我们有处理它们的方法。

图 1.2。并行计算过程将工作划分为可以单独处理并完成后重新组合的块。

图片描述

对于希望抓取整个互联网的年轻程序员来说,这个过程会更好。他们仍然需要找到一种方法来获取足够的计算资源来处理所有这些块,但每次他们获得一台新机器,他们的过程就会变得更快。实际上,即使是早期的谷歌也是在成千上万的计算机集群上运行的。

1.3.2. 使用 map 和 reduce 风格的可扩展计算

当我们思考 map 和 reduce 风格的计算时,重要的是要在我们数据的大小和我们可用的计算资源容量的背景下进行思考(图 1.3)。对于正常大小的数据——这允许我们使用个人电脑规模的资源来处理我们可以存储在个人电脑上的数据——我们可以依赖 map 和 reduce 风格的基本原理和标准 Python 代码。在这个领域,我们不会看到与其他编程风格太大的区别。

图 1.3。我们可以将 map 和 reduce 风格的编程视为一个建设项目:从蓝图,帮助我们组织工作;到原材料的转换;到将部件组装成最终产品。

图片描述

随着数据规模的增加,我们到达了一个可以使用个人电脑硬件处理数据的地方,但我们却在个人电脑上存储数据时遇到了麻烦。在这种情况下,如果我们愿意,我们可以在集群中工作我们的工作,但这不是必需的。在这里,map 和 reduce 风格的好处开始变得明显。我们可以使用稍微修改过的代码来处理这个规模的数据。

最后,我们来到了数据的最最终和最大的类别。这是我们需要在分布式环境中处理和存储的数据。在这里,我们可以使用像 Hadoop 和 Spark 这样的分布式计算框架。尽管我们不能使用完全相同的代码,但我们可以使用从小规模数据中提取的原则和模式。我们通常还需要使用云计算服务,如 AWS。

其他大型数据技术:Splunk、Elasticsearch、Pig 和 Hive

因为这本书专注于可扩展的工作流程,所以我故意省略了那些只有在高容量环境中才有意义的大数据工具,包括 Splunk、Elasticsearch、Apache Pig 和 Apache Hive。后两者基于 Hadoop 堆栈,与 Hadoop 和 Spark 是天然的伙伴。如果你正在处理大量数据,调查这些工具是非常值得的。

我们可以在图 1.3 中看到这一点。图 1.3 显示了本书中教授的技术如何与各种数据大小和可用的计算资源相匹配。我们首先介绍你可以在笔记本电脑或个人计算机上使用的技巧:Python 内置的 map 和 reduce 以及并行计算能力。在最后两个部分(从第七章开始),我们将介绍分布式计算框架,如 Hadoop 和 Spark,以及如何使用 Amazon Web Services EMR 在云中部署这些服务。

1.3.3. 何时使用 map 和 reduce 风格编程

map 和 reduce 风格的编程适用于任何地方,但它的具体优势在于你可能需要扩展的领域。扩展意味着从一个小的应用程序开始,比如你晚上在笔记本电脑上作为一个宠物项目构建的小游戏,并将其应用到更大的用例中,比如每个人都在他们的手机上玩的热门游戏。

考虑我们假设的游戏中的一小步:改进 AI。比如说,我们有一个所有玩家都在竞争的 AI 对手,我们希望 AI 每 1000 场比赛后都能改进。一开始,我们可以在单台机器上更新我们的 AI。毕竟,我们只有 1000 场比赛,处理它们是微不足道的。即使玩家数量增加,我们也只需要每隔几个小时运行这个改进。然而,最终,如果我们的游戏足够受欢迎,我们就需要为这个任务分配几台机器——他们需要处理的信息量会更大(比赛历史中会有更多比赛),他们需要更快地处理信息(因为游戏速度会更快)。这将是一个非常适合 map 和 reduce 风格的绝佳应用,因为我们可以轻松修改我们的代码以实现并行,这样我们就可以将我们的 AI 改进扩展到任何数量的用户。

1.4. map 和 reduce 风格

并行编程工作流程有三个部分,使其与标准线性工作流程区分开来:

  1. 将工作分成块。

  2. 分别处理这些块。

  3. 重新组装工作。

在这本书中,我们将让函数mapreduce为我们处理这三个部分。

1.4.1. 用于转换数据的 map 函数

map是我们将用于将数据序列从一种类型转换为另一种类型的函数(图 1.4)。这个函数的名字来源于数学,在那里一些数学家认为函数是获取输入并返回相应输出的规则。再次考虑我们年轻而有抱负的程序员,他们可能想要将网页序列(或所有网页的序列)map到包含在这些页面中的 URL 上。然后,他们可以使用这些 URL 来查看哪些页面被链接得最频繁以及由谁链接。

图 1.4. 我们可以使用map函数将数据序列从一种类型转换为另一种类型,例如将网页 URL 转换为包含在这些页面上的链接列表。

关于map的一个关键点是,它总是保留与输入中提供的相同数量的对象。例如,如果我们想用map获取 10 万个网站的出站链接,那么结果数据结构将是 10 万个链接列表。

注意

mapreduce的根源在于一种称为声明式编程的编程风格。声明式编程侧重于解释代码的逻辑,而不是指定低级细节。这就是为什么在 map 和 reduce 风格中扩展我们的代码是自然的:逻辑保持不变,即使问题的大小发生变化。

由于map函数在我们整本书中的操作中起着基础性作用,现在看看一个map函数的简单示例是值得的。让我们设想我们想要将 7 加到四个数字的序列中:-1, 0, 1, 和 2。为此,我们编写了一个名为add_seven的小函数,它接受一个数字n并返回n+7。为了对我们数字序列进行此操作,我们只需在add_seven和我们的序列上调用map(图 1.5)。

图 1.5. map的基本用法之一是增加数字序列,例如将-1, 0, 1, 和 2 转换为 6, 7, 8, 和 9。

你会注意到,就像我们之前提到的,我们有与输出(4)相同的输入(4)。此外,这些输入和输出有直接的 1 对 1 关系:特定的输出对应于每个输入。

1.4.2. 高级变换的 reduce 函数

如果我们想要将那个序列转换成不同长度的东西,我们需要我们的另一个关键函数:reducereduce允许我们将数据序列转换成任何形状或大小的数据结构(图 1.6)。例如,如果我们的程序员想要将那些链接转换成频率计数——找出哪些页面被链接得最多——他们就需要使用reduce,因为链接的页数可能与爬取的页数不同。我们可以很容易地想象,100 个网页可能链接到 0 到 100 万个外部页面,这取决于相关的网页内容。

图 1.6. 我们可以使用reduce函数将一种类型的数据序列转换为另一种类型:另一个序列或甚至是一个原始数据类型。

如果我们愿意,甚至可以使用reduce将数据序列转换为原始数据类型,例如整数或字符串。例如,我们可以使用reduce来找出 100 个网页上的出站链接数量(一个整数),或者我们可以用它来找出长文本文档中最长的单词,例如一本书(一个字符串)。这样,reducemap更加灵活。

1.4.3. 用于数据转换管道的 Map 和 Reduce

通常,我们希望将mapreduce一起使用,一个紧接着另一个。这种模式产生了MapReduce编程模式。MapReduce 编程模式依赖于map函数将某些数据转换为另一种类型的数据,然后使用reduce函数来合并这些数据。一个数学示例可能是计算一系列数字的最大质因数之和。我们可以使用map将每个数字转换为它的最大质因数,然后使用reduce来计算它们的和。一个更实际的例子可能是找出一系列网页中最长的单词,当我们只有 URL 时。我们可以使用map将 URL 转换为文本,然后使用reduce来找出最长的单词(图 1.7)。

图 1.7. 函数mapreduce通常一起使用,以快速执行大量数据的复杂转换。

1.5. 分布式计算以实现速度和规模

为了充分利用并行编程,我们需要在一个分布式环境中工作,也就是说,一个可以在多台机器之间分配工作负载的环境。考虑以下场景。

场景

一家金融交易公司提出了一种基于纽约市夜间出租车和拼车交通以及早晨的鱼价来预测第二天市场活动的方法。公司的模拟非常完美,但需要五小时才能运行。交通结果在凌晨 3:00 被视为最终结果,而市场直到上午 9:00 才开放。这本来会留出足够的时间,但有些日子鱼价直到早上 6:00 才有。交易公司如何让它的模型及时运行?

在上述场景中,如果我们的交易员希望输入当天的实际鱼价数据,他们将不会有什么运气。幸运的是,可以将这个问题分布到计算机网络中,让它们各自计算一个不同的场景。这样,无论鱼价数据如何,他们都已经有了结果在手。

分布式计算是并行计算的一种扩展,其中我们为每个任务块分配的计算资源是其自己的机器。这可能会变得复杂。所有这些机器都必须与分割任务并合并结果的机器进行通信。好处是,许多复杂的任务——如金融模拟——可以同时执行,并将结果汇总(图 1.8)。

图 1.8. 我们可以使用分布式计算同时运行复杂的场景,并将结果返回到单个位置。

重要的是,对于我们可以以分布式方式执行的问题,我们通常可以通过将工作分配到越来越多的机器或提高分配给任务的机器的能力来加快它们的速度。哪种解决方案会导致代码运行更快取决于问题。然而,对我们金融交易公司来说的好消息是,他们可能有钱实现其中任何一个方案。

1.6. Hadoop:用于映射和归约的分布式框架

要了解更多关于分布式计算的信息,我们首先将查看一种称为Apache Hadoop的特定分布式计算形式,或简单地称为Hadoop。Hadoop 被设计为谷歌原始 MapReduce 框架的开源实现,并已发展成为被处理大量数据的公司广泛使用的分布式计算软件。此类公司的例子包括 Spotify、Yelp 和 Netflix。

场景

Spotify 是一家云音乐提供商,提供两项标志性服务:通过互联网提供免费音乐和定制、精选的播放列表,帮助您发现新音乐。这些定制播放列表通过比较您喜欢和收听的歌曲与其他用户收听的内容,然后建议您可能错过的歌曲来实现。挑战在于 Spotify 有数亿用户。Spotify 如何比较所有这些用户的音乐品味?

为了创建他们的音乐推荐,Spotify 使用 Hadoop。Hadoop 允许 Spotify 在其分布式文件系统上存储其收听日志(PB 级信息),然后定期分析这些信息。数据量是 Hadoop 如此有价值的原因。

如果 Spotify 拥有较少的信息,它可以使用关系数据库。然而,由于我们谈论的是音乐,一个包含 10PB MP3 的 10PB 播放列表需要大约 20,000 年才能播放。如果有人在大约人类驯化家畜之前开始播放,您可以在一生中完成这个播放列表。

使用 Hadoop 意味着数据存储和处理都可以分布式进行,因此 Spotify 不必 necessarily 关注它有多少数据。只要它能支付新机器来存储数据,它就可以使用 Hadoop 将它们聚集在一起(图 1.9)。

图 1.9. Hadoop 允许我们在分布式文件系统的节点上存储数据,并使用高度并行的 MapReduce 过程分析数据。

1.7. Spark 用于强大的映射、减少以及其他功能

我们还将简要介绍 Apache Spark(或简称 Spark)作为一种分布式计算框架。Spark 某种程度上是 Apache Hadoop 框架的继承者,它更多地通过内存操作来完成工作,而不是通过写入文件。这里的内存指的是一个集群中多台机器的内存,而不是单台机器的内存。

结果是 Apache Spark 可以比 Apache Hadoop 快得多。根据 Apache 自己的估计,Spark 可以比 Hadoop 快 100 多倍,尽管与单台机器上的线性过程相比,两者都会显著提高速度。Spark 还有一些用于机器学*的优秀库,我们将对其进行探讨。

最终,你决定是否想在工作中使用 Spark 或 Hadoop 将取决于你。Spark,就像 Hadoop 一样,被许多大型组织使用,例如亚马逊、eBay,甚至是 NASA。两者都是非常好的选择。

1.8. AWS 弹性 MapReduce—云中的大数据集

目前实现 Hadoop 和 Spark 最受欢迎的方式之一是通过亚马逊的弹性 MapReduce。弹性 MapReduce(EMR)将我们一直在讨论的 MapReduce 框架与亚马逊的“弹性”系列云计算 API 相结合,例如弹性云计算(EC2)。这些工具具有非常相关的目的:让软件开发者能够专注于编写代码,而不是关注硬件的采购和维护。

在传统的分布式计算中,个人或更常见的是公司必须拥有所有机器。然后他们必须将这些机器联合成一个集群,确保这些机器与所有最新的软件保持同步,并确保所有机器都在运行。使用 EMR,我们只需要一些零钱就可以涉足分布式计算,亚马逊处理其余的事情。

由于 EMR 允许我们按需运行分布式作业,而无需拥有自己的集群,我们可以通过并行编程扩大我们想要解决的问题的范围。EMR 允许我们通过并行编程解决小型问题,因为它使其具有成本效益。我们不需要在服务器上进行前期投资来原型化新想法。EMR 还允许我们通过并行编程解决大型问题,因为我们可以根据需要获取所需的所有资源,无论是几十台还是几千台机器(图 1.10)。

图 1.10. EMR 允许我们以更低的成本运行小型并行作业,同时在我们需要运行大型作业时进行扩展。

摘要

  • 我们可以使用映射和减少风格的编程来解决本地机器或分布式云环境中的问题。

  • 并行编程通过在不同的处理器或不同的机器上同时运行许多操作来帮助我们加快程序的速度。

  • map 函数执行一对一的转换,是转换数据使其更适合使用的绝佳方式。

  • reduce 函数执行一对一到多一的转换,是组装数据到最终结果的绝佳方式。

  • 分布式计算允许我们在拥有足够计算机的情况下快速解决问题。

  • 我们可以通过多种方式执行分布式计算,包括使用 Apache Hadoop 和 Apache Spark 库。

  • AWS 是一个云计算环境,它使得进行大规模并行工作变得既容易又经济高效。

第二章. 加速大数据集工作:map 和并行计算

本章涵盖

  • 使用 map 转换大量数据

  • 使用并行编程转换大量数据

  • 使用 map 并行从网络抓取数据

在本章中,我们将探讨 map 及其如何用于并行编程,并将这些概念应用于完成两个网络抓取练*。使用 map,我们将关注三个主要功能:

  1. 我们可以使用它来替换 for 循环。

  2. 我们可以使用它来转换数据。

  3. map 只在必要时进行评估,而不是在调用时。

这些关于 map 的核心思想也是它在并行编程中对我们如此有用的原因。在并行编程中,我们使用多个处理单元对任务进行部分工作,并在稍后结合这些工作。将大量数据从一种类型转换为另一种类型是一项容易分解成片段的任务,并且执行这些操作的指令通常很容易转移。使用 map 使代码并行化可以简单到在程序中添加四行代码。

2.1. map 简介

在 第一章 中,我们简要地讨论了 map,这是一个用于转换数据序列的函数。具体来说,我们查看了一个将数学函数 n+7 应用到整数列表 [–1,0,1,2] 上的例子。我们还查看了一个图形 图 2.1,它显示了一系列数字被映射到它们的输出。

图 2.1. map 函数将另一个函数应用于序列中的所有值,并返回它们输出的序列:将 [–1, 0, 1, 2] 转换为 [6, 7, 8, 9]。

此图显示了 map 的本质。我们有一个长度为一些的输入,在这种情况下是四个,以及相同长度的输出。每个输入都通过与其他所有输入相同的函数进行转换。这些转换后的输入随后作为我们的输出返回。

需要一些 Python 知识

在我们处理大数据集的过程中,这本书将涵盖一些高级主题。话虽如此,在这本书的第一部分(第一章到第六章),我的一个目标是为所有读者提供可能缺失的编程教育背景知识。根据你的经验,你可能已经熟悉了一些概念,比如正则表达式、类和方法、高阶函数以及匿名函数。如果不熟悉,到第六章结束时你将熟悉它们。

到这一部分结束时,我的目标是让你准备好学*关于分布式计算框架和大数据集处理。如果你在任何时候觉得你需要更多关于 Python 的背景知识,我推荐 Naomi Ceder 的《快速 Python 书》(Manning,2018;mng.bz/vl11)。

这听起来不错,但对我们大多数人来说,并不关心中学数学问题,比如应用简单的代数变换。让我们看看map在实际应用中有哪些用法,这样我们才能真正看到它的力量。

情景

你想为你的销售团队生成一个电话列表,但你的客户注册表的原开发人员忘记在表单中构建数据验证检查。结果,所有的电话号码格式都不一样。例如,有些格式很好看——(123) 456-7890;有些只是数字——1234567890;有些使用点作为分隔符——123.456.7890;还有一些,试图提供帮助,包括国家代码——+1 123 456-7890。

首先,让我们用你可能已经熟悉的方式解决这个问题:for循环。我们将在列表 2.1 中这样做。在这里,我们首先创建一个匹配所有数字的正则表达式,并编译它。然后,我们遍历每个电话号码,并使用正则表达式的.findall方法从这个号码中提取数字。从那里,我们从右向左数数字。我们将最右边的四个数字作为最后四个,接下来的三个数字作为第一个三个,再接下来的三个数字作为区号。我们假设任何其他数字都将是国家代码(美国为+1)。我们将所有这些存储在变量中,然后我们使用 Python 的字符串格式化将它们附加到一个列表中,以存储我们的结果:new_numbers

列表 2.1. 使用for循环格式化电话号码
import re

phone_numbers = [
    "(123) 456-7890",
    "1234567890",
    "123.456.7890",
    "+1 123 456-7890"
]

new_numbers = []

R = re.compile(r"\d")                         *1*

for number in phone_numbers:                  *2*
  digits = R.findall(number)

  area_code = "".join(digits[-10:-7])         *3*
  first_3 = "".join(digits[-7:-4])
  last_4 = "".join(digits[-4:len(digits)])

  pretty_format = "({}) {}-{}".format(area_code,first_3,last_4)
  new_numbers.append(correct_format)          *4*
  • 1 编译我们的正则表达式

  • 2 遍历所有电话号码

  • 3 将数字收集到变量中

  • 4 以正确的格式附加数字

我们如何使用map来解决这个问题?同样,但使用map,我们必须将这个问题分成两部分。让我们这样分开:

  1. 解决电话号码的格式问题

  2. 将该解决方案应用于我们所有的电话号码

首先,我们将处理电话号码的格式化。为了做到这一点,让我们创建一个包含一个方法的小类,该方法可以找到字符串的最后 10 个数字,并以美观的格式返回它们。这个类将编译一个正则表达式来查找所有的数字。然后我们可以使用最后七个数字来以我们期望的格式打印电话号码。如果有超过七个,我们将忽略国家代码。

注意

我们在这里使用类(而不是函数)的原因是因为它将允许我们一次性编译正则表达式,但多次使用它。从长远来看,这将节省我们的计算机重复编译正则表达式的努力。

我们将创建一个 .pretty_format 方法,该方法期望一个格式错误的电话号码(一个字符串),并使用编译后的正则表达式来查找所有的数字。然后,就像我们在前面的例子中所做的那样,我们将匹配位置 –10、–9 和 –8,使用切片语法,并将它们分配给一个名为 area code 的变量。这些数字应该是我们的区号。我们将匹配位置 –7、–6 和 –5 并将它们分配为电话号码的前三位数字。然后我们将电话号码的最后四位数字取出来。同样,任何在 –10 之前出现的数字将被忽略。这些将是国家代码。最后,我们将使用 Python 的字符串格式化来以我们期望的格式打印数字。这个类可能看起来像以下列表所示。

列表 2.2. 使用 map 重新格式化电话号码的类
import re

class PhoneFormatter:                              *1*
  def __init__(self):                              *2*
    self.r = re.compile(r"\d")

  def pretty_format(self, phone_number):           *3*
    phone_numbers = self.r.findall(phone_number)
    area_code = "".join(phone_numbers[-10:-7])     *4*
    first_3 = "".join(phone_numbers[-7:-4])
    last_4 = "".join(phone_numbers[-4:len(phone_numbers)])
    return "({}) {}-{}".format(area_code,          *5*
                               first_3,            *5*
                               last_4)             *5*
  • 1 创建一个类来保存我们的编译后的正则表达式

  • 2 创建一个初始化方法来编译正则表达式

  • 3 创建一个格式化方法来进行格式化

  • 4 从电话号码字符串中收集数字

  • 5 返回所需的“美观”格式的数字

现在我们能够将任何格式的电话号码转换成美观的格式,我们可以将我们的类与 map 结合起来,将其应用于任意长度的电话号码列表。为了结合这两个功能,我们将实例化我们的类,并将方法作为 map 将其应用于序列中所有元素的功能传递。我们可以像以下列表所示那样做。

列表 2.3. 将 .pretty_format 方法应用于电话号码
phone_numbers = [                                   *1*
  "(123) 456-7890",
  "1234567890",
  "123.456.7890",
  "+1 123 456-7890"
]

P = PhoneFormatter ()                               *2*
print(list(map(P.pretty_format, phone_numbers)))    *3*
  • 1 初始化测试数据以验证我们的函数

  • 2 初始化我们的类,以便我们可以使用其方法

  • 3 将 .pretty_format 方法映射到电话号码并打印结果

你会注意到在最底部,我们在打印之前将 map 的结果转换为 list。如果我们打算在代码中使用它们,我们就不需要这样做;然而,因为 map 是惰性的,如果我们不将它们转换为 list 就打印它们,我们只会看到一个通用的 map 对象作为输出。这并不像我们期望的格式良好的电话号码那样令人满意。

你还会注意到这个例子中的另一件事是我们完美地设置了利用map的优势,因为我们正在进行 1 对 1 的转换。也就是说,我们正在转换序列中的每个元素。本质上,我们已经把这个问题转化成了我们中学的代数例子:将n+7应用到数字列表上。

在图 2.2 中,我们可以看到两个问题的相似之处。对于每个问题,我们都在做三件事:获取一系列数据,用某个函数对其进行转换,并获取输出。这两个问题之间的唯一区别是数据类型(整数与电话号码字符串)和转换(简单的算术与正则表达式模式匹配和格式化打印)。

图 2.2。我们可以使用map通过应用清洗函数到所有这些来清理文本字符串到一个通用格式。

map的关键在于识别可以应用这种三步模式的情况。一旦我们开始寻找,我们就会到处看到它。让我们看看这个模式的另一个版本,一个更复杂版本:网络爬虫。

场景

在 2000 年代初,你公司的竞争对手可能在他们的博客上发布了一些关于他们顶级机密公式的信息。你可以通过包含发布日期的 URL 访问他们所有的博客文章(例如,arch-rival-business.com/blog/01-01-2001)。设计一个脚本,可以检索 2001 年 1 月 1 日至 2010 年 12 月 31 日之间发布的每个网页的内容。

让我们思考一下我们如何从我们的竞争对手的博客中获取数据。我们将从 URL 中检索数据。这些 URL,然后,可以成为我们的输入数据。转换将把这些 URL 转换成网页内容。这样思考问题,我们可以看到它与我们在本章中使用map的其他问题非常相似。

图 2.3 展示了与之前我们用map解决的相同格式的难题。在顶部,我们可以看到输入数据。然而,这里我们不会使用电话号码或整数,而是使用 URL。在底部,我们再次看到我们的输出数据。这就是我们最终会得到 HTML 的地方。在中间,我们有一个函数,它将接受每个 URL 并返回 HTML。

图 2.3。我们还可以使用map来检索与一系列 URL 对应的 HTML,一旦我们编写了一个可以针对单个 URL 执行此操作的函数。

2.1.1. 使用map检索 URL

针对这个问题,我们知道我们可以用 map 解决它。那么问题来了:我们如何获取所有这些 URL 的列表?Python 有一个方便的 datetime 库来解决这个问题。在这里,我们创建了一个生成器函数,它接受 (YYYY,MM,DD) 格式的开始和结束日期 tuple,并生成它们之间的日期列表。我们使用生成器而不是常规循环,因为这可以防止我们提前在内存中存储所有数字。以下列表中的 yield 关键字将此区分为一个生成器,而不是使用 return 的传统函数。

列表 2.4. 生成日期范围的函数
from datetime import date                                     *1*

def days_between(start, stop):                                *2*
  today = date(*start)                                        *3*
  stop = date(*stop)
  while today < stop:                                         *4*
   datestr = today.strftime("%m-%d-%Y")
   yield http://jtwolohan.com/arch-rival-blog/"+ datestr      *5*
    today = date.fromordinal(today.toordinal()+1)             *6*
  • 1 导入 datetime 库的 date 类

  • 2 创建我们的生成器函数

  • 3 解包日期开始和结束元组以将它们存储为日期

  • 4 遍历所有日期,直到达到我们的截止日期

  • 5 返回日期作为路径

  • 6 将日期增加一天

利用 datetime

这个函数所做的绝大部分工作都来自于 Python 的 datetime 库的 date 类。datetime date 类表示一个日期,并包含关于格里高利历的知识以及一些用于处理日期的便利方法。你会注意到我们直接将 date 类导入为 date。在我们的函数中,我们创建了两个这样的类:一个用于我们的开始日期,一个用于我们的结束日期。然后,我们让我们的函数生成新的日期,直到我们达到截止日期。

我们函数的最后一行使用了序数日期表示法,即从公元 1 年 1 月 1 日起的天数。通过增加这个值并将其转换为 date 类,我们可以增加我们的日期一天。因为我们的 date 类是日历感知的,它会自动通过周、月和年。它甚至还会考虑闰年。

最后,值得看看我们的 yield 语句所在的行。这是我们输出 URL 的地方。我们取网站的基准 URL——jtwolohan.com/arch-rival-blog/——并将日期格式化为 MM-DD-YYYY 字符串附加到末尾,就像我们的问题所指定的那样。date 类的 .strftime 方法允许我们使用日期格式化语言将日期转换为所需的字符串格式。

将输入转换为输出

一旦我们有了输入数据,下一步就是编写一个函数,将我们的输入数据转换为输出数据。这里的输出数据将是 URL 的网页内容。幸运的是,Python 在其 urllib.request 库中提供了一些有用的工具。利用这一点,以下这样的函数可能对我们适用:

from urllib import request

def get_url(path):
  return request.urlopen(path).read()

这个函数接受一个 URL,并返回该 URL 上找到的 HTML。我们依赖于 Python 的 request 库的urlopen函数来检索 URL 上的数据。这些数据以HTTPResponse对象的形式返回给我们,但我们可以使用它的.read方法将 HTML 作为字符串返回。值得尝试在你的 REPL 环境中使用你经常访问的网站(如www.manning.com)的 URL 来查看这个函数的实际效果。

然后,就像在先前的场景中一样,我们可以使用map将这个函数应用于我们序列中的所有数据,如下所示:

blog_posts = map(get_url,days_between((2000,1,1),(2011,1,1)))

这一行代码将我们的get_url函数应用于由我们的days_between函数生成的每个 URL。将起始日期和结束日期((2000 年 1 月 1 日)和(2011 年 1 月 1 日))传递给days_between函数,结果生成 2000 年 1 月 1 日和 2011 年 1 月 1 日之间的日期生成器:21 世纪第一个十年的每一天。该函数返回的值存储在变量blog_posts中。

如果你在本地的机器上运行这个程序,程序应该几乎立即完成。这是怎么可能的?当然,我们不可能那么快地抓取 10 年的网页,对吧?嗯,不。但是,有了我们的生成器函数和map,我们实际上并没有尝试这样做。

2.1.2. 惰性函数(如 map)对大数据集的强大功能

map是我们所说的惰性函数。这意味着当我们调用它时,它实际上并不进行评估。相反,当我们调用map时,Python 会存储评估函数的指令,并在我们要求值的确切时刻运行它们。这就是为什么当我们之前想要查看map语句的值时,我们明确地将map转换为列表;Python 中的列表需要实际的物体,而不是生成这些物体的指令。

如果我们回顾一下map的第一个例子——将n+7映射到数字列表:[–1,0,1,2]——我们使用了图 2.4 来描述map

图 2.4。我们最初认为map是一种将输入序列转换为输出序列的东西。

图 2.4

然而,将map想象成图 2.5 中的样子会更准确一些。

图 2.5。在 Python 中,基本的map将输入序列转换为计算输出序列的指令——而不是序列本身。

图 2.5

在图 2.5 中,我们有相同的输入值在顶部,以及我们应用于所有这些值的相同函数;然而,我们的输出已经改变。之前我们有 6、7、8 和 9,现在我们有指令。如果我们让计算机评估这些指令,结果将是 6、7、8 和 9。在我们的程序中,我们通常会认为这两个输出是相等的。然而,作为程序员,我们需要记住,这里有一个细微的差别:Python 中的默认map在调用时并不进行评估,它为后续评估创建指令。

作为 Python 程序员,你可能已经见过懒数据。在 Python 中找到懒对象的一个常见地方是range函数。当从 Python2 迁移到 Python3 时,Python 团队决定使range变为懒加载,这样 Python 程序员(你和我)就可以创建巨大的范围,而无需做两件事:

  1. 花时间生成大量数字列表

  2. 当我们可能只需要少数几个值时,将这些值全部存储在内存中

这些好处对map也是相同的。我们喜欢懒map,因为它允许我们在不必要大量内存或花费时间生成它的情况下转换大量数据。这正是我们想要的方式。

2.2. 并行处理

太好了,现在我们有了使用map从互联网获取所有数据的方法。但是,使用map逐页离线获取数据将会非常慢。如果我们爬取一个网页需要 1 秒钟,而我们需要爬取 3,652 个网页,那么下载所有数据(3,652 页 × 每页 1 秒/每分钟 60 秒 = 61 分钟)将需要超过一个小时。这并不是一个特别长的等待时间,但足够长,以至于我们想要避免它,如果我们能的话。我们可以做到。

我们如何避免这种等待?好吧,如果我们一次下载多个页面而不是一次下载一个页面会怎样?使用并行编程,我们就可以做到这一点。

并行编程意味着以这种方式编程,我们将问题分解成可以分别和同时处理的块。通常,我们想要对每个块执行的工作将是相同的。例如,在我们的案例中,我们想要处理每个 URL(一个独立的数据块,与其他任何 URL 无关)并检索该 URL 上的网站(一个常见的过程)。

图 2.6 显示了使用标准线性处理和并行处理下载 URL 之间的差异。

图 2.6:一次读取一个网页很慢;我们可以通过并行编程来加快这个速度。

图片

使用线性处理,我们会逐个处理 URL 并将它们转换为网页。我们会处理一个 URL,获取数据,然后处理下一个 URL。并行编程允许我们将这个任务分割并更快地处理。当我们编写并行代码时,我们会为任务分配一定数量的“工作者”(通常是 CPU)。然后,每个工作者都会处理我们数据的一部分。

在图 2.6 中,数据是相同的,数据转换也是相同的。我们设置中唯一的变化是我们一次执行的任务数量。之前我们一次只做一项任务,现在我们一次做四项。这将使我们的工作速度提高四倍。

2.2.1. 处理器和处理

如果四个更好,为什么不试试八个?为什么不是十个?为什么不是一千个?嗯,这是一个非常好的问题。当人们在电脑上工作时,大多数人不会考虑的一个问题,甚至大多数程序员也不会考虑的一个问题,就是计算机硬件对计算机行为的影响。例如,大多数人会知道他们有 Mac 还是 PC;然而,除非他们的电脑上贴有 Intel 的标签,否则大多数人可能无法说出他们有什么类型的处理器。

然而,在并行编程中,这些处理器是我们的英雄。处理器是能够执行指令的小电路板,也就是说,实际上在做工作。我们常常认为我们电脑的内存是我们能做的事情的限制因素,这确实可以。但我们的 CPU 同样重要。有很多内存但处理器弱就像在自助餐队伍中只有一个盘子:当然,有很多食物,但大部分食物永远不会被吃掉。如果我们的 CPU 有多个核心,就像得到额外的盘子:每次我们去自助餐,我们就能带更多的食物回到桌子上。

就像盘子一样,CPU 越多越好。我们拥有的越多,我们就能分配给任务越多,我们就能完成更多的工作。你可以通过在你的 Python REPL 中运行以下 Python 命令来检查你的机器上有多少个 CPU:

import os
os.cpu_count()

或者,你可以从终端运行以下命令:

python3 -c "import os; print(os.cpu_count())"

这两个命令做的是同一件事。第一个部分从 Python 标准库中导入os模块,第二个部分检查你有多少个 CPU。如果你不熟悉os模块,它充满了与操作系统交互的工具。根据你使用的操作系统,一些函数的确切细节会有所不同。在使用这个模块之前熟悉这些细节是值得的。

这些命令很有用,因为它们会告诉你从标准并行编程实现中你能获得多少速度提升。当我们用 Python 并行实现代码时,默认情况下 Python 会使用我们所有的 CPU。如果我们不希望它这样做,我们就必须指定我们希望它使用更少的 CPU。

但这有点超前了。在 Python 中实现并行代码是什么样子呢?让我们回到我们的 URL 下载示例。我们想要并行抓取网页。我们需要修改我们的代码多少?下面的列表将给你一个想法。

列表 2.5. 并行网页抓取
from datetime import date                         *1*
from urllib import request

from multiprocessing import Pool

def days_between(start,stop):
  today = date(*start)
  stop = date(*stop)
  while today < stop:
    datestr = today.strftime("%m-%d-%Y")
    yield "http://jtwolohan.com/arch-rival-blog/"+datestr
    today = date.fromordinal(today.toordinal()+1)

def get_url(path):
  return request.urlopen(path).read()

with Pool() as P:                                *2*

  blog_posts = P.map(get_url,                    *3*
                     days_between((2000,1,1),    *3*
                                  (2011,1,1)))   *3*
  • 1 导入 multiprocessing 库

  • 2 使用 Pool()收集我们的处理器

  • 3 并行执行我们的 map 操作

正如我们在代码列表 2.5 中看到的那样,代码根本不需要做太多改变。因为我们一开始就使用map组织了我们的代码,所以使我们的代码并行化只需要两行新代码,并在第三行添加两个字符。如果你的机器上有四个 CPU,这个程序应该比非并行版本快大约四倍。这将把我们的假设运行时间从一小时缩短到大约 15 分钟。

这相当简单,应该是这样的。这类任务通常被轻蔑地称为令人尴尬的并行。换句话说,加快这些任务的解决方案简单得令人尴尬。尽管如此,在并行编程过程中可能会出现一些问题。

在使用 Python 进行并行化工作时,我们可能会遇到一些问题

  • 无法 pickle 数据或函数,导致我们的程序无法运行

  • 有序操作返回不一致的结果

  • 状态相关操作返回不一致的结果

2.2.2. 并行化和 pickle

当我们在并行编写代码时——例如,当我们之前调用我们的并行map函数时——Python 在幕后做了很多工作。当我们的并行化不起作用时,通常是因为我们没有完全思考 Python 隐藏给我们的工作。Python 隐藏给我们的东西之一就是pickle

Pickle 是 Python 的对象序列化或打包的版本,对象序列化是将我们的代码中的对象以高效的二进制格式存储在磁盘上,以便我们的程序在以后的时间读取。术语pickle来自 Python 的pickle模块,该模块提供了 pickle 数据和读取 pickle 数据的函数。

Pickle 和 unpickle 的过程看起来就像图 2.7 所示。

图 2.7. Pickle 允许我们将数据和指令以机器可读的状态保存,这样 Python 可以在以后重用它。

在左侧,我们以原始编程环境和原始代码对象开始这个过程。此时并没有什么特别的事情发生;我们只是在 Python 中像往常一样编程。接下来,我们将代码对象 pickle。现在,我们的代码对象已保存在磁盘上的二进制文件中。接下来,我们从新的编程环境读取 pickle 文件,我们的原始代码对象在新环境中对我们变得可访问。在第一个环境中 pickle 的所有内容现在在新环境中以同样的方式对我们可用。

注意

我们的代码可以以我们想要的任何格式保存。在并行编程中,我们通常会将文件快速读回到 Python 环境中,但也没有理由我们不能将 pickle 对象保留在磁盘上更长时间。然而,将数据 pickle 以进行长期存储并不是一个好主意,因为如果你升级了 Python 版本,数据可能变得不可读。

为什么我们在并行编程中使用 pickle?记得我们讨论过并行编程允许我们的程序同时做很多事情吗?Python 通过 pickle 对象(函数和数据)将工作传输到将处理我们的问题的每个处理器。这个过程看起来像图 2.8。

图 2.8。pickle 允许我们在处理器之间或甚至在不同机器之间共享数据,保存指令和数据,然后在其他地方执行它们。

图片

我们从只在单个处理器上运行的代码开始;这是编码的标准方式。为了使我们的代码并行工作,Python 然后将我们的问题分解成可以由单个处理单元解决的各个部分。主工作流将这些部分 pickle。这种 pickle 确保处理器将知道如何执行我们需要它执行的工作。当处理单元准备好执行工作的时候,它从磁盘读取 pickle 文件并执行工作。然后,最后,工作进程 pickle 结果并将其返回给主进程。

大多数情况下,这种方法工作得很好;然而,只有某些类型的 Python 对象可以被 pickle。如果我们尝试在无法 pickle 的对象上使用并行方法,Python 将抛出错误。幸运的是,大多数标准 Python 对象都是 pickleable 的,因此可以在并行 Python 代码中使用。Python 可以自然 pickle 以下类型:

  • None、True 和 False

  • 整数、浮点数、复数

  • 字符串、字节、字节数组

  • 只包含可 pickle 对象的元组、列表、集合和字典

  • 在模块顶层定义的函数

  • 在模块顶层定义的内建函数

  • 在模块顶层定义的类

我们不能 pickle 以下类型的对象:

  • Lambda 函数

  • 嵌套函数

  • 嵌套类

避免与 Python 内置的 multiprocessing 模块一起使用不可 pickle 的类型的最简单方法是避免使用它们。对于我们绝对必须使用它们的情况,一个名为pathos的社区库通过一个名为dill的模块解决了许多这些问题(明白了吗?Dill pickles?)。dill模块采用不同的 pickle 方法,允许我们 pickle 几乎所有我们想要的东西,包括我们之前无法 pickle 的三个对象类型。

使用 pathos 和dill与使用 multiprocessing 模块没有太大区别。我们首先要做的是安装库。从命令行运行

pip3 install pathos

除了安装 pathos,Python 还会安装 pathos 依赖的一些库,包括dill。安装了 pathos 后,我们现在可以调用它,它将在幕后使用dill。如果你还记得我们的 multiprocessing 示例,它看起来像这样:

from multiprocessing import Pool

# ... other code here ...

with Pool() as P:
  blog_posts = P.map(get_url,days_between((2000,1,1),(2011,1,1)))

要将其转换为 pathos,我们只需做一些更改。我们的新代码将看起来像这样:

from pathos.multiprocessing import ProcessPool

# ... other code here ...

with ProcessPool(nodes=4) as P:
  blog_posts = P.map(get_url,days_between((2000,1,1),(2011,1,1)))

从多进程迁移到 pathos 只需要两个真正的变化。首先,我们必须从 pathos 而不是从 multiprocessing 导入。此外,在 pathos 中,我们想要的池被称作 ProcessPool 而不是仅仅 Pool。就像 Pool 一样,ProcessPool 是为我们招募工作处理单元的函数。我们需要用 ProcessPool 代替 Pool。与 Pool 一样,我们只需要指定节点数,如果我们想使用少于最大节点数。我们在这里指定它是为了演示目的。现在我们的 ProcessPool 可以作为 P 访问,我们可以像调用我们的 multiprocessing.Pool 对象一样调用它:P.map

2.2.3. 排序和并行化

另一个在我们并行工作时可能引起我们问题的条件是顺序敏感性。当我们并行工作时,我们不能保证任务将以它们输入的相同顺序完成。这意味着,如果我们正在做需要按线性顺序处理的工作,我们可能不应该并行执行。

要亲自测试这一点,请尝试在 Python 中运行此命令:

with Pool() as P:
  P.map(print,range(100))

如果我们用 for 循环来做这件事,我们期望在屏幕上打印出从 0 到 99 的每个数字的有序列表。然而,使用 map 构造时,我们并没有得到这个结果。使用 map,我们在屏幕上得到的是一个部分有序、部分不匹配的序列,以及一个 None 的列表。这是怎么回事?

当 Python 并行化我们的代码时,它会将问题分成几块,以便我们的处理单元进行处理。从它们的角度来看,每次它们有处理问题的能力时,都会抓取第一个可用的块。然后它们处理这个问题,直到完成,然后抓取下一个可用的块来处理。当块按顺序不可用时,它们将按顺序完成。我们可以将这个过程可视化,如图 2.9 所示。

图 2.9. 并行处理不一定按顺序完成任务,所以在使用并行技术之前,我们必须知道这是否可以接受。

02fig09_alt.jpg

在图 2.9 中,我们的问题从顶部开始。我们将问题分成 10 块,并将它们放入队列中。当处理器可用时,它们将从队列中拉取一个任务,处理它,并将结果发送到底部的已完成任务区域。然后处理器抓取下一个可用的任务并处理它们,直到所有任务都完成。但是这些操作完成所需的时间是不同的。例如,在已完成任务区域,我们可以看到任务 1、2、3 和 5 已经完成,而任务 4、6、7 和 8 正在处理中。任务 9 和 10 仍然在队列中,尚未分配给处理器。如果任务 1(或 2 或 3)和 5 很短,但任务 4 很长,以至于两个任务可以在单个任务 4 完成的时间内完成,这种情况很容易发生。

尽管 Python 可能不会按顺序完成问题,但它仍然记得它应该按什么顺序完成。确实,我们的map返回的顺序正是我们预期的,即使它不是按那个顺序处理的。为了证明这一点,我们可以运行以下代码:

def print_and_return(x):
  print(x); return x

with Pool() as P:
  P.map(print_and_return, range(20))

打印的输出将不会排序,但返回的列表将会排序。打印的输出显示了处理块的工作顺序;列表输出显示了返回的数据结构。我们可以看到,尽管 Python 以“错误”的顺序处理问题,但它仍然正确地排序了结果。什么时候这会给我们带来问题呢?嗯,如果我们依赖于状态的话。

2.2.4. 状态和并行化

在面向对象的编程中,我们经常编写依赖于类状态的函数。考虑“砰”/“蜂鸣”问题。这是一个常用于介绍编程语言语法的例子。它涉及遍历数字,如果数字不能被三(或五,或其他数字)整除,则返回“砰”,如果可以,则返回“蜂鸣”。预期的输出是在适当间隔的“砰”和“蜂鸣”序列。

在 Python 中,我们可以使用类来解决“砰”/“蜂鸣”问题,如下面的列表所示。

列表 2.6. 使用for循环的经典“砰”/“蜂鸣”问题
class FizzBuzzer:
  def __init__(self):
    self.n = 0               *1*
  def foo(self,_):           *2*
    self.n += 1              *3*
    if (self.n % 3)  == 0:   *4*
      x = "buzz"             *4*
    else: x = "fizz"         *5*
    print(x)                 *6*
    return x                 *6*

FB = FizzBuzzer()            *7*
for i in range(21):          *7*
  FB.foo(i)                  *7*
  • 1 计数器从 0 开始。

  • 2 决定“砰”声和“蜂鸣声”的 foo 函数

  • 3 每次函数运行时增加计数器

  • 4 如果计数器能被三整除,则发出蜂鸣声。

  • 5 如果不是,则发出“砰”声。

  • 6 两者都打印语句并返回它

  • 7 测试类是否正常工作

该类关注我们调用其.foo方法多少次,并且每第三次它会打印并返回“蜂鸣”而不是“砰”,而不是“砰”。我们使用它在一个循环中来演示它是否正常工作。如果你在自己的机器上运行这个程序,你会看到这就像我们预期的那样工作:我们打印出“砰”声,每隔第三个位置插入一个“蜂鸣”声。然而,当我们尝试使用并行的map做同样的事情时,会发生一些奇怪的事情:

FB = fizz_buzzer()
With Pool() as P:
  P.map(FB.foo, range(21))

这里发生了什么?为什么我们只听到了“砰”声而没有蜂鸣声?让我们回到之前讨论map时提到的事情。记得我们说过map实际上并不执行计算,它只是存储计算指令吗?这就是我们称之为“惰性”的原因。在这个例子中,执行FB.foo计算指令包括我们在请求它时FB的状态。所以,由于在请求指令时FB.n为 0,map对所有操作都使用FB.n = 0,即使在我们使用它时FB.n发生了变化。由于FB.n = 0 总是产生“砰”声,所以我们只听到了“砰”声。

我们可以通过将FB.n改为 2 来测试它,这将始终产生“蜂鸣”,并运行相同的命令。这看起来可能像这样:

FB = FizzBuzzer()
FB.n = 2
with Pool() as P:
  P.map(FB.foo, range(21))

在这里,正如我们所期望的,当FB.n = 2时,我们存储了FB.foo的指令,结果是得到了所有的 buzz 而没有 fizz。

我们能做什么呢?通常,类似这种情况需要我们重新思考问题。一个常见的解决方案是将内部状态变成外部变量。例如,我们可以使用range生成的数字而不是FB存储的内部值。然后我们也可以将类替换为一个简单的函数,如下所示:

  def foo(n):
    if (n % 3) == 0:
      x = "buzz"
    else: x = "fizz"
    print(x)
    return x

这个函数完全做了.foo方法所做的事情,但它依赖于外部变量n的值,而不是内部状态self.n。然后我们可以将这个函数应用到由range生成的数字上,并通过并行map得到我们预期的结果。

with Pool() as P:
  print(P.map(foo, range(1,22)))

当你运行这个程序时,请注意打印的值不会按正确的顺序返回。这是因为,正如我们在上一节中提到的,处理器正在从堆栈中抓取第一个可用的任务并尽可能快地完成它。有时,fizz 任务会比 buzz 慢,并且会连续打印两个 buzz。有时,buzz 会花费更长的时间,我们会看到三或更多的 fizz 连续出现。然而,最终的数据将会按正确的顺序排列:fizz, fizz, buzz ... fizz, fizz, buzz ... fizz, fizz, buzz。

值得花点时间看看这会如何在视觉上呈现。我们首先看看当我们尝试使用状态进行并行化时会发生什么,然后看看没有它会发生什么。首先,让我们回顾一下图 2.8(如图 2.10 中重复所示)。

图 2.10。当我们使用并行map来序列化和分发工作时,我们也会序列化状态信息。这允许我们并行执行工作,但可能会产生意外的结果。

02fig10_alt.jpg

这张图展示了我们在进行并行计算时发生的情况:首先,我们将任务分割成块——在这个例子中是四块——然后将这些块以序列化的格式保存到磁盘上。然后我们的处理器获取它们并处理,直到全部完成。关于状态,我们需要最关注的是这个第二步——序列化数据。

在第一步中,map为问题的每个部分提供指令。这一步类似于我们的并行化步骤,我们将问题分割成块。记住,map不会立即完成问题的任务,它会写入指令并在稍后执行——它是懒惰的。第二步,我们将指令保存到磁盘上。map已经捕获了这些指令,所以很容易做到。然而,请注意,我们正在保存问题的指令。这意味着我们需要的任何状态都将被存储,例如当我们存储FB.n时。最后一步,我们的处理器读取指令并执行它们。

2.3。整合一切:抓取维基百科网络

在本章中,我们介绍了很多强大的内容。为了总结所有内容,让我们考虑一个最终的场景,即创建一个网络图,例如图 2.11 中的那种。

图 2.11。网络图是由边连接的节点序列,常用于显示对象之间的关系,例如人与人之间的友谊、系统之间的通信或城市之间的道路。

场景

我们希望从维基百科创建一个主题网络图。也就是说,我们希望能够输入一个术语(例如:并行计算)并找到该页面直接的网络中的所有维基百科页面——链接到该页面或该页面链接到的页面。结果将是我们网络中所有页面的图。

让我们先思考当前的问题,并绘制出一个解决方案。维基百科有一个很好的 API 用于获取维基百科页面的数据,因此我们想要使用它。我们知道我们将从一个单独的页面开始,因此我们想要将这个页面作为我们网络的起点。从这个页面开始,我们想要获取所有入链和出链,这些将成为我们图中的其他节点。然后,对于每个其他节点,我们想要获取与这些节点相关联的节点。

我们可以将这个任务进一步分解成一个待办事项列表:

  1. 编写一个函数来获取维基百科页面的入链和出链。

  2. 从我们的初始页面获取入链和出链。

  3. 将这些页面整理成一个长长的列表。

  4. 从所有这些页面获取入链和出链。

  5. 我们将并行进行,以加快速度。

  6. 将所有链接表示为页面之间的边。

  7. 奖励:使用图形库(如 networkx)来显示图形。

首先,让我们编写一个函数来获取基于维基百科页面标题的入链和出链。我们将首先从urllib模块导入JSON模块和requests类。你可能会记得urllib:这个模块帮助我们从互联网获取数据,这正是我们想要在维基百科页面中做的事情。JSON模块是一个用于解析 JSON 格式数据的模块。它将 JSON 数据读取为原生 Python 类型。我们将使用它将维基百科提供的数据转换为易于管理的格式。

接下来,我们将创建一个小的函数来帮助我们将维基百科页面链接转换为这些页面的标题。维基百科自然将这些链接打包成 JSON 对象——我们只需要标题字符串。

最后,我们到达了创建从维基百科获取信息的实际函数。我们的函数get_wiki_links期望一个页面标题,并将其转换为包含入链和出链的dict。这个dict将允许我们稍后轻松访问这些链接。

在这个函数中,我们首先创建查询的 URL。如果你对 URL 的来源感兴趣,维基百科在网上有很好的 API 文档;然而,我将在下面解释相关部分。/w/api.php告诉维基百科我们想要使用其 API 而不是请求标准网页。action=query告诉维基百科我们将执行查询操作。查询是维基百科提供的许多操作之一。它专门用于获取有关页面的元数据,例如哪些页面链接到给定页面以及哪些页面被链接到。

prop=links|linkshere告诉维基百科 API 我们感兴趣的属性是页面的链接以及哪些页面链接到该页面。pllimitlhlimit告诉 API 我们希望获取最多 500 个结果。这是在不注册为机器人的情况下我们可以获取的最大结果数。title参数是我们放置我们想要页面的标题的地方,format参数定义了返回给我们的数据应该如何格式化。为了方便起见,我们将选择 JSON 格式。

接下来,设置好我们的 URL 后,我们可以将其传递给request.urlopen,它使用get请求打开 URL。维基百科将我们的请求发送到其 API,并返回我们请求的信息。我们可以使用.read方法将此信息读入内存,我们确实这样做了。由于我们要求维基百科以 JSON 格式返回此信息,因此我们可以使用json.reads读取 JSON 字符串,它将 JSON 字符串转换为 Python 对象。生成的对象j是一个dict,它表示维基百科返回的 JSON 对象。

现在,我们可以遍历这些对象,并从中提取链接,这些链接将在page['query']['pages'][0"]["links"]page['query']['pages'][0"]["linkshere"]中达到四个层级。前一个对象包含指向我们当前页面的页面,后一个对象包含链接到我们当前页面的页面。维基百科 API 定义了这种结构,这是我们了解如何找到所需数据的方式。正如我们之前所提到的,这些对象——linkslinkshere——不是页面标题,而是 JSON 对象,其中标题作为一个元素。为了只获取标题,我们将使用我们的link_to_title函数。由于我们将有多个链接,并且这些链接将在一个列表中,我们将使用map将所有对象转换为它们的标题。

最后,我们将这些对象作为dict返回。整体来看,如下所示。

列表 2.7. 从标题检索维基百科页面网络的函数
import json                                                  *1*
from urllib import request, parse

def link_to_title(link):                                     *2*
  return link["title"]

def clean_if_key(page,key):                                  *3*
    if key in page.keys():
        return map(link_to_title,page[key])
    else: return []

def get_wiki_links(pageTitle):                               *4*
    safe_title = parse.quote(pageTitle)                      *5*
    url = "https://en.wikipedia.org/w/api.php?action=query&
prop=links|linkshere&pllimit=500&lhlimit=500&titles={}&
format=json&formatversion=2".format(safe_title)
    page = request.urlopen(url).read()                       *6*
    j = json.loads(page)                                     *7*
    jpage = j["query"]["pages"][0]
    inbound = clean_if_key(jpage,"links")                    *8*
    outbound = clean_if_key(jpage,"linkshere")
    return {"title": pageTitle,                              *9*
            "in-links":list(inbound),
            "out-links":list(outbound)}
  • 1 导入我们需要的库

  • 2 创建一个辅助函数,用于从链接结果中获取标题

  • 3 创建一个辅助函数,用于获取找到的链接的标题(如果存在的话

  • 4 定义我们的 get_wiki_links 函数

  • 5 引用标题以确保它是 URL 安全的

  • 6 向 URL 发送 HTTP 请求并读取响应

  • 7 将响应解析为 JSON

  • 8 如果存在,清理传入和传出的链接

  • 9 返回页面的标题及其入链和出链

到目前为止,我们已经完成了待办事项列表中的第 1 项,并为我们处理第 2 项和第 3 项奠定了良好的基础。现在我们可以通过编写一个小函数并创建脚本中的“仅在执行时”部分来实现这一点。让我们现在就做吧。

接下来,这是一个简单的函数,它将页面的入链和出链展平成一个大的列表:

def flatten_network(page):
    return page["in-links"]+page["out-links"]

这里是我们代码的一部分,它将在我们用 Python3 调用此脚本时运行:

if __name__ == "__main__":
  root = get_wiki_links ("Parallel_computing")
  initial_network = flatten_network(root)

if __name__ == "__main__" 告诉 Python 只有当它直接作为脚本调用时才使用此代码。之后的行表示使用我们的函数从维基百科的并行计算页面获取所有链接。最后一行将网络存储在一个变量中。

接下来,让我们使用这个列表以及我们刚刚编写的函数,来获取并行计算页面网络中的所有维基百科页面。我们将并行执行以加快速度。为此,我们需要扩展之前代码中的“仅在执行时运行”部分。我们将添加几行,使其看起来像这样:

if __name__ == "__main__":
    root = get_wiki_links ("Parallel_computing")
    initial_network = flatten_network(root)
    with Pool() as P:
        all_pages = P.map(get_wiki_links, initial_network)

我们再次调用 Pool 来召集一些处理器用于并行编程。然后我们使用这些处理器获取每个页面(无论是链接到还是被我们的根页面“并行计算”链接)的维基百科页面信息。假设我们有四个处理器,我们将在这个任务上节省四分之一的时间,如果我们逐个获取页面的信息的话。

现在,我们想要将每个页面对象表示为页面之间的边。这会是什么样子呢?这个表示将是一个 tuple,其中第一个位置的元素代表执行链接的页面,第二个位置的元素代表被链接到的页面。如果 Parallel_computing 链接到 Python,我们想要的 tuple 将是这样的:("Parallel_computing", "Python")

为了创建这些,我们需要另一个函数。这个函数将每个页面 dict 转换为这些边 tuple 的列表。

def page_to_edges(page):
    a = [(page['title'],p) for p in page['out-links']]
    b = [(p,page['title']) for p in page['in-links']]
    return a+b

这个函数遍历一个页面的网络中的每个页面,为所有出链页面创建一个 tuple 列表,形式为 (page,out-link),以及所有入链页面,形式为 (in-link,page)。然后我们将这两个列表合并并返回。

我们还需要更新代码中的脚本部分。这部分现在看起来像这样:

from multiprocessing import Pool

if __name__ == "__main__":
    root = get_wiki_links ("Parallel_computing")
    initial_network = flatten_network(root)
    with Pool() as P:
        all_pages = P.map(get_wiki_links, initial_network)
        edges = P.map(page_to_edges, all_pages)

我们添加了一行,将 page_to_edges 函数应用于我们之前函数收集的所有页面。因为我们仍然有所有这些处理器可用,所以我们可以再次使用它们来更快地完成这项任务。

我们最后想要做的是将这个边列表展平成一个大的列表。这样做最好的方式是使用 Python 的 itertools chain 函数。chain 函数接受一个可迭代的可迭代对象,并将它们连接起来,以便可以逐个访问。例如,它允许我们将 [[1,2,3],[1,2],[1,2,3]] 视为 [1,2,3,1,2,1,2,3]。

我们将在我们的edges对象上使用这个链式函数。到这一点,我们不再需要我们的处理器来进行并行化,所以我们将缩进,退出这个代码块,并让我们的处理器释放。

from itertools import chain

edges = chain.from_iterable(edges)

chain函数默认是惰性的,所以如果我们想将其打印到屏幕上,就像map一样,我们需要将其包裹在一个list调用中。如果你决定将其打印到屏幕上,不要期望看到太多。你将看到 1,000,000 个字符串-字符串tuples(网络中的每 1000 页有 1000 个tuple)。

注意

我们刚刚写了大约 50 行代码,一点一点地。当我们这样编码时,有时我们可能会错过一些导致代码出错的小细节。如果你在运行代码时遇到麻烦,请记住,你可以在网上找到这本书的源代码。如果你在调试时花费了超过几分钟的时间,请参考它:www.manning.com/downloads/1961

2.3.1. 可视化我们的图

可视化我们的图的最佳方式是将它从 Python 中移出,并导入到 Gephi 中,这是一款专门的图形可视化软件。Gephi 在社会科学中因作为出色的网络和图形可视化工具而闻名。它可以处理多种格式的数据,但更喜欢一种自定义格式,即.gefx。我们将使用一个名为networkx的 Python 库将我们的图导出为此格式。整个过程看起来可能像这样:

import networkx as nx

G = nx.DiGraph()
    for e in edges:
        G.add_edge(*e)
    nx.readwrite.gexf.write_gexf(G,"./MyGraph.gexf")

我们在这里所做的是创建一个有向图(nx.DiGraph)对象,并通过遍历我们的链式边来向其中添加边。图对象有一个.add_edge方法,它允许我们通过逐个声明其边来构建一个图。一旦完成,剩下的就是将图导出为 Gephi 格式,即.gefx。networkx 库有一个方便的函数叫做write_gefx,我们将使用它来处理我们的图对象并提供一个路径名。然后,图将以.gefx 格式保存在该路径。在我的机器上,输出文件的大小略低于 36 MB。

注意

Gephi 是一款出色的图形可视化软件;然而,这本书并不是关于图形可视化的。如果你认为你不会从可视化你的网络爬取中找到满足感,或者如果你在使用 Gephi 时感到沮丧,请随意跳过。在这本书中我们不会再使用 Gephi。

从这里,我们可以打开 Gephi,导入我们的.gefx 文件,并查看我们的图。如果你没有安装 Gephi,你可以在gephi.org找到它。Gephi 是免费软件,在开源许可下分发,并在 Windows、MacOS 和 Linux 上运行。

当你打开 Gephi 时,你可能需要调整一些设置,以便让图显示得更好看。我将把图形可视化留给你和你的创造力,因为我远非这个领域的专家。

如果你学*如何可视化超过 10 万个节点的图没有耐心,请更改我们的查询设置以检索更少的页面数量。我还会让你自己回顾代码,找出如何做到这一点。(提示:在我们的 Wikipedia API 请求中。)

当我只请求每个页面的 50 个邻居时,我最终得到大约 1,300 个节点的网络,默认情况下在 Gephi 中看起来像图 2.12。

图 2.12. 围绕并行计算的 Wikipedia 页面网络

2.3.2. 返回到映射

在我们结束本章之前,看看我们所做的工作如何适合我们一直在使用的map图是有意义的。回到map数据转换图是有用的,因为它以简单的方式使我们能够将复杂任务——网络抓取和创建实体网络——进行上下文化。

首先,让我们从整个过程的流程图(图 2.13)开始。在左侧,我们从种子文档开始。我们将get_wiki_links函数应用于此文档以获取我们网络中的所有页面:入链和出链的页面。从那里,其次,我们将get_wiki_links函数映射到所有这些页面上。这返回了扩展网络,即链接到并从我们的种子页面链接的页面。第三,我们将所有这些链接转换为边。这将数据从更隐式的数据结构转换为更明确的图定义。最后,我们使用这些边中的每一个来构建图。

图 2.13. 我们将单个种子页面转换为页面网络,分为四个步骤。

在这个过程中,我们使用了两个映射语句:一个将我们的初始网络扩展为一个扩展网络,另一个将我们的扩展网络转换为边。在第一种情况下,如图 2.13 所示,我们取自种子抓取的所有链接,抓取这些链接,并返回每个链接的网络。结果是,之前我们有一个页面列表(或者,如果你还记得数据的样子,一个包含页面标题、入链和出链的dict),我们现在有一个页面列表的列表(或者再次:一个包含这些“页面”dict的列表)。尽管中间发生了很多事情——我们 ping Wikipedia API,Wikipedia API 获取页面并返回结果,我们将结果解析为 JSON,我们通过 JSON 找到我们想要的值,我们将它们存储在dict中并返回dict——我们可以将所有这些表示为从一个对象到下一个对象的数据转换。

接下来,我们完成第三步,将我们在第二步中检索到的网络转换为我们可以用来定义有向图的边的列表。我们编写了一个 path_to_edges 函数来用于这个目的。我们所做的不那么复杂:我们正在将两个字符串列表转换为单个 tuple 列表;然而,通过 path_to_edges 函数将这一点抽象出来,允许我们在更高层次上可视化整个转换。这种高层次的理解直接对应于我们的整体过程,并突出了正在发生的事情:我们的链接网络正在被转换为边。

回顾我们刚刚编写的 Wikipedia 抓取和网络创建程序,我们可以看到使用 map 对于许多任务来说是非常自然的。事实上,每次我们将某种类型的序列转换为另一种类型的序列时,我们正在做的事情都可以表达为 map。我喜欢将这些情况称为 N-to-N 转换,因为我们正在将一些数据元素 N 转换为相同数量的数据元素,但格式不同。

就在这个最后的例子中,我们遇到了两种这些 N-to-N 情况。我们首先将 N 个链接转换为 N 个链接网络。然后我们将 N 个链接网络转换为 N 个边。在这些每种情况下,我们都使用了 map,就像我们刚才所绘制的。

我们也在这些情况下使用了并行编程来更快地完成任务。它们是并行编程的绝佳候选者,因为我们有耗时且重复的任务,我们可以用自包含的指令清晰地表达出来。我们使用并行 map 来完成这个任务。并行 map 允许我们表达我们的并行化愿望,并使用类似于我们进行非并行 map 时使用的语法。总的来说,使这个问题并行化所需的工作量仅相当于四行代码:一个导入;使用 Pool() 处理我们的处理器,并修改我们的 map 语句以使用 Pool.map 方法。

2.4. 练*

2.4.1. 并行化的问题

并行化是一种有效加快我们程序速度的方法,但可能会带来一些问题。在本章的早期,我提到了三个。你能记住多少,它们是什么?

2.4.2. map 函数

map 函数是我们在本书中处理大数据集的关键部分。哪句话最能描述 map 函数?

  • map 将数据序列转换为不同大小相同的序列。

  • map 允许我们条件性地处理数据,替换 if-else 语句。

  • map 用优化的字节码替换条件 while 循环。

2.4.3. 并行化和速度

并行化是有用的,因为它允许我们更快地处理大数据集。以下哪个解释说明了并行化是如何工作的?

  • 并行化在编译时优化我们的代码。

  • 并行化在多个计算资源上计算类似任务。

  • 并行化从我们的代码中移除重复,并减少昂贵的操作数量。

2.4.4. 序列化存储

以下哪个不是使用序列化的好用途?

  • 短期、单机存储

  • 在集群上的计算任务之间共享数据

  • 长期存储,其中数据完整性至关重要

2.4.5. 网络抓取数据

在网络抓取中,我们最常见的事情之一是将 dicts 转换为其他东西。使用 mapdict 列表转换为仅包含页面文本的内容,以下为您的输入数据:

[{"headers":(01/19/2018,Mozilla,300),
  "response":{"text":"Hello world!","encoding"0:"utf-8"}},
     {"headers":(01/19/2018,Chrome,404),
  "response":{"text":"No page found","encoding":"ascii"}},
     {"headers":(01/20/2018,Mozilla,300),
  "response":{"text":"Yet another web page.","encoding":"utf-8"}}]

您的结果列表应该是 ["Hello world!","No page found","Yet another web page."]

2.4.6. 异构映射转换

到目前为止,我们只看了如何使用 map 来转换同质列表,这些列表包含相同类型的数据。然而,没有理由我们不能使用 map 来转换异构数据的列表。编写一个函数,将 [1, "A", False] 转换为 [2,"B",True]

摘要

  • map 语句是转换数据序列(如列表或元组中的数据)到其他类型数据序列的绝佳方式。

  • 每当我们遇到一个 for 循环时,我们应该寻找将其替换为 map 的机会。

  • 因为 map 定义了转换规则,而不是执行实际的转换,所以它可以很容易地与并行技术配对,这可以让我们加快代码的执行速度。

  • 如果我们知道要抓取的 URL 序列或要调用的 API,我们可以使用 map 从维基百科或任何网页上抓取数据。

  • 因为 map 创建指令但不立即评估它们,所以它并不总是与有状态的对象很好地配合,尤其是在并行应用时。

第三章. 用于映射复杂转换的函数管道

本章涵盖

  • 使用 map 进行复杂的数据转换

  • 将小型函数链接成管道

  • 在大型数据集上并行应用这些管道

在上一章中,我们看到了如何使用 map 来替换 for 循环,以及使用 map 如何使并行计算变得简单:对 map 进行小的修改,Python 将会处理其余部分。但到目前为止,我们使用 map 时一直在处理简单的函数。即使在第二章的维基百科抓取示例中,我们最费力的函数也只是在互联网上提取文本。如果我们想使并行编程真正有用,我们就会想以更复杂的方式使用 map。本章介绍了如何使用 map 来做复杂的事情。具体来说,我们将介绍两个新概念:

  1. 辅助函数

  2. 函数链(也称为管道)

我们将通过查看两个非常不同的例子来处理这些主题。在第一个例子中,我们将解码一个恶意黑客小组的秘密信息。在第二个例子中,我们将帮助我们的公司在社交媒体粉丝中进行人口统计分析。最终,尽管如此,我们将以相同的方式解决这两个问题:通过将小的辅助函数组合成函数链。

3.1. 辅助函数和函数链

辅助函数 是一些小而简单的函数,我们依赖它们来完成复杂的事情。如果你听说过(相当粗俗的)说法,“吃大象最好的办法是一口一口吃”,那么你已经熟悉辅助函数的概念了。有了辅助函数,我们可以将大问题分解成小块,我们可以快速编码。事实上,让我们提出一个可能的格言供程序员参考:

解决复杂问题的最佳方式是一次使用一个辅助函数

J.T. Wolohan

函数链管道 是我们将辅助函数投入使用的途径。(这两个术语意思相同,不同的人可能更喜欢其中一个;我将交替使用这两个术语,以避免过度使用任何一个。)例如,如果我们正在烘焙蛋糕(对我们中烘焙挑战者来说是一个复杂的任务),我们希望将这个过程分解成许多小步骤:

  1. 加入面粉。

  2. 加入糖。

  3. 加入黄油。

  4. 混合原料。

  5. 将蛋糕放入烤箱。

  6. 从烤箱中取出蛋糕。

  7. 让蛋糕凝固。

  8. 给蛋糕抹上奶油。

这些步骤都很小,容易理解。这些将是我们的辅助函数。这些辅助函数中的任何一个单独都不能使我们从原材料变成蛋糕。我们需要将这些动作(函数)链接起来以烘焙蛋糕。另一种说法是,我们需要将原料通过我们的蛋糕制作管道传递,在这个过程中它们将被转换成蛋糕。换一种说法,让我们再次看看我们的简单 map 语句,这次是在 图 3.1 中。

图 3.1. 标准的 map 语句显示了我们可以如何将一个函数应用于多个值,以返回一个由该函数转换的值序列。

正如我们多次看到的,我们在顶部有输入值,中间有一个函数,我们将这些值传递给这个函数,底部是我们的输出值。在这种情况下,n+7 是我们的辅助函数。n+7 函数在这种情况下做工作,而不是 mapmap 将辅助函数应用于所有输入值,并为我们提供输出值,但单独来看,它对我们帮助不大。我们需要一个特定的输出,而为了达到这个目的,我们需要 n+7

值得注意的是,看看函数链,也就是一系列(相对)小的函数,我们一个接一个地应用。它们也有数学的基础。我们从数学家称为 函数组合 的规则中得到它们。

函数组合表明,像 j(x) = ((x+7)²–2)*5 这样复杂的函数与一系列较小的函数相同,这些函数连在一起,每个函数都执行复杂函数的一部分。例如,我们可能有以下四个函数:

  1. f(x) = x+7

  2. g(x) = x²

  3. h(x) = x – 2

  4. i(x) = x * 5

我们可以将它们连在一起,形成 i(h(g(f(x)))),并使其等于 j(x)。我们可以在图 3.2 中看到这一过程。

图 3.2。函数组合表明,如果我们按顺序应用一系列函数,那么这就像我们应用它们作为一个单一函数一样。

图片

在我们通过图 3.2 中的管道时,我们可以看到我们的四个辅助函数:f、g、h 和 i。我们可以看到,当我们将 x 的值 3 输入到这个函数链中时会发生什么。首先,我们对 x 应用 f,得到 10(3+7)。然后我们对 10 应用 g,得到 100(10²)。然后我们对 100 应用 h,得到 98(100–2)。最后,我们最后对 98 应用 i,得到 490(98*5)。得到的值与我们将 3 输入原始函数 j 时得到的值相同。

通过这两个简单想法——辅助函数和管道——我们可以实现复杂的结果。在本章中,你将学*如何在 Python 中实现这两个想法。正如我在章节介绍中提到的,我们将探索这两个想法在两个场景中的力量:

  1. 破解秘密代码

  2. 预测社交媒体粉丝的人口统计

3.2. 揭示黑客通信

现在我们已经熟悉了函数管道的概念,让我们通过一个场景来探索它们的威力。在这里,我们将通过分解成许多更小的任务来征服一个复杂的任务。

场景

一群恶意的黑客开始使用数字代替常见字符和汉字来分隔单词,以阻止自动尝试监视他们。为了阅读他们的通信——并找出他们在说什么——我们需要编写一些代码来撤销他们的诡计。让我们编写一个脚本,将他们的黑客语言转换为英语单词列表。

我们将像在书中解决前一个问题一样解决这个问题:从map开始。具体来说,我们将使用map的想法来设置我们正在执行的大图数据转换。为此,我们将使用图 3.3 来可视化这个问题。

图 3.3。我们可以将我们的黑客问题表达为一个map转换,其中我们以难以阅读的黑客消息作为输入。然后,在我们用hacker_translate函数清理它们之后,它们变成了普通英语文本。

图片

在顶部,我们有我们的输入值。我们可以看到,它们是一些很难阅读的黑客通信,乍一看,它们并没有太多意义。在中间,我们有我们的map语句和hacker_translate函数。这将是我们的重载函数。它将完成清理文本的工作。最后,在底部,我们有我们的输出:普通英语。

现在这个问题不是一个简单的问题;它更像是烘焙蛋糕。为了完成它,让我们将其分解为几个更小的、我们可以轻松解决的问题。例如,对于任何给定的黑客字符串,我们希望执行以下操作:

  • 将所有的 7 替换为 t。

  • 将所有的 3 替换为 e。

  • 将所有的 4 替换为 a。

  • 将所有的 6 替换为 g。

  • 将所有中文字符替换为空格。

如果我们可以为每行黑客文本做这五件事情,我们将得到我们想要的纯英文文本。在我们编写任何代码之前,让我们看看这些函数将如何转换我们的文本。首先,我们将从图 3.4 中将 7 替换为 t 开始。

图 3.4. 我们的黑客翻译管道的一部分将涉及将 7 替换为 t。我们将通过映射一个在所有输入上执行该替换的函数来完成。

图 3.4 辅助

在图 3.4 的顶部,我们看到我们的未更改的输入文本:混乱的、难以阅读的黑客通信。在中间,我们有我们的函数replace_7t,它将替换所有的 7 为 t。在底部,我们的文本中没有任何 7。这使得我们的文本稍微易于阅读。

接下来,我们将将所有黑客通信中的 3 替换为 e。我们可以在图 3.5 中看到这一点。

图 3.5. 我们黑客翻译管道的第二步将涉及将 3 替换为 e。我们将通过映射一个在所有输入上执行该替换的函数来完成。

图 3.5 辅助

在图 3.5 的顶部,我们看到我们的黑客文本经过轻微清理;我们已经将 7 替换为 t。在中间,我们有我们的replace_3e函数,它旨在将 3 替换为 e。在底部,我们有现在更易读的文本。所有的 3 都消失了,并且其中有一些 e。

继续进行,我们将用 4 和 a 以及 6 和 g 做同样的事情,直到我们移除了所有的数字。我们将跳过讨论这些函数,以避免重复。一旦我们完成了这些步骤,我们就准备好处理那些中文字符了。我们可以在图 3.6 中看到这一点。

图 3.6. 在中文字符上进行替换将是我们的hacker_translate函数链中的最后一步,我们可以用map语句来处理它。

图 3.6 辅助

在图 3.6 中,我们看到顶部主要是带有中文字符的英文句子,这些字符将单词粘在一起。在中间,我们有我们的分割函数:sub_chinese。在底部,最终,我们有完全清理后的句子。

3.2.1. 创建辅助函数

现在我们已经概述了解决方案,让我们开始编写一些代码。首先,我们将编写所有替换辅助函数。

我们将一次性编写所有这些函数,因为它们都遵循类似的模式:我们取一个字符串,找到所有某个字符(一个数字)并将其替换为另一个字符(一个字母)。例如,在 replace_7t 中,我们找到所有的 7 并将其替换为 t。我们使用内置的 Python 字符串方法 .replace 来做这件事。.replace 方法允许我们指定要移除的字符以及要替换的字符,如下面的列表所示。

列表 3.1. 替换辅助函数
def replace_7t(s):             *1*
    return s.replace('7','t')
def replace_3e(s):             *2*
    return s.replace('3','e')
def replace_6g(s):             *3*
    return s.replace('6','g')
def replace_4a(s):             *4*
    return s.replace('4'.,'a')
  • 1 将所有 7 替换为 t

  • 2 将所有 3 替换为 e

  • 3 将所有 6 替换为 g

  • 4 将所有 4 替换为 a

这样就完成了前几个步骤。现在我们想要在中文文本出现的地方进行分割。这个任务稍微复杂一些。因为黑客使用不同的中文字符来表示空格,而不仅仅是重复使用同一个字符,所以我们不能在这里使用 replace。我们必须使用正则表达式。因为我们使用正则表达式,我们想要创建一个小的类,它可以提前为我们编译它。在这种情况下,我们的 sub_chinese 函数实际上将是一个类方法。我们将在下面的列表中看到这一点。

列表 3.2. 在中文字符上进行分割的函数
import re

class chinese_matcher:                               *1*

    def __init__(self):
        self.r = re.compile(r'[\u4e00-\u9fff]+')     *2*

    def sub_chinese(self,s):
        return self.r.sub(s, " ")                    *3*
  • 1 我们在类初始化时编译我们的正则表达式。

  • 2 在这个例子中,我们想要匹配一个或多个中文字符。这些字符可以在 Unicode 范围从 4e00 到 9fff 中找到。

  • 3 现在我们可以使用这个编译好的正则表达式在一个使用表达式模式分割方法的方法中。

我们在这里做的第一件事是创建一个名为 chinese_matcher 的类。在初始化时,该类将编译一个匹配所有中文字符的正则表达式。这个正则表达式将是一个范围正则表达式,查找 Unicode 字符从 \u4e00(Unicode 标准中的第一个中文字符)到 \u9fff(Unicode 标准中的最后一个中文字符)。如果你之前使用过正则表达式,你应该已经熟悉这个概念,比如使用正则表达式 [A-Z]+ 匹配一个或多个大写英文字符。我们在这里使用的是同样的概念,只是不是匹配大写字符,而是匹配中文字符。而且不是直接输入字符,而是输入它们的 Unicode 编号。

设置好正则表达式后,我们可以在一个方法中使用它。在这种情况下,我们将使用一个名为 .sub_chinese 的方法。这个方法将应用正则表达式方法 .split 到任意字符串上,并返回结果。因为我们知道我们的正则表达式匹配一个或多个中文字符,所以结果将是每次字符串中出现中文字符时,我们将该字符替换为空格。

3.2.2. 创建一个管道

现在我们已经准备好了所有的辅助函数,我们准备烘焙我们的黑客阻挠蛋糕。接下来要做的就是将这些辅助函数连接起来。让我们看看三种实现方式:

  1. 使用一系列映射

  2. 使用compose链式连接函数

  3. 使用pipe创建函数管道

一系列地图

对于这种方法,我们取所有我们的函数并将它们映射到彼此的结果上。

  • 我们将replace_7t映射到我们的样本消息上。

  • 然后,我们将replace_3e映射到那个结果上。

  • 然后,我们将replace_6g映射到那个结果上。

  • 然后,我们将replace_4a映射到那个结果上。

  • 最后,我们将C.sub_chinese映射。

列表 3.3 中显示的解决方案并不美观,但它有效。如果你打印结果,你会看到所有的混乱样本句子都被翻译成了易于阅读的英语,单词之间被分开——这正是我们想要的。记住,你需要评估map之后才能打印它!

列表 3.3. 通过序列map链式连接函数
C = chinese_matcher()

map(C.sub_chinese,
        map(replace_4a,
            map(replace_6g,
                map(replace_3e,
                    map(replace_7t, sample_messages)))))
使用compose构建管道

虽然我们当然可以用这种方式将函数连接起来,但还有更好的方法。我们将查看两个可以帮助我们做到这一点的函数:

  1. compose

  2. pipe

这些函数都在 toolz 包中,你可以像安装大多数 Python 包一样使用pip安装:pip install toolz

首先,让我们看看composecompose函数以我们希望它们应用的相反顺序接收我们的辅助函数,并返回一个应用它们的函数。例如,compose(foo, bar, bizz)将应用bizz,然后是bar,然后是foo。在我们问题的具体上下文中,这看起来就像列表 3.4。

在列表 3.4 中,你可以看到我们调用了compose函数,并传递了所有我们想要包含在管道中的函数。我们以相反的顺序传递它们,因为compose将反向应用它们。我们将compose函数的输出(它本身也是一个函数)存储到一个变量中。然后我们可以调用那个变量或将它传递给map,它将对所有样本消息应用它。

列表 3.4. 使用compose创建函数管道
from toolz.functoolz import compose

hacker_translate = compose(C.sub_chinese, replace_4a, replace_6g,
                           replace_3e, replace_7t)

map(hacker_translate, sample_messages)

如果你打印出来,你会注意到结果与我们将函数通过一系列map语句连接起来的结果相同。主要的不同之处在于我们清理了大量的代码,这里我们只有一个map语句。

使用pipe的管道

接下来,让我们看看pipepipe函数将通过管道传递一个值。它期望值通过管道传递,并应用函数。与compose不同,pipe期望函数按照我们想要应用它们的顺序。所以pipe(x, foo, bar, bizz)foo应用于 x,然后是bar应用于那个值,最后是bizz应用于那个值。composepipe之间的另一个重要区别是,pipe评估每个函数并返回一个结果,因此如果我们想将其传递给map,我们实际上必须将其包裹在一个函数定义中。再次回到我们的具体例子,它看起来可能如下所示。

列表 3.5. 使用pipe创建函数管道
from toolz.functoolz import pipe

def hacker_translate(s):
        return pipe(s, replace_7t, replace_3e, replace_6g,
                       replace_4a, C.sub_chinese)

    map(hacker_translate,sample_messages)

在这里,我们创建了一个函数,它接受我们的输入,并在通过我们传递给pipe作为参数的函数序列管道后返回该值。在这种情况下,我们开始于replace_7t,然后应用replace_3ereplace_6greplace_4a,最后是C.sub_chinese,按照这个顺序。结果,与compose一样,与使用一系列map将函数链接在一起时相同——你可以自由地打印出结果并证明给自己看——但我们到达那里的方式要干净得多。

创建辅助函数的管道提供了两个主要优势。代码变得更加

  • 非常可读且清晰

  • 模块化和易于编辑

前一个优势,提高可读性,特别是在我们必须进行复杂的数据转换或当我们想要执行一系列可能相关或可能无关的操作时,这一点尤其正确。例如,刚刚接触到compose的概念,我相当确信你可以猜测这个管道做了什么:

my_pipeline = compose(reverse, remove_vowels, make_uppercase)

后一个优势,使代码模块化和易于编辑,在处理动态情况时是一个主要的好处。例如,假设我们的黑客对手改变了他们的诡计,现在他们正在替换更多的字母!我们只需简单地将新函数添加到我们的管道中即可进行调整。如果我们发现黑客停止替换一个字母,我们可以从管道中删除该函数。

一个黑客翻译管道

最后,让我们回到这个问题的map示例。一开始,我们希望有一个函数hacker_translate,它将我们从混乱的黑客秘密翻译成普通的英语。我们可以从图 3.7 中看到我们实际上做了什么。

图 3.7. 我们可以通过构建一个解决问题各个部分的函数链来解决黑客翻译问题。

03fig07_alt.jpg

图 3.7显示了我们的输入值在上部,输出值在底部,中间我们看到我们的五个辅助函数如何改变输入。将复杂问题分解成几个小问题使得编写这个问题的解决方案变得相当直接,并且使用map,我们可以轻松地将管道应用于所需输入的任意数量。

3.3. 推测的 Twitter 人口统计

在上一节中,我们探讨了如何通过链式连接小型函数并将它们应用于所有黑客的消息来挫败一群黑客。在本节中,我们将更深入地探讨我们可以使用链式连接的小型、简单辅助函数能做什么。

场景

市场部门负责人有一个理论,认为男性客户比女性客户更有可能在我们产品的社交媒体上互动,并要求我们编写一个算法,根据用户帖子中的文本预测提及我们产品的 Twitter 用户的性别。市场部门负责人为我们提供了每个客户的推文 ID 列表。我们必须编写一个脚本,将这些 ID 列表转换为表示我们对其性别信念强度的得分以及关于他们性别的预测。

为了解决这个问题,我们再次从一个大图map开始。我们可以看到在图 3.8 中。

图 3.8. 我们的gender_prediction_pipelinemap图展示了问题的开始和结束:我们将从推文 ID 列表中获取,并将它们转换为关于用户的预测。

图 3.8 中的map图让我们能够看到我们的输入数据在顶部,我们的输出数据在底部,这将帮助我们思考如何解决问题。在顶部,我们可以看到我们有一系列代表推文 ID 的数字列表,这将是我们的输入格式。在底部,我们看到我们有一系列包含"score""gender"键的字典,这让我们对我们的gender_prediction_pipeline函数需要做什么有了概念。

现在,从多个推文 ID 中预测 Twitter 用户的性别不是一个任务;实际上,这是一系列任务。为了完成这个任务,我们必须要做以下几步:

  • 获取那些 ID 表示的推文

  • 从那些推文中提取推文文本

  • 对提取的文本进行分词

  • 评分分词

  • 根据推文得分对用户进行评分

  • 根据用户的得分对用户进行分类

观察任务列表,我们实际上可以将我们的过程分解为两个转换:在用户级别发生的转换和在推文级别发生的转换。用户级别的转换包括评分用户和分类用户等。推文级别的转换包括检索推文、提取文本、分词文本和评分文本等。如果我们仍在使用for循环,这种情况意味着我们需要一个嵌套的for循环。由于我们正在使用map,我们将在我们的map内部使用map

3.3.1. 推文级别管道

首先,让我们看看我们的推文级别转换。在推文级别,我们将把一个推文 ID 转换成该推文的单个分数,代表该推文的性别分数。我们将根据他们使用的单词给推文打分。一些单词会使推文更像“男性的推文”,而另一些单词会使推文更像“女性的推文”。我们可以在图 3.9 中看到这个过程是如何进行的。

图 3.9。我们可以将四个函数链接在一起形成一个管道,以完成我们问题的各个子部分。

文本分类

通过对推文中使用的单词分配分数来对推文进行分类可能看起来很简单,但实际上它并不太远离学术界和工业界处理这种情况的方法。基于词汇表的分类方法,通过给单词分配分数然后将这些分数汇总为总分,以它们的简单性实现了显著的性能。而且因为它们是透明的,它们为从业者提供了可解释性的好处。

在本章中,我们只对实际情况进行了*似,但您可以在我的 GitHub 页面上找到一个最先进的分类器:github.com/jtwool/TwitterGenderPredictor

图 3.9 展示了我们的推文在从 ID 转换为分数的过程中将经历的几个转换。从左上角开始,我们看到我们以推文 ID 作为输入,然后通过get_tweet_from_id函数传递它们,并返回推文对象。接下来,我们将这些推文对象通过tweet_to_text函数传递,该函数将推文对象转换为这些推文的文本。然后,我们通过应用tokenize_text函数对推文进行分词。之后,我们使用score_text函数对推文进行评分。

接下来,让我们关注用户级别的转换,这里的流程要简单一些:

  1. 我们将推文级别的流程应用于用户的所有推文。

  2. 我们取推文分数的平均值以获得用户级别的分数。

  3. 我们将用户分类为“男性”或“女性”。

图 3.10 展示了用户级别流程的执行过程。

图 3.10。我们可以将小函数链接起来,将用户推文 ID 的列表转换为分数,然后转换为平均值,最后转换为对他们的人口统计特征的预测。

我们可以看到,每个用户最初都是一个推文 ID 的列表。通过在我们的score_user函数上应用所有这些推文 ID 列表,我们为每个用户返回一个单一分数。然后,我们可以使用我们的categorize_user函数将这个分数转换成一个包含分数和用户预测性别的dict,就像我们最初想要的那样。

这些map图为我们编写代码提供了路线图。它们帮助我们了解需要发生哪些数据转换,以及我们可以在哪里构建管道。例如,我们现在知道我们需要两个函数链:一个用于推文,一个用于用户。考虑到这一点,让我们开始解决推文管道。

我们的推文管道将包括四个函数。让我们按以下顺序解决它们:

  1. get_tweet_from_id

  2. tweet_to_text

  3. tokenize_text

  4. score_text

我们的get_tweet_from_id函数负责接收一个推文 ID 作为输入,在 Twitter 上查找该推文 ID,并返回我们可以使用的推文对象。抓取 Twitter 数据最简单的方法是使用python-twitter包。你可以用pip轻松安装python-twitter

pip install python-twitter

一旦你设置了python-twitter,你还需要在 Twitter 上设置一个开发者账户。(见“Twitter 开发者账户”侧边栏。)你可以在developer.twitter.com/完成这项操作。如果你已经有了 Twitter 账户,就没有必要创建另一个账户;你可以用已有的账户登录。账户设置好后,你就可以申请 Twitter 所说的“应用”了。你需要填写一个申请表,如果你告诉 Twitter 你正在用这本书学*并行编程,他们会很高兴为你提供一个账户。当你被要求描述你的用例时,我建议输入以下内容:

我的应用程序的核心目的是学*并行编程技术。我正在跟随由 Manning Publications 出版的由 JT Wolohan 撰写的《Python 精通大数据集》第三章中提供的场景。

我打算对少于 1,000 条推文进行词汇分析。

我不打算使用我的应用程序来发推文、转发或“点赞”内容。

我不会在任何在线地方显示任何推文。

Twitter 开发者账户

因为这个场景涉及到 Twitter 抓取,即 Twitter 数据的自动收集,我想给你一个机会进行真实的 Twitter 抓取。这样做需要你请求一个 Twitter 开发者账户。这些开发者账户过去更容易获得。Twitter 开始限制谁可以在其平台上开发,因为它想要打击机器人。如果你不想注册 Twitter,不想注册开发者账户,或者不想等待,你可以不注册开发者账户继续操作。

在这本书的存储库中,我包括了可以替代推文的文本,你可以从你的推文级管道中省略前两个功能(get_tweet_from_idtweet_to_text)。

一旦您的 Twitter 开发者账户设置完成并被 Twitter 确认(这可能需要一两个小时),您将导航到您的应用程序并找到您的消费者密钥、消费者密钥、访问令牌密钥和访问令牌密钥(图 3.11)。这些是您的应用程序的凭证。它们告诉 Twitter 将您的请求与您的应用程序关联起来。

图 3.11。您的 Twitter 开发者账户中的“密钥和令牌”选项卡为您提供了项目的 API 密钥、访问令牌和访问密钥。

图 3.11

在您的开发者账户设置完成并且安装了python-twitter之后,我们终于可以开始编写我们的推文级管道代码了。我们首先要做的是导入python-twitter库。这正是我们刚刚安装的库。它提供了一系列方便的函数,用于与 Twitter API 一起工作。然而,在我们能够使用这些美好的函数之前,我们需要对我们的应用程序进行认证。我们通过从库中初始化一个Api类来完成认证。这个类接受我们的应用程序凭证,这些凭证是从 Twitter 开发者网站上获取的,并在它调用 Twitter API 时使用这些凭证。

在这个类准备就绪之后,我们可以创建一个函数来返回来自 Twitter ID 的推文。我们需要将我们的 API 对象传递给这个函数,以便我们可以使用它来向 Twitter 发出请求。一旦我们这样做,我们就可以使用 API 对象的.GetStatus方法通过 ID 检索推文。以这种方式检索到的推文会以 Python 对象的形式返回,非常适合在我们的脚本中使用。

我们将在下一个函数tweet_to_text中使用这个事实,该函数接受推文对象并返回其文本。这个函数非常简短。它调用推文对象的文本属性并返回该值。python-twitter返回的推文对象的文本属性,正如我们所期望的,包含了推文的文本。

在推文文本准备好之后,我们可以对其进行分词。分词是一个将文本分解成更小单元以便分析的过程。在某些情况下,这可能相当复杂,但就我们的目的而言,我们将根据空白字符将文本分割开来,以分隔单词。对于像“这是条推文”这样的句子,我们会得到一个包含每个单词的列表:["这是", "条", "推文"]。我们将使用内置的字符串.split方法来完成这个操作。

一旦我们有了令牌,我们需要对它们进行评分。为此,我们将使用我们的score_text函数。这个函数将查找每个令牌在词典中的位置,检索其评分,然后将所有这些评分加在一起以获得推文的总体评分。为了做到这一点,我们需要一个词典,一个包含单词及其相关评分的列表。在这里,我们将使用dict来实现这一点。为了查找每个单词的评分,我们可以将dict.get方法映射到单词列表上。

dict.get 方法允许我们在找不到键的情况下查找键并提供默认值。在我们的情况下,这很有用,因为我们希望我们词典中没有找到的单词具有零的中性值。

为了将此方法转换为函数,我们使用所谓的 lambda 函数lambda 关键字允许我们指定变量以及我们想要如何转换它们。例如,lambda x: x+2 定义了一个函数,它将两个加到传递给它的任何值上。代码 lambda x: lexicon.get(x, 0) 在我们的词典中查找传递给它的任何内容,并返回该值或 0(如果找不到)。我们经常将其用于短函数。

最后,在编写了所有这些辅助函数之后,我们可以构建我们的 score_tweet 管道。这个管道将接受一个推文 ID,将其传递到所有这些辅助函数中,并返回结果。为此过程,我们将使用 toolz 库中的 pipe 函数。这个管道代表了我们在推文级别想要做的全部内容。我们可以在以下列表中看到所有需要的代码。

列表 3.6. 推文级别管道
from toolz import pipe                                      *1*
import twitter

Twitter = twitter.Api(consumer_key="",                      *2*
                      consumer_secret="",
                      access_token_key="",
                      access_token_secret="")

def get_tweet_from_id(tweet_id, api=Twitter):               *3*
    return api.GetStatus(tweet_id, trim_user=True)

def tweet_to_text(tweet):                                   *4*
    return tweet.text

def tokenize_text(text):                                    *5*
    return text.split()

def score_text(tokens):                                     *6*
    lexicon = {"the":1, "to":1, "and":1,                    *7*
             "in":1, "have":1, "it":1,
             "be":-1, "of":-1, "a":-1,
             "that":-1, "i":-1, "for":-1}
    return sum(map(lambda x: lexicon.get(x, 0), tokens))    *8*

def score_tweet(tweet_id):                                  *9*
    return pipe(tweet_id, get_tweet_from_id, tweet_to_text,
                          tokenize_text, score_text)
  • 1 导入 python-twitter 库

  • 2 验证我们的应用程序

  • 3 使用我们的应用程序通过 ID 查找推文

  • 4 从推文对象中获取文本

  • 5 在空白处拆分文本,以便我们可以分析单词

  • 6 创建我们的 score_text 函数

  • 7 创建一个用于评分单词的迷你样本词典

  • 8 将每个单词替换为其点值

  • 9 将推文通过我们的管道

3.3.2. 用户级别管道

在构建了我们的推文级别管道之后,我们准备构建我们的用户级别管道。正如我们之前所阐述的,我们需要为我们的用户级别管道做三件事:

  1. 将推文管道应用于用户的所有推文

  2. 取这些推文得分的平均值

  3. 根据这个平均值对用户进行分类

为了简洁起见,我们将前两个操作合并为一个函数,并将第三个操作作为一个独立的函数。当一切完成时,我们的用户级别辅助函数将如下所示。

列表 3.7. 用户级别辅助函数
from toolz import compose

def score_user(tweets):                            *1*
    N = len(tweets)                                *2*
    total = sum(map(score_tweet, tweets))          *3*
    return total/N                                 *4*

def categorize_user(user_score):                   *5*
    if user_score > 0:                             *6*
        return {"score":user_score,
                "gender": "Male"}
return {"score":user_score,                        *7*
        "gender":"Female"}

pipeline = compose(categorize_user, score_user)    *8*
  • 1 计算用户所有推文的平均得分

  • 2 计算推文数量

  • 3 计算用户所有单独推文的得分总和

  • 4 返回总和除以推文数量

  • 5 获取得分并返回预测的性别

  • 6 如果用户得分大于 0,我们将说该用户是男性。

  • 7 否则,我们将说该用户是女性。

  • 8 将这些辅助函数组合成一个管道函数

在我们的第一个用户级别辅助函数中,我们需要完成两件事:对所有用户的推文进行评分,然后找到平均得分。我们已经知道如何评分他们的推文——我们只是为这个确切目的构建了一个管道!为了评分推文,我们将该管道映射到所有推文上。然而,我们不需要得分本身,我们需要平均得分。

为了找到简单的平均值,我们需要将值的总和除以我们正在求和的值的数量。为了找到总和,我们可以在推文中使用 Python 的内置sum函数。为了找到推文数量,我们可以使用len函数找到列表的长度。有了这两个值,我们可以通过除以长度来计算平均值。

这将给我们每个用户的平均推文评分。有了这个,我们可以将用户分类为“男性”或“女性”。为了进行这种分类,我们将创建另一个小的辅助函数:categorize_user。这个函数将检查用户的平均评分是否大于零。如果是,它将返回一个包含评分和性别预测为“男性”的dict。如果他们的平均评分是零或更少,它将返回一个包含评分和性别预测为“女性”的dict

这两个快速辅助函数就足够我们用户级管道使用了。现在我们可以将它们组合起来,记得按照我们想要应用它们的相反顺序提供它们。这意味着我们首先放置分类函数,因为我们最后使用它,然后是评分函数,因为我们首先使用它。结果是一个新的函数——gender_prediction_pipeline,我们可以用它来对用户的性别进行预测。

3.3.3. 应用管道

现在我们已经准备好了用户级和推文级函数链,我们剩下的只是将函数应用于我们的数据。为此,我们可以使用推文 ID 和我们的完整推文级函数链,或者——如果你决定不注册 Twitter 开发者账户——我们可以只使用推文文本。如果你将只使用推文文本,请确保创建一个省略get_tweet_from_idtweet_to_text函数的推文级函数链(score_tweet)。

将管道应用于推文 ID

在第一次应用我们的管道时,它可能看起来像列表 3.8。在那里,我们首先初始化我们的数据。我们开始的数据是四个包含五个推文 ID 的列表。这四个列表代表一个用户。推文 ID 实际上并不来自同一个用户;然而,它们是来自互联网的真实的推文,随机抽取的。

列表 3.8. 将性别预测管道应用于推文 ID
users_tweets = [                                                 *1*
[1056365937547534341, 1056310126255034368, 1055985345341251584,
 1056585873989394432, 1056585871623966720],
[1055986452612419584, 1056318330037002240, 1055957256162942977,
 1056585921154420736, 1056585896898805766],
[1056240773572771841, 1056184836900175874, 1056367465477951490,
 1056585972765224960, 1056585968155684864],
[1056452187897786368, 1056314736546115584, 1055172336062816258,
 1056585983175602176, 1056585980881207297]]

with Pool() as P:                                                *2*
    print(P.map(pipeline, users_tweets))
  • 1 首先,我们需要初始化我们的数据。在这里,我们使用四组推文 ID。

  • 2 然后,我们可以使用 map 将我们的管道应用于我们的数据。在这里,我们使用并行 map。

我们初始化了数据后,现在可以应用我们的gender_prediction_pipeline。我们将按照上一章介绍的方法来做:使用并行map。我们首先调用Pool来收集一些处理器,然后我们使用该Pool.map方法来并行应用我们的预测函数。

如果我们在行业环境中做这件事,这将是一个使用并行 map 的绝佳机会,原因有两个:

  1. 我们对每个用户都执行相同的工作。

  2. 从网络中检索数据以及找到所有这些推文的评分都是相对耗时和耗内存的操作。

关于第一个问题,每当我们发现自己反复做同样的事情时,我们应该考虑使用并行化来加快我们的工作速度。这在我们使用专用机器(如我们的个人笔记本电脑或专用计算集群)并且不需要担心抢占其他人或应用程序可能需要的处理资源时尤其如此。

关于第二个问题,我们最好在计算至少有些困难或耗时的情况下使用并行技术。如果我们试图并行执行的工作太简单,我们可能花费更多的时间来分割工作和重新组装结果,这比以标准线性方式执行它要花费更多的时间。

将管道应用于推文文本

将管道直接应用于推文文本将非常类似于将管道应用于推文 ID,如下面的列表所示。

列表 3.9. 将性别预测管道应用于推文文本
user_tweets = [                                                            *1*
        ["i think product x is so great", "i use product x for everything",
        "i couldn't be happier with product x"],
        ["i have to throw product x in the trash",
        "product x... the worst value for your money"],
        ["product x is mostly fine", "i have no opinion of product x"]]

with Pool() as P:                                                          *2*
    print(P.map(gender_prediction_pipeline, users_tweets))
  • 1 首先,我们需要初始化我们的数据。这里,我们使用四组推文 ID。

  • 2 然后,我们可以使用 map 将我们的管道应用到我们的数据上。这里,我们使用的是并行 map。

与列表 3.8 相比,列表 3.9 的唯一变化是我们的输入数据。我们不再需要从推特上找到我们想要查找、检索和评分的推文 ID,而是可以直接评分推文文本。因为我们的score_tweet函数链去除了get_tweet_from_idtweet_to_text辅助函数,所以gender_prediction_pipeline将完全按照我们的预期工作。

我们可以如此容易地修改我们的管道是我们最初想要组装它们的主要原因之一。当条件变化时,就像它们经常发生的那样,我们可以快速轻松地修改我们的代码来应对它们。如果我们预见到需要处理两种情况,我们甚至可以创建两个函数链。一个函数链可以是score_tweet_from_text,它将处理以文本形式提供的推文。另一个函数链可以是score_tweet_from_id,它将分类以推文 ID 形式提供的推文。

回顾整个示例,我们创建了六个辅助函数和两个管道。对于这些管道,我们使用了来自 toolz 包的pipe函数和compose函数。我们还使用这些函数与并行map一起从互联网上并行下载推文。使用辅助函数和函数链使我们的代码易于理解和修改,并且与想要反复应用相同函数的并行map很好地配合。

3.4. 练*

3.4.1. 辅助函数和函数管道

在本章中,你学*了辅助函数和函数管道相互关联的概念。用你自己的话定义这两个术语,然后描述它们是如何相关的。

3.4.2. 数学老师的小技巧

一个经典的数学老师技巧是让学生对一个“未知”数字进行一系列算术运算,最后老师猜测学生正在想的数字。这个技巧是最终数字总是老师事先知道的常数。一个这样的例子是将一个数字翻倍,加 10,然后除以 2,再减去原始数字。使用一系列小型辅助函数链接起来,将这个过程映射到 1 到 100 之间的所有数字上。老师是如何总是知道你在想什么数字的?

示例
map(teacher_trick, range(1,101))
>>> [?,?,?,?,...,?]

3.4.3. 凯撒密码

凯撒密码是一种古老的方法,通过将字母的位置移动 13 个位置来构建秘密代码,所以 A 变成 N,B 变成 O,C 变成 P,以此类推。将三个函数链接起来创建这个密码:一个将字母转换为整数,一个将数字加 3,一个将数字转换为字母。通过映射字符串的链接函数将这个密码应用于一个单词。创建一个新函数和一个新管道来反转你的密码。

示例
map(caesars_cypher,["this","is","my",sentence"])
>>> ["wklv","lv","pb","vhqwhqfh"]

概述

  • 使用小型辅助函数设计程序,通过将问题分解成小块来使难题容易解决。

  • 当我们将一个函数通过函数管道 pipe 传递时,它期望输入数据作为其第一个参数,我们想要应用的函数作为剩余参数的顺序。

  • 当我们使用 compose 创建一个函数链时,我们以相反的顺序将函数链中的函数作为参数传递,并且生成的函数应用这个链。

  • 构建函数链和管道是有用的,因为它们是模块化的,它们与 map 的配合非常好,并且我们可以轻松地将它们移动到并行工作流程中,例如使用我们在第二章中学到的 Pool() 技术。

  • 我们可以通过使用嵌套函数管道来简化处理嵌套数据结构,我们可以用 map. 来应用它。

第四章. 使用惰性工作流程处理大型数据集

本章涵盖

  • 在本地编写处理大型数据集的惰性工作流程

  • 理解 map 的惰性行为

  • 使用生成器编写用于惰性模拟的类

在第二章 (第 2.1.2 节,更确切地说),我介绍了我们喜爱的 map 函数默认是惰性的;也就是说,它只有在需要时才会进行评估。在本章中,我们将探讨惰性的几个好处,包括我们如何利用惰性在笔记本电脑上处理大数据。我们将关注两个方面的惰性好处:

  1. 文件处理

  2. 模拟

在文件处理中,我们将看到惰性使我们能够处理比没有惰性时能放入内存中多得多的数据。在模拟中,我们将看到我们如何使用惰性来运行“无限”模拟。实际上,惰性函数使我们能够像处理有限数量的数据一样轻松地处理无限数量的数据。

4.1. 什么是懒加载?

懒加载,或 懒加载评估,是编程语言在决定何时执行计算时使用的一种策略。在懒加载下,Python 解释器仅在程序需要该代码的结果时才执行懒加载 Python 代码。

例如,考虑 Python 中的 range 函数,它以懒加载的方式生成数字序列。也就是说,我们可以调用 range(10000),而不会返回一个包含 10,000 个数字的列表;我们会得到一个知道如何生成 10,000 个数字的迭代器。这意味着我们可以进行荒谬地大的 range 调用,而不用担心我们会用完所有内存来存储整数。例如,range(10000000000) 的大小与 range(10) 相同。你可以用两行代码自己检查这一点:

>>> from sys import getsizeof
>>> getsizeof(range(10000000000)) == getsizeof(range(10))
True

这种懒加载与 急切 评估相反,其中所有内容都是在调用时进行评估。这可能是你*惯于思考编程的方式。你写一段代码,然后当计算机到达那个点时,它会计算你告诉它计算的内容。相比之下,在懒加载中,计算机接收你的指令并将它们存档,直到需要使用它们。如果你从未要求计算机提供最终结果,它将永远不会执行任何中间步骤。

以这种方式,懒加载就像一个即将在遥远的未来提交作业的高中生。老师可以在年初就告诉学生如何写作业。他们甚至可以警告学生,作业将在几周后到期。但直到截止日期前,学生才会真正开始做作业。主要区别是,计算机总是会完成工作。

此外,就像学生推迟做作业以便做其他事情——比如处理即将到期的其他作业或只是和朋友闲逛——我们的懒加载程序也在做其他事情。因为我们的程序懒加载我们的指令,它有更多的内存(时间)来做我们要求的其他事情(其他作业)或甚至运行其他进程(也许是与朋友闲逛?)。

4.2. 一些需要了解的懒加载函数

我们已经讨论了两个你熟悉的函数——maprange——是如何懒加载的。在本节中,我们将关注你应该了解的另外三个懒加载函数:

  1. filter 用于修剪序列的函数

  2. zip 用于合并序列的函数

  3. iglob 用于从文件系统中懒加载的函数

filter 函数接受一个序列,并将其限制为仅包含满足给定条件的元素。zip 函数接受两个序列,并返回一个包含元组的单一序列,每个元组包含原始序列中的每个元素。而 iglob 函数是一种查询文件系统的懒加载方式。

4.2.1. 使用 filter 函数缩小序列

filter 函数确实如您所期望的那样工作:它作为一个过滤器。具体来说,它接受一个条件函数和一个序列,并返回一个包含满足该条件的序列所有元素的惰性可迭代对象(图 4.1)。例如,在下面的列表中,我们看到 filter 如何接受一个检查数字是否为偶数的函数,并返回一个只包含偶数的可迭代对象。

图 4.1. filter 函数产生一个新的序列,只包含使限定函数返回 True 的元素。

列表 4.1. 从序列中检索偶数
def is_even(x):
   if x%2 == 0: return True
   else: return False

print(list(filter(is_even, range(10))))
# [0,2,4,6,8]

在 列表 4.1 中,我们对过滤器调用 list 以便它能够以良好的方式打印出来,就像我们对 map 所做的那样。在这两种情况下,我们必须调用 list,因为 filtermap 都是惰性的,直到我们对特定值感兴趣时才会进行评估。因为列表不是惰性的,将我们的惰性对象转换为 list 让我们能够看到单个值。

filter 函数是一个非常有价值的工具,因为它可以帮助我们简洁地定义一个常见的操作。还有四个相关的函数也值得了解,它们都执行相同的基本操作,但略有不同:

  1. filterfalse

  2. keyfilter

  3. valfilter

  4. itemfilter

就像 filter 一样,所有这些函数只是做我们期望它们做的事情。当我们想要获取使限定函数返回 False 的所有结果时,我们可以使用 filterfalse 函数。当我们想要根据 dict 的键进行过滤时,我们可以使用 keyfilter 函数。当我们想要根据 dict 的值进行过滤时,我们可以使用 valfilter 函数。而且,当我们想要根据 dict 的键和值进行过滤时,我们可以使用 itemfilter。我们可以在 列表 4.2 中看到所有这些函数的示例。

在 列表 4.2 中,我们使用了这四个函数。第一个,filterfalse,来自 Python 中的 itertools 模块。当我们结合 iterfalse 和之前的 is_even 函数时,我们得到所有非偶数(奇数)。对于 keyfiltervalfilteritemfilter,我们需要输入一个 dict。当我们结合 keyfilteris_even 时,我们得到 dict 中所有具有偶数键的项。当我们结合 valfilteris_even 时,我们得到 dict 中所有具有偶数值的项。对于 itemfilter,我们可以评估 dict 的键和值。在 列表 4.2 中,我们创建了一个小函数 both_are_even,用于测试一个项的键和值是否都是偶数。正如列表所示,我们确实得到了键和值都是偶数的项。

列表 4.2. 测试 filter 函数的变体
from itertools import filterfalse
from toolz.dicttoolz import keyfilter, valfilter, itemfilter

def is_even(x):
    if x % 2 == 0: return True
    else: return False

def both_are_even(x):
    k,v = x
    if is_even(k) and is_even(v): return True
    else: return False

print(list(filterfalse(is_even, range(10))))
# [1, 3, 5, 7, 9]

print(list(keyfilter(is_even, {1:2, 2:3, 3:4, 4:5, 5:6})))
# [2, 4]

print(list(valfilter(is_even, {1:2, 2:3, 3:4, 4:5, 5:6})))
# [1, 3, 5]

print(list(itemfilter(both_are_even, {1:5, 2:4, 3:3, 4:2, 5:1})))
# [2, 4]

4.2.2. 使用 zip 组合序列

zip 是另一个懒加载函数。当我们有两个可迭代对象想要合并在一起,使得第一个位置的项目在一起,第二个位置的项目在一起,以此类推时,我们会使用 zip。自然地,我们可以将 zip 函数想象成一个拉链。当拉链穿过每一对齿时,它会将它们拉在一起形成一个对(图 4.2)。

图 4.2. zip 函数的行为就像一个拉链,但它不是通过金属齿的互锁,而是通过 Python 可迭代对象的值进行互锁。

当我们有两个相关的序列想要将它们放在一起时,我们会使用 zip 函数。例如,如果一个冰淇淋摊贩知道他们在过去两周内卖出了多少冰淇淋筒,他们可能会对将这个数据与温度一起拉链起来感兴趣,以分析是否存在任何趋势,如下面的列表所示。

列表 4.3. 冰淇淋数据和 zip 函数
ice_cream_sales = [27, 21, 39, 31, 12, 40, 11, 18, 30, 19, 24, 35, 31, 12]
temperatures = [75, 97, 88, 99, 81, 92, 91, 84, 84, 93, 100, 86, 90, 75]

ice_cream_data = zip(ice_cream_sales, temperatures)
print(list(ice_cream_data))
# [(27,75), (21,97), (39,88), ... (12,75)]

以这种方式将数据配对在 tuple 中是有帮助的,因为 tuple 可以很容易地传递给函数并解包。由于 zip 是懒加载的,因此生成的迭代器几乎不占用内存。这意味着我们可以在机器上收集和移动大量数据,而无需将其存储在内存中。

生成的单个序列也是映射函数的完美目标,因为 map 函数接受一个函数和一个序列,你想要应用该函数。由于 map 也是懒加载的,我们可以计算所有这些销售额,而无需太多的内存开销。实际上,我们所有的懒加载函数,如 mapfilterzip,都很好地协同工作。并且由于它们都以某种方式接受序列作为输入,因此它们可以串联在一起,并保持它们低内存开销的懒加载特性。

4.2.3. 使用 iglob 进行懒加载文件搜索

我们在这里将要查看的最后一个函数是 iglob。我们可以使用 iglob 函数来找到文件系统上与给定模式匹配的文件序列。具体来说,文件是根据标准的 Unix 规则匹配的。对于在许多原型中使用基于文件系统的存储的情况,这可以非常有帮助。

例如,如果我们将博客文章存储为文件中的 JSON 对象,我们可能能够用一行代码(第二行)选择 2015 年 6 月的所有博客文章。

from glob import iglob

blog_posts = iglob("path/to/blog/posts/2015/06/*.json")

这种类型的语句将找到存储在我们存储所有博客文章的目录中的 2015 目录内的 06 目录内的所有 JSON 文件。

对于单一月份,懒加载获取所有博客文章可能不是什么大问题。但如果我们每天有几篇文章,并且有几年的文章,那么我们的列表将会有几千个项目长。或者如果我们进行了一些网页抓取,并将每个页面存储为包含收集时元数据的 .JSON 对象,我们可能会有数百万这样的文件。将所有这些存储在内存中会对我们想要进行的任何处理造成负担。

在一分钟内,我们将看到一个例子,说明这种惰性文件处理可能很有用,但首先,让我们花点时间来谈谈 Python 中序列数据类型的细节。

4.3. 理解迭代器:懒惰 Python 背后的魔法

到目前为止,我们已经讨论了懒惰的好处以及一些可以利用这些好处的函数。在本节中,我们将深入了解迭代器的细节——我们可以按顺序移动的对象——并讨论生成器——用于创建序列的特殊函数。我们在第二章中简要提到了生成器,但这次我们将更深入地探讨,包括对小生成器表达式的分析。

理解迭代器的工作方式非常重要,因为它们是我们能够在笔记本电脑或台式计算机上处理大数据的基础。我们使用迭代器用查找数据的指令替换数据,用执行这些转换的指令替换转换。这种替换意味着计算机只需关注它现在正在处理的数据,而不是它刚刚处理过的数据或将来需要处理的数据。

4.3.1. 懒惰 Python 的骨架:迭代器

迭代器是所有可以迭代的 Python 数据类型的基类。也就是说,我们可以遍历迭代器的项目,或者我们可以像在第二章中学*的那样,将函数映射到它上面。迭代过程由一个特殊的方法.__iter__()定义。如果一个类有这个方法并且返回一个具有.__next__()方法的对象,那么我们可以遍历它。

感谢__next__():迭代器的一向性

.__next__()方法告诉 Python 序列中的下一个对象是什么。我们可以直接使用next()函数来调用它。例如,如果我们已经过滤了一个单词列表,只包含有字母 m 的单词,我们可以使用next()来检索下一个包含 m 的单词。

列表 4.4 演示了在惰性对象上调用next函数。我们创建了一个小函数来检查字符串中是否有字母 m。然后我们使用这个函数与filter一起筛选出只包含字母 m 的单词。然后,因为我们的筛选结果是可迭代的,我们可以调用next函数来获取一个包含 m 的单词。

列表 4.4. 使用next检索 m 单词
words = ["apple","mongoose","walk","mouse","good",
         "pineapple","yeti","minnesota","mars",
         "phone","cream","cucumber","coffee","elementary",
         "sinister","science","empire"]

def contains_m(s):
    if "m" in s.lower(): return True
    else: return False

m_words = filter(contains_m, words)

next(m_words)
next(m_words)
next(m_words)

print(list(m_words))
["mars","cream","cucumber","elementary", ... ]

如果你在这个控制台中运行它,你会发现每次调用next()函数时都会得到下一个项目,这不会让你感到惊讶。然而,你可能惊讶的是,filter(以及map和所有我们的懒惰朋友)是单向的;一旦我们调用next,返回给我们的项目就会从序列中移除。我们永远无法回退并再次检索那个项目。我们可以通过在调用next之后对可迭代对象调用list()来验证这一点。(参见图 4.3。)

图 4.3. 当我们调用 .__next__() 方法或 next() 函数时,我们得到可迭代中的下一个项目。

04fig03_alt.jpg

迭代器之所以这样工作,是因为它们针对大数据进行了优化,但如果我们想逐个元素地探索它们,它们可能会给我们带来问题。它们不是用来手工检查的;它们是用来处理大数据的。如果我们还在调试代码,丢失已经看到的元素可能会使迭代器比列表稍微笨拙一些。然而,当我们确信代码按预期工作,迭代器使用更少的内存并提供更好的性能。

4.3.2. 生成器:创建数据的函数

生成器是 Python 中一类懒加载产生值的函数。它们是实现迭代器的一种简单方式。在第二章中,我们使用生成器函数按顺序生成 URL。这样做的优点是我们不需要在内存中保留列表。实际上,这正是生成器和懒加载函数的主要优势:避免在内存中存储我们不需要的更多数据。

正如我们在第二章中看到的,设计生成器的一种方法是通过定义一个使用 yield 语句的函数。例如,如果我们想要一个生成前 n 个偶数的函数,我们可以使用生成器来实现。这个函数将接受一个数字 n,并为 1 到 n 之间的每个 i 产生 i*2 的值,如列表 4.5 所示。

生成器表达式:单行代码中的无限数据量

如果我们计划多次进行这种生成,yield 语句非常出色。然而,如果我们只计划使用这些数字一次,我们可以用生成器表达式来更简洁地编写代码。生成器表达式看起来像列表解析——简短的声明如何将数据转换成新列表——但它们不是预先生成列表,而是创建一个懒加载的迭代器。这具有我们所有其他懒加载方法所具有的相同优势:我们可以处理更多数据而不会产生内存开销。

在列表 4.5 的末尾展示了前 100 个偶数的生成器表达式。你会注意到表达式周围的括号是圆括号而不是方括号。这是生成器表达式和列表解析之间的语法区别。

列表 4.5. 偶数生成函数
def even_numbers(n):
    i = 1
    while i <= n:
        yield i*2
        i += 1

first_100_even = (i*2 for i in range(1,101))

为了直观地理解生成器表达式和列表解析之间的区别,让我们打开 Python 控制台并运行几乎相同的命令:一个使用生成器表达式,另一个使用列表解析。对于这些语句,我们将使用 itertools 模块中的一个函数 countcount 函数产生一个懒加载的数字序列,类似于 range,但它没有结束;count 函数不会停止。

如果我们想要一个无限长的偶数序列,我们可以运行一个单独的命令(在我们从itertools导入count之后):

from itertools import count
evens = (i*2 for i in count())

你会注意到这个命令立即运行。如果我们对刚刚创建的evens对象调用next(),我们将得到一个偶数。我们还可以使用itertools模块中的islice函数从这个序列中取出块(发音为“i” “slice”,不是“is” “lice”):

from itertools import islice
islice(evens, 5,10)

将此与列表解析的相同结果进行比较。

警告

以下代码将无法完成运行,因此你最好在像repl.it这样的基于网络的壳中运行它。

这是列表解析版本:

evens = [i*2 for i in count()]

我们的生成器表达式运行得又快又简单,但我们的列表解析永远不会完成。这是因为列表解析试图一次性生成所有这些数字并将它们存储在一个列表中。如果我们想要访问特定的元素,或者如果我们需要重复访问序列,这是很好的。生成器会丢失已经使用过的数字,我们只能访问一次。但如果我们必须处理大量数据,我们的列表解析将花费更多的时间。

4.4. 诗歌谜题:懒加载处理大量数据集

现在我们已经花了一些时间来回顾迭代器和懒加载函数的来龙去脉,让我们看看两个实际场景,在这些场景中我们会想要使用这些工具。

场景

一首新的诗歌在全球文化中掀起了一场风暴,但没有人能够确切地识别这位神秘的作者。两位诗人声称这首诗是他们的作品,并提供了他们未发表的诗歌的数 TB 数据,以便你验证哪位诗人更有可能是这首诗的真实作者。

在这种情况下,我们需要处理两位作者的大量数据以确认哪位作者创作了流行的神秘诗歌。我们将使用一种简单但强大的技术:比较功能词的频率。功能词是那些内容价值很小但有助于句子执行任务的词。在其他词中,功能词包括冠词athe。我们将使用这两个词athe的比率来检测我们的真实作者(图 4.4)。

图 4.4. 计算功能词可以让我们了解文档的真实作者。

4.4.1. 为本例生成数据

由于这个场景需要一个大型数据集,这里的“大”是指超过你在跟随的电脑上的内存量,我已选择在本书的仓库中提供一个数据生成脚本(github.com/jtwool/mastering-large-datasets)。你可以使用该函数生成你想要的任何数据量。如果你能生成至少 100 MB,我建议你完成这一部分后将其全部删除。话虽如此,如果你有一块 PB 级的硬盘,请随意填满它。本节中的代码将能够处理它——尽管可能需要一些时间。懒函数在处理数据方面非常出色,但硬件仍然限制了我们可以多快地处理它。另一个好选择:生成一小部分数据,完成本章,然后生成更多数据,让代码在夜间处理。

不幸的是,由于每位作者都向我们提供了大量信息,我们永远无法一次性在内存中处理所有信息。因此,我们不得不使用懒函数来逐步处理。我们还将使用我们在第三章中学到的一些技术:将我们的大型问题分解成我们可以用小型辅助函数解决的问题。

首先,让我们看看我们需要做什么。

  1. 我们最终想要比较每位作者中athe的比例。

  2. 为了做到这一点,我们需要为每位作者读取每个文件。

  3. 我们还需要一种方法来获取athe的词频。

  4. 为了做到这一点,我们需要将诗篇分解成单词。

最终,我们的过程将类似于图 4.5。首先,我们将读取文件。然后,我们将清理它们,使它们成为整洁的工作列表,而不是无结构的诗歌。然后,我们将过滤掉我们感兴趣的单词。最后,我们将获取计数并计算比例。

图 4.5。许多小步骤将帮助我们确定神秘诗的作者。

4.4.2。使用 iglob 读取诗篇

在第 4.2.3 节中,我们探讨了iglob,这是一个在文件系统上搜索文件并返回匹配路径列表作为可迭代对象的函数。由于我们的诗人慷慨地提供了他们未发表的大量作品,我们将想要使用这个函数来限制我们需要花费在存储这些路径上的开销。

这样的直接步骤也是我们首先应该解决的问题。为了读取每位作者的诗篇,让我们使用iglob定义两个可迭代对象:一个用于每位作者。这只是一个简单的两行代码,如下所示。

列表 4.6。使用iglob列出作者的诗篇
    author1_poems = iglob("path/to/author_one/*.txt")
    author2_poems = iglob("path/to/author_two/*.txt")

4.4.3。一个诗篇清理正则表达式类

现在我们有了诗歌,我们需要一种方法将它们转换成可工作的数据格式。就像我们之前处理文本数据一样,我们最终希望将打开和读取诗歌文件时得到的文本数据的长时间字符串转换成单词列表。在此之前,我们还想使用正则表达式移除所有标点符号。对于诗歌来说,这尤为重要,因为诗人以独特的标点符号使用而闻名。

由于我们将使用正则表达式,我们希望创建一个类,这样我们就可以编译那个正则表达式一次,然后根据需要多次使用它。我们将给这个类添加一个属性,包含一个编译后的正则表达式,该表达式匹配我们想要移除的所有标点符号,以及一个使用该正则表达式来移除标点的函数。由于我们使用该函数使我们的文本数据易于处理,因此在那里添加小写化以规范化文本,以及根据空白分割单词也是合理的。

我们可以在 图 4.6 中看到这是如何实现的,其中我们将一首诗转换成我们期望的数据结构。我们开始于诗人原本意图的原始诗歌文本,但在清理后,文本就准备好供我们分析了。

图 4.6. 我们可以使用包含正则表达式的类将一首诗转换成单词列表。

最终,我们应该得到一个看起来像以下列表的类。在列表中,我选择使用正则表达式移除所有句号、逗号、分号、冒号、感叹号、问号和连字符。

列表 4.7. 一个诗歌清理类
class PoemCleaner:
    def __init__(self):
        self.r = re.compile(r'[.,;:?!-]')           *1*

    def clean_poem(self, fp):
        with open(fp) as poem:
            no_punc = self.r.sub("",poem.read())    *2*
            return no_punc.lower().split()          *3*
  • 1 编译正则表达式以匹配所有标点

  • 2 从诗歌中移除标点

  • 3 返回没有标点的诗歌小写并分割成标记列表

4.4.4. 计算冠词的比例

接下来我们将要处理的一步,获取冠词的比例,我们将使用两个自定义函数来解决:我们已经在本章中查看过的 filter 函数和我们在 第三章 中查看过的 itertools.chain 函数,以及来自 toolz 库的新函数:frequencies。所有这些都将在一个包装函数内部进行,我们可以使用这个包装函数来传递我们的 PoemCleaner 类 (图 4.7)。

图 4.7. 一个大函数将包含所有我们的较小函数,这样我们就可以方便地应用我们的诗歌分析流程。

我们首先需要的自定义函数是确定一个单词是否应该被保留的函数。我们不想花费时间或内存去计数所有单词,因为我们只会用 athe 来确定作者身份。为此,我们将使用一个过滤器与一个辅助函数结合,将所有单词的懒序列缩小到仅包含 athe。这个辅助函数必须返回 True 如果一个单词是 athe,否则返回 False。这个辅助函数看起来像 列表 4.8。

注意

用于检测文本真实作者的函数词方法可能看起来过于简单,但它并不远离历史上最受欢迎的作者身份分析技术:发现未署名的《联邦党人文集》的作者身份。在那个例子中,使用了一个包含 30 个函数词的列表来识别詹姆斯·麦迪逊是 12 篇争议文章的唯一或主要作者。

列表 4.8. 测试一个单词是否为 athe 的函数
def word_is_desired(w):
    return w in ["a","the"]     *1*
  • 1 我们检查 w 是否在包含“a”和“the”的列表中,并返回结果。

一旦我们构建了这个函数,我们就可以将其用作 filter 函数的条件部分。该过滤器的输入序列将是我们的 .clean_poem 方法映射到诗歌路径序列。我们将应用 itertools.chain 函数到结果序列的单词序列,这样我们就可以将它们视为一个大的序列。

到目前为止,我们已经有了一种获取每个作者使用 athe 的序列的方法。现在我们需要计数并找到它们之间的比率。对于计数,toolz 库有一个名为 frequencies 的函数可以做到这一点。它接受一个序列作为输入,并返回一个 dict,其中包含在序列中出现的项目作为键,相应的值为它们出现的次数(图 4.8)。换句话说:它提供了我们序列中项目的频率。

图 4.8. frequencies 函数接受一个序列并将其转换为包含原始序列中项目及其出现次数的 dict

从这些计数中,我们可以编写另一个小函数来计算比率。该函数需要接受一个 dict 并返回 "a" 键的值除以 "the" 键的值。因为我们正在进行除法,所以使用我们的 dict.get 方法并使用一个略大于零的值是谨慎的,这样我们就不冒除以零的风险。这个辅助函数和组合诗歌分析函数应该看起来像以下列表。

列表 4.9. 诗歌分析函数
def word_ratio(d):
    return float(d.get("a",0))/float(d.get("the",0.0001))

def analyze_poems(poems, cleaner):
    return word_ratio(
        toolz.frequencies(
            filter(word_is_desired,
                itertools.chain(*map(cleaner.clean_poem, poems)))))

为了将这些内容串联起来,我们需要创建我们的 PoemCleaner 类的实例,并将我们的 analyze_poems 函数应用于每个作者的迭代器。总的来说,我们将在以下列表中看到代码。在列表的末尾,我添加了一个 print 语句,将显示作者的不同倾向,以及原始诗歌中找到的值。运行此脚本将告诉你真正的作者是谁!

列表 4.10. 诗歌谜题最终脚本
import toolz
import re, itertools
from glob import iglob

def word_ratio(d):
    return float(d.get("a",0))/float(d.get("the",0.0001))

class PoemCleaner:
    def __init__(self):
        self.r = re.compile(r'[.,;:!-]')
    def clean_poem(self, fp):
        with open(fp) as poem:
            no_punc = self.r.sub("",poem.read())
            return no_punc.lower().split()

def word_is_desired(w):
    if w in ["a","the"]:
        return True
    else: return False

def analyze_poems(poems, cleaner):
    return word_ratio(
        toolz.frequencies(
            filter(word_is_desired,
                itertools.chain(*map(cleaner.clean_poem, poems)))))

if __name__ == "__main__":

    cleaner = PoemCleaner()
    author1_poems = iglob("path/to/author_one/*.txt")
    author2_poems = iglob("path/to/author_two/*.txt")

    author1_ratio = analyze_poems(author1_poems, cleaner)
    author2_ratio = analyze_poems(author2_poems, cleaner)

    print("""
    Original_Poem:  0.3
    Author One:     {:.2f}
    Author Two:     {:.2f}
    """.format(author1_ratio, author2_ratio))

使用这个脚本,我们可以解析比我们能够处理内存中的数据更大的数据量。能够做到这一点是从只能处理小数据的开发者过渡到可以处理大数据(ish)的开发商的关键里程碑。正如我们在 iglobfilter 的例子中所看到的,懒惰在这方面帮了我们很多。接下来,我们将看到懒惰如何帮助我们生成数据。

4.5. 懒惰模拟:模拟渔村

在第 4.4 节中介绍的诗歌谜题展示了我们如何在本地机器上处理大数据;然而,我们也可以使用本章中的工具来生成大数据。

场景

一个环境保护组织委托你设计一个模拟,以说明过度捕鱼的问题。他们概述了一个涉及四个小村庄和一个湖泊的场景。这些村庄的每个人每年都同意只捕一条鱼;然而,有些年份,一个村庄会作弊,捕捞的鱼是允许的两倍。每个村庄都有其自身的作弊倾向,但如果两个村庄在同一年被发现作弊,每个村庄的作弊倾向都会增加。这些村庄每年也会增长。这些村庄能生存多少年?

对于这种模拟问题,我们通常以与之前编程略有不同的方式编程。对于模拟,我们通过编写类获得很多价值,我们之前很少以编译正则表达式之外的方式讨论过类。类很棒,因为它们允许我们整合关于模拟每个部分的资料(图 4.9)。在这个特定的模拟中,有两个演员需要特别注意,并值得拥有自己的类:

  1. 模拟的整体

  2. 村庄

图 4.9。我们可以使用类来表示复杂模拟场景中的演员,例如随着时间的推移,村庄在湖中捕鱼。

图片

将模拟视为一个类将给我们一个地方来跟踪我们处于哪一年,剩下多少鱼,以及哪些村庄与模拟相关联。它将给我们一个简单的方式来并行运行许多模拟,正如我们稍后将看到的。

将村庄视为一个类对于许多相同的原因是有用的。这些村庄都将拥有自己独特的数据,如独特的人口和独特的作弊倾向。村庄还需要做一些事情,比如每年增加人口(也许会增加他们的作弊率)。

你可能会注意到,将问题分解为两个类与我们如何在第三章中将大问题分解为一系列小辅助函数的方式相似。确实,我们将在这两个类内部进一步分解大型模拟。Lake_Simulation类将获得处理模拟本身的方法,而渔村将获得捕鱼和更新的方法。

4.5.1. 创建村庄类

在村庄和模拟之间,村庄是工作量较小的一部分,所以让我们从这里开始。对于村庄,我们将创建一个具有存储其人口和作弊率的属性的类(图 4.10)。这两个属性中的每一个都将对每个村庄是唯一的,我们不希望为每个模拟自己设置它们,所以我们将使用随机变量来代替。我将保持村庄规模较小——在 1,000 到 5,000 之间——并且作弊的量相对较低——在 0.05 到 0.15 之间。

图 4.10. Village 类代表了村庄能做的一切,包括去捕鱼和增长。

为了生成人口和作弊率的随机数,我们需要导入 Python 的 random 模块并使用它的 uniform 函数,该函数以均匀的可能性选择两个点之间的值。换句话说,该范围内的每个数字都有相同的出现可能性。我们可以为人口和作弊率调用 uniform 函数,如下面的列表所示。

列表 4.11. Village 类的起点
import random
class Village:                                     *1*
  def __init__(self):                              *2*
    self.population = random.uniform(1000,5000)    *3*
    self.cheat_rate = random.uniform(.05,.15)      *4*
  • 1 定义一个 Village

  • 2 在类初始化时自定义发生的事情

  • 3 为类提供一个在 1,000 到 5,000 之间的均匀选择的人口值

  • 4 为类提供一个介于 5% 和 15% 之间的作弊率

这就概述了 Village 类的核心,我们可以继续看村庄将要做的一些事情:比如去捕鱼和每轮更新自己。让我们首先看看去捕鱼。

去捕鱼:为我们的模拟对象实现第一个方法

每年,当一个村庄去捕鱼时,它都有作弊的选择,并且所有村庄的作弊率都不同。为了解决这个问题,我们将生成一个介于 0 和 1 之间的均匀随机变量。如果这个数字低于作弊率,那么这个村庄将会作弊;否则,它将按照规则行事。当一个村庄作弊时,每人将拿走两条鱼。当它不作弊时,每人只拿走一条鱼。最后,因为我们的模拟需要知道我们的村庄是否作弊以及它拿走了多少鱼,我们将返回拿走的鱼的数量以及村庄是否作弊,如下面的列表所示。

列表 4.12. 去捕鱼的方法
  def go_fishing(self):
    if random.uniform(0,1) < self.cheat_rate:   *1*
      cheat = 1
      fish_taken = self.population * 2
    else:                                       *2*
      cheat = 0
      fish_taken = self.population * 1
    return fish_taken, cheat                    *3*
  • 1 检查村庄是否会作弊;如果会,应用作弊规则。

  • 2 如果村庄不作弊,应用标准规则。

  • 3 最后,返回村庄拿走的鱼以及它们是否作弊。

村庄类的年度更新函数

在捕鱼之后,每个村庄每年也会有所变化。每年,人口都会增长,并且,根据当年有多少村庄作弊,一个特定的村庄可能会增加它决定作弊的比率。为了简化问题,我们的村庄每年将以 2.5% 的比率增长。

为了决定是否增加作弊率,我们需要知道模拟中今年有多少作弊者。因为这个信息包含在模拟类中,所以我们需要将模拟传递给 .update 方法,如下面的列表所示。

列表 4.13. 更新我们的村庄
  def update(self, sim):
    if sim.cheaters >= 2:                          *1*
      self.cheat_rate += .05
    self.population = int(self.population*1.025)   *2*
  • 1 如果我们发现超过两个作弊者,增加作弊率。

  • 2 不论如何增加人口。

4.5.2. 为我们的捕鱼模拟设计模拟类

这两个方法,.go_fishing.update,完善了我们的 Village 类。我们将使用这个类来表示模拟中的村庄(图 4.11)。此外,如前所述,我们还需要一个表示模拟本身的类。这个类将跟踪模拟级别的变量,例如现在是哪一年以及还剩下多少鱼。此外,我们的模拟类将有一个相当大的方法来运行模拟本身。

图 4.11. 我们的模拟是一个循环过程,其中我们捕鱼,检查是否需要停止模拟,如果我们将继续,则更新模拟,然后再次捕鱼。

使用 .init 方法设置模拟

我们模拟的开始是它的 .__init__ 方法,该方法将设置模拟(列表 4.14)。为了设置模拟,我们只需要四件事:

  1. 村庄— 由一个村庄对象列表表示,在我们的例子中是 4

  2. 鱼— 在这种情况下,只是鱼的数量

  3. 起始年份— 也是一个数字,在我们的例子中是 1

  4. 作弊者的数量— 再次,一个整数,表示作弊村庄的数量

我们将把这些变量分配给模拟类本身,因此随着模拟的变化,这些变量也会随之变化。

列表 4.14. 设置模拟
class Lake_Simulation:
  def __init__(self):
    self.villages = [Village() for _ in range(4)]
    self.fish = 80000
    self.year = 1
    self.cheaters = 0
在我们的 .simulate 方法中编写模拟逻辑

模拟逻辑将放在模拟类的 .simulate 方法中。这意味着这个方法将负责

  • 找出一年捕鱼的结果

  • 每年更新模拟

  • 如果我们用完鱼,就结束模拟

  • 如果我们“存活”足够长,就结束模拟

由于我们的模拟可以无限进行,如果湖里永远不会过度捕捞,我们将以一个无限循环开始我们的 .simulate 方法;在这种情况下,我们将使用一个无限 for 循环:

for _ in itertools.count():

itertools.count() 函数返回一个生成器,它产生一个无限增长的数字序列(1, 2, 3, 4, . . . 1000, 1001, 1002, . . . 无穷大)。通过使用“_”,我们告诉 Python 忽略 for 循环返回的值,因为我们不需要它。

村庄捕鱼:介绍 map 和类的方法 caller

在设置好循环之后,我们可以开始寻找我们捕鱼年份的结果。对于我们的模拟,我们每个村庄都会单独去捕鱼。这就是为什么我们设置了带有.go_fishing方法的村庄类。为了使所有村庄都去捕鱼,我们可以将它们的.go_fishing方法映射到模拟的.villages属性中的类列表上。

要做到这一点,我们需要operator.methodcaller函数。methodcaller接受一个字符串并返回一个函数,该函数在传递给它的任何对象上调用具有该字符串名称的方法。由于我们在本书中查看的 map 和 reduce 编程风格非常面向函数,能够使用函数调用类方法是非常有帮助的。这种能力使我们能够使用mapfilter等函数对它们进行操作。

从那里,因为我们的.go_fishing方法返回一个捕获的鱼和表示该村庄是否作弊的数字的tuple,我们将这个函数映射到村庄列表上的输出看起来就像我们在捕获的鱼和作弊指示符的序列上使用了zip函数一样。了解这一点后,我们可以解压缩tuple序列并取各个序列的总和,这将给出捕获的总鱼数和作弊者的总数。

解压缩是压缩的反义词。而压缩接受两个序列并返回一个tuple列表,解压缩则接受一个单一序列并返回两个。当我们调用zip函数时,可以通过在列表前加一个星号来调用unzipzip(*my_sequence)。我们可以在下面的列表中看到unzip和我们的模拟步骤的第一阶段的其余部分。

列表 4.15. 所有村庄都去捕鱼
for _ in itertools.count():
        yearly_results = map(methodcaller("go_fishing"), self.villages)
        fishes, cheats = zip(*yearly_results)                           *1*
        total_fished = sum(fishes)                                      *2*
        self.cheaters = sum(cheats)                                     *3*
  • 1 将 yearly_results 解压缩成两个列表:fishes 和 cheats

  • 2 鱼列表包含每个村庄捕捞的鱼的数量,其总和是捕捞的总鱼数。

  • 3 欺骗列表包含每个作弊村庄的 1,其总和是作弊者的数量。

在我们弄清楚有多少作弊者和捕捞了多少鱼之后,我们将检查模拟是否应该结束,或者我们应该继续进行。为此,我们将使用两个if检查,每个检查都将中断我们的无限for循环。

  1. 第一个if检查将检查我们是否已经通过了 1,000 个模拟年份。

  2. 第二个if检查将检查是否所有的鱼都被捕捞了。

如果这些条件中的任何一个被触发,我们将在屏幕上打印一条消息来解释发生了什么。如果我们想存储我们的模拟结果,这将是一个将我们的模拟结果写入文件的好地方。下面的列表显示了这段代码的短小片段看起来是什么样子。

列表 4.16. 检查模拟是否应该结束
if self.year > 1000:
    print("Wow! Your villages lasted 1000 years!")
    break
elif self.fish < total_fished:
    print("The lake was overfished in {} years.".format(self.year))
    break
最终计算:解决年份

如果我们通过了年末检查,我们可以更新我们的模拟以反映该年。更新模拟涉及从剩余的鱼中移除捕获的鱼,以一定数量重新繁殖鱼(毕竟,鱼会繁殖),并更新所有村庄。如果你愿意,我们也可以在这里添加一个 print 语句,这样我们就可以看到每年发生了什么。

为了更新剩余鱼的数量,我们将从 self.fish 中减去 total_fished,然后增加 self.fish 的 15%。为了更新所有村庄,我们再次在所有村庄上映射 methodcaller。然而,这次我们将调用 .update 方法而不是 .go_fishing。最后,对于 print 语句,我建议至少包括年份和剩余鱼的数量。请参见以下列表中所示的内容。

列表 4.17. .simulate 方法
else:
    self.fish = (self.fish-total_fished)* 1.15                   *1*
    map(methodcaller("update"), self.villages)                   *2*
    print("Year {:<5}   Fish: {}".format(self.year,
                                                 int(self.fish)))
            self.year += 1
  • 1 更新剩余的鱼

  • 2 通过映射“update”方法更新村庄

有了这些,我们就完成了我们的捕鱼模拟!初始化一个 Lake_Simulation 类,并多次调用 .simulate 方法以查看发生了什么。每次运行模拟时,你应该得到不同的生存年数。以下列表显示了完整的模拟代码。

列表 4.18. 完整的捕鱼模拟
import random, itertools
from operator import methodcaller

class Village:
  def __init__(self):
    self.population = random.uniform(1000,5000)
    self.cheat_rate = random.uniform(.05,.15)

  def update(self, sim):
    if sim.cheaters >= 2:
      self.cheat_rate += .05
    self.population = int(self.population*1.025)

  def go_fishing(self):
    if random.uniform(0,1) < self.cheat_rate:
      cheat = 1
      fish_taken = self.population * 2
    else:
      cheat = 0
      fish_taken = self.population * 1
    return fish_taken, cheat

class Lake_Simulation:
  def __init__(self):
    self.villages = [Village() for _ in range(4)]
    self.fish = 80000
    self.year = 1
    self.cheaters = 0

  def simulate(self):
    for _ in itertools.count():
        yearly_results = map(methodcaller("go_fishing"), self.villages)
        fishes, cheats = zip(*yearly_results)
        total_fished = sum(fishes)
        self.cheaters = sum(cheats)
        if self.year > 1000:
            print("Wow! Your villages lasted 1000 years!")
            break
        if self.fish < total_fished:
            print("The lake was overfished in {} years.".format(self.year))
            break
        else:
            self.fish -= total_fished
            self.fish = self.fish*1.15
            map(lambda x:x.update(self), self.villages)
            print("Year {:<5}   Fish: {}".format(self.year,
                                                 int(self.fish)))
            self.year += 1

if __name__ == "__main__":
    random.seed("map and reduce")
    Lake = Lake_Simulation()
    Lake.simulate()
    Lake.simulate()
    Lake.simulate()
    Lake.simulate()

我们可以运行这个模拟几次(每次注释掉或更改随机种子),以查看不同的结果。我们的模拟输出将是年份,打印到终端,显示该年剩余的鱼的数量。通常,我们的模拟会在大约 10 年后结束,如 列表 4.19 所示。有时,它将持续数千次运行。

模拟和随机种子

当我们运行模拟时,我们会使用大量的随机数生成器。我们的代码中的随机性可能会在与其他人共享时造成困惑,因为他们期望得到与我们相同的结果。我们可以通过使用 随机种子 来解决这个问题。通过设置种子,我们可以确保我们将得到有效随机的数字,但这些数字将每次都在相同的序列中。任何其他使用相同代码和相同随机种子的用户都将得到与我们相同的结果。

列表 4.19. 捕鱼场景输出
Year 1      Fish: 77183               *1*
Year 2      Fish: 70035               *1*
Year 3      Fish: 65724               *1*
Year 4      Fish: 60766               *1*
Year 5      Fish: 49965               *1*
Year 6      Fish: 42644               *1*
Year 7      Fish: 30315               *1*
Year 8      Fish: 20046               *1*
Year 9      Fish: 8327                *1*
The lake was overfished in 10 years
  • 1 在大多数场景的运行中,湖泊将在大约十年左右被过度捕捞。

这些长时间运行代表的是村庄在模拟早期阶段避免作弊的场景。你可以调整作弊率、捕获的鱼的数量和鱼群增长率,以查看模拟在不同假设下的行为。

4.6. 练*

4.6.1. 懒惰函数

在我们使用 Python 中的 map 和 reduce 风格时,懒惰函数很常见。以下哪些函数是懒惰的?

  • map

  • reduce

  • filter

  • list

  • zip

  • sum

  • range

  • len

4.6.2. Fizz buzz 生成器

一个经典的玩具编程问题就是 fizz buzz 问题,其中我们想要将能被 3 整除的数字替换为 fizz,将能被 5 整除的数字替换为 buzz。如果一个数字既能被 fizz 也能被 buzz 整除,则应将其替换为 fizz buzz。我们在第二章中实现了一个使用类的版本。创建一个生成器来解决这个问题。提示:记住,你可以使用取模运算符(%)来检查除法是否产生余数。

4.6.3. 重复访问

当我们使用内置的生成器函数,如range时,我们只能迭代一次。为什么是这样?

4.6.4. 并行模拟

有许多方法可以并行运行多个模拟。一种方法是将我们的Lake_Simulation类的.simulate方法映射到序列上,使用我们在第二章中引入的with Pool() as P:构造。修改列表 4.18 中的代码,以便可以并行运行模拟。

4.6.5. Scrabble 单词

流行游戏 Scrabble 涉及在棋盘上放置拼字来拼写单词。拼写长单词和包含更多罕见字母的单词可以获得更多分数。在一个简化的版本中,Z 值 10 分;F、H、V 和 W 值 5 分;B、C、M 和 P 值 3 分;其他所有字母值 1 分。使用mapfilter函数,将单词列表减少到只包含价值超过八分的单词:zebra、fever、charm、mouse、hair、brill、thorn。

摘要

  • 惰性函数是那些只有在需要它们返回的值时才进行评估的函数——不会早也不会晚。我们可以使用像mapfilterzipiglob这样的惰性函数来处理我们笔记本电脑上的大量数据。

  • Python 通过迭代器实现惰性,我们可以自己创建迭代器,从函数中接收迭代器,或使用方便的生成器函数和语句构建迭代器。

  • 我们可以使用yield语句或通过简洁而强大的类似列表解析的生成器表达式来创建使用函数的生成器。

  • 我们只能以一个方向遍历迭代器;一旦我们从一个迭代器中看到了一个元素,我们就永远无法再次访问该元素。

  • 我们可以使用filter函数方便地收集列表的一个子集。与filter函数类似,有一系列函数:filterfalsevalfilterkeyfilteritemfilter

  • 我们可以使用zip将两个列表组合成一个单一的tuple序列——当与map结合使用时,这是一个实用的技巧。

  • 我们可以使用来自 toolz 库的frequencies来获取序列中唯一元素的数量。

  • 我们可以将惰性函数和生成器应用于解决数据密集型问题,例如文本分析和模拟。

  • 我们可以使用methodcaller将对象的方法映射到该对象的序列上。

第五章. 使用 reduce 进行累积操作

本章涵盖

  • 识别 N-to-X 数据转换的reduce模式

  • 编写用于减少的辅助函数

  • 编写用于简单归约的 lambda 函数

  • 使用reduce总结数据

在第二章中,我们学*了 map 和 reduce 编程风格的第一个部分:map。在本章中,我们将介绍第二个部分:reduce。正如我们在第二章中提到的,map执行 N 到 N 的转换。也就是说,如果我们有一个想要将一个序列转换成相同大小序列的情况,map就是我们的首选函数。在我们回顾的例子中,这包括文件处理(我们有一个文件列表,我们想要对它们中的每一个都做些什么;在第四章讨论)和网页抓取(我们有一个网站列表,我们想要获取每个网站的内容;在第二章讨论)。

在本章中,我们将专注于reduce进行 N 到 X 的转换;也就是说,我们有一些序列,但我们想要得到除了另一个相同大小的序列之外的东西,通常是不同大小的序列,或者甚至根本不是序列。然而,确实存在我们实际上想要使用reduce来得到相同大小序列的情况。我们将探讨所有这些情况,了解reduce并在一些你已熟悉的常见转换中使用它。学*reduce将为我们提供一个方便的工具,在map不适用但仍然想要从使用通用编程模式中受益的情况下使用。

5.1. 使用reduce进行 N 到 X 转换

当我们说reduce是一个 N 到 X 的转换函数时,我们的意思是,无论何时我们有一个序列并且想要将其转换成我们不能使用map的东西,我们都可以愉快地使用reduce。这是mapreduce如此完美搭配的原因之一:map可以以非常简洁的方式处理大多数转换,而reduce则可以处理最后的转换,尽管它的方式可能不那么优雅。

一个你已经熟悉的 N 到 X 转换的例子,我们将在第 5.2 节中更详细地探讨,就是求和函数。在数学中,这通常用Σ表示。在 Python 中,我们可以从基础库中访问sum函数。求和函数接受一个数字序列(整数、浮点数、虚数)并返回一个单一的数字,它是序列中所有数字的总和(图 5.1)。

图 5.1. 求和函数是大多数人已经知道的 reduce 模式的一个常见例子。

图片

例如,如果我们有一个包含数字 10、5、1、19、11 和 203 的序列,我们可以将它们相加并得到一个单一的数字。这样,我们就将我们的六个原始数字缩减到只有一个结果数字。我们将数据的大小从 N(6)缩减到 X(1)。这就是 reduce 模式的本质:将一个序列转换成其他东西。

5.2. Reduce 的三个部分

使用 reduce 对数字序列进行求和很简单,但它仍然需要 reduce 函数的三个部分(图 5.2):

  1. 一个累加器函数

  2. 一个序列

  3. 一个初始化器

图 5.2. reduce 函数有三个部分:累加器,它指定 reduce 的行为;一个序列,我们对其执行 reduce 操作;以及一个初始值,我们用它来开始我们的 reduce 操作。

累加器函数为 reduce 执行繁重的工作。它是一种特殊的辅助函数,就像我们在第二章、第三章、第四章中使用 map 时所用的那些函数一样。序列是一个我们可以遍历的对象,例如列表、字符串和生成器。我们的初始化器是要传递给累加器的初始值。在大多数 reduce 的实现中,此参数是可选的。

如果我们要对数字序列进行求和,我们希望

  1. 使我们的累加器函数成为一个加法函数

  2. 我们要处理的序列是我们想要求和的数字序列

  3. 我们的初始值设为 0 以从零开始计数

在 Python 中,这可能会看起来像以下列表。要运行此代码,您需要定义一个加法函数——my_add。我们将在下一节关于累积函数的子节中这样做。

列表 5.1. reduce 的三个部分
from functools import reduce   *1*

xs = [10,5,1,19,11,203]        *2*

reduce(my_add, xs, 0)          *3*
  • 1 首先,我们需要从 functools 库中导入 reduce

  • 2 然后,我们可以设置我们的数据以进行求和。

  • 3 当我们调用 reduce 时,请注意累加器先出现,然后是序列,最后是初始值。

列表 5.1 提供了使用 reduce 进行求和的示例。关于这段简短代码有两点值得注意。首先,我们需要从 functools 库中导入 reducereduce 函数不是像 map 那样默认导入,尽管它在任何 Python 发行版中都是可用的。在 Python 的旧版本(2.7 及以下)中,reduce 是默认可用的。

从基础 Python 中移除 reduce 函数

在 2002 年,Python 的创造者 Guido van Rossum 将本书中包含的许多方法称为一个错误。他认为这些方法损害了可读性,特别是 reduce 方法对大多数人来说很难理解。我不同意。Reduce 只是未被广泛教授。此外,并行和分布式计算的出现使得这些工具变得极其有价值。

在本章中,你将了解一个强大、多功能的工具,Python 语言的维护者不希望你了解这个工具。

其次,我们为reduce放置参数的顺序是特定的。像map一样,累加器或辅助函数首先出现,然后是序列,最后是我们的初始化器。初始化器放在最后,因为它是一个可选参数。

5.2.1. reduce中的累加函数

累加函数都具有一个共同的原型。它们接收一个累加值和序列中的下一个元素,并返回另一个对象,通常是累加值的同一类型。例如,在我们的求和函数中,我们希望接收到目前为止的总和作为累加值,序列中的下一个元素作为下一个值,并将它们相加。相应的代码如下所示。

列表 5.2. 求和的累加函数
def my_add(acc, nxt):    *1*
    return acc + nxt     *2*
  • 1 我们的 my_add 函数接收一个累加值(acc)和下一个元素(nxt)。

  • 2 它返回这两个值相加的结果,这将是一个新的数字,就像 acc 一样。

关于这一行函数,你需要注意的一点是变量名。我偏好的命名约定是将变量标记为reduce累加函数,使用acc表示累加值,nxt表示下一个值;然而,还有其他方式。一些更简洁的团队喜欢使用a来表示累加器,b来表示下一个值。你也可能会看到使用left来表示累加器,right来表示下一个值。为了理解为什么累加函数需要接收一个累加值和下一个元素,了解reduce如何进行转换是有帮助的。

如何进行递减

在其最简单的实现中,reduce遍历序列,与累加值一起处理每个元素。这个累加值开始时要么是我们提供的初始化值,要么是没有提供时序列的第一个元素。例如,当reduce对 10、5、1、19、11 和 203 进行求和时,它是将 10 加到 0 得到 10,然后将 5 加到当前总和中(10)得到 15,然后将 1 加到那个数上得到 16,然后将 19 加到那个数上得到 35,依此类推,直到所有数字都被处理(图 5.3)。总值是累加值。序列中的下一个值是下一个值。

从左到右递减

因为reduce是从左到右遍历序列的,正如我们刚才提到的,一些团队会在他们的累加辅助函数中称累加值为left,将这些函数传递的下一个值为right。在其他编程语言中的reduce函数版本可以从右到左进行递减。在这些情况下,团队可以很容易地判断哪些函数是为左到右递减编写的,哪些是为右到左递减编写的。

图 5.3. reduce 函数通过处理序列中的每个元素并将其与累加器值连接来工作。

图片

当这些数据结构和函数很简单时,reduce 可能看起来是不必要的;然而,当数据结构和我们想要对其进行的转换变得更加复杂时,我们可以使用 reduce 来使我们的转换更加透明。更复杂的 reduce 实现,如我们将在 第六章 中看到的,还允许进行并行归约,这提供了与并行 map 相同的性能改进,而无需对我们的代码进行大量重写。

测试我们的求和函数

到目前为止,我们有一个工作的求和归约。您可以自由运行 列表 5.1 和 5.2 中的组合代码。如果您将 reduce 调用包裹在 print 函数中,您应该在屏幕上看到一个整数被打印出来。与 第四章 中我们查看的 map 和惰性数据类型不同,reduce 在被调用时进行评估。

5.2.2. 使用 lambda 函数进行简洁的累积

当您在编写 列表 5.2 的代码时,您可能已经在思考,有时为像将两个数字相加这样的单行语句创建整个函数似乎很愚蠢。在这种情况下,通常使用 lambda 函数而不是定义一个函数。

Lambda 函数也被称为匿名函数,因为我们没有将它们保存到命名空间中。尽管我们完全可以随时随地在任何地方调用我们的 my_add 函数,但匿名函数仅存在于 reduce 调用中,并且在该单个命令的作用域之外不可用。对于小型操作,这真的很好。我们甚至不必担心这些函数的命名。对于大型操作,我们更愿意有一个可调用的函数。

Python 中的 lambda 函数由三部分定义 (图 5.4):

  1. lambda 关键字

  2. 函数将接受的参数

  3. 一个冒号和函数将要执行的语句

图 5.4. 当我们不需要再次使用函数的行为时,我们可以在 mapreduce 操作中使用 lambda 函数代替标准函数。

图片

例如,我们的 my_add 函数可以是一个简单的 lambda 函数。

你会注意到 lambda 关键字是我们语句中的第一件事,后面跟着我们的两个参数:accnxt。这两个参数由逗号分隔,就像它们在正常函数声明中一样。然而,与函数声明不同的是,我们不会找到任何函数名。此外,我们在同一行上参数之后立即声明函数的行为,只由冒号和空格分隔。最后,你会注意到这个 lambda 语句返回一个函数。如果我们想的话,我们可以将其分配给一个变量并像正常函数一样使用它;然而,通常我们只想完成我们的匿名、一次性 lambda 函数。

lambda 函数的最佳用途是在我们的 reduce 调用中直接声明它。要做到这一点,我们只需在 reduce 函数的第一个位置(即累积函数的位置)写下 lambda 语句,留下后两个位置用于我们的序列和初始化器。例如,我们可以使用 lambda 函数将 列表 5.1 和 5.2 中的代码简化到以下列表中的代码。

列表 5.3. reduce 中的 lambda 函数用于求和
from functools import reduce

xs = [10, 5, 1, 19, 11, 203]
print(reduce(lambda acc, nxt: acc+nxt, xs, 0))    *1*
  • 1 我们的 lambda 加法函数位于 reduce 语句的第一个位置。

这段代码实现了我们之前代码相同的结果。不过,这次我们不需要为我们的加法函数留出空间。在这种情况下,使用 lambda 函数效果很好,因为我们的任务很小:我们正在添加两个数字。其他使用 lambda 的情况包括当我们想要公开类方法或属性时。

例如,我们可以使用 lambda 函数来公开 dict 类的 .get 方法并计算几个产品的价格总和。我们可以在下面的列表中看到这一点。

列表 5.4. Lambda 函数可以用来公开类方法
from functools import reduce

my_products = [
    {"price": 9.99,                                                *1*
     "sn": '00231'},                                               *1*
    {"price": 59.99,
     "sn": '11010'},
    {"price": 74.99,
     "sn": '00013'},
    {"price": 19.99,      "sn": '00831'},
]

reduce(lambda acc, nxt: acc+nxt.get("price", 0), my_products, 0)   *2*
  • 1 我们的产品数据存储在字典中,每个字典包含一个价格和一个序列号(sn)。

  • 2 我们可以在 lambda 函数中调用每个字典的 .get 方法来添加价格。

列表 5.4 展示了一个经典的 lambda 函数:我们需要做一些稍微复杂的事情,比如获取 dict 中价格键的值并将其添加到另一个值中,但不是我们一定会再次想要做的事情。我们的 lambda 函数仍然非常易于阅读,而且创建一个完整的函数来从 dict 中获取值并添加到另一个值中会显得很愚蠢。

5.2.3. reduce 中复杂起始行为的初始化器

reduce 问题的最后一部分是初始化参数。初始化参数是 reduce 操作将用作第一个累积值的值。我们可以将其视为在序列的开头插入该值,并将所有其他值向右移动 1 位(图 5.5)。

图 5.5. 初始化值将所有值向右移动 1 位,改变了 reduce 操作的起始值。

对于我们的求和简化,添加一个初始值 10 将会使整个reduce增加 10。而不是从第一个值(默认)或从零开始,我们将从 10 开始累加。如果我们想给所有订单添加一个 10 的处理费,例如,这可能会很有用。

然而,大多数情况下,我们想要使用初始值,并不是因为我们想要改变数据的,而是因为我们想要改变数据的类型。通过用不同类型的值初始化我们的reduce,我们的累加函数可以期望有两个不同的类型参数,即使我们有一个所有值都是同一类型的列表。如果我们改变求和简化中的整数 0 为浮点数 0,0.0,就像以下列表所示,我们可以看到这一点是如何发挥作用的。

列表 5.5:使用浮点数初始化求和函数
from functools import reduce

xs = [10, 5, 1, 19, 11, 203]

print(reduce(lambda acc, nxt: acc+nxt, xs, 0.0))     *1*
  • 1 将最后一个参数从整数(0)更改为浮点数(0.0)会改变输出

如列表 5.5 所示,将浮点数插入到我们的求和简化中,会改变我们求和的最终输出类型为浮点数。这是因为浮点数加整数在 Python 中总是返回浮点数。由于累加函数总是有一个浮点数作为其累加参数(图 5.6),这种效果会跨简化级联。

图 5.6:初始器的类型通常决定了我们累加函数的行为。

这种模式,即我们使用初始值来改变我们序列的类型,将会是一个常见的现象。我们经常希望我们的累加器能够接受并返回与列表中元素类型不同的类型。这比我们仅使用单一数据类型所能实现的转换种类更广泛。我们将在 5.3.2 节中稍后看到一个该模式的示例,并在本章稍后再次看到。

5.3:你熟悉的简化

在查看使用求和函数的基本reduce之后,让我们看看这本书中你已经看到的另外两个简化:

  1. filter

  2. frequencies

我们在第四章中探讨了这两个函数。filter函数返回一个列表,其中包含对给定条件评估为True的项目。frequencies函数返回一个dict,其键是列表的唯一元素,其值是列表中这些项的计数。

5.3.1. 使用reduce创建过滤器

对于我们的filter简化,让我们执行一个只返回偶数的filter操作。这样,我们可以将此代码与我们之前在第四章中工作的一些示例进行比较。然而,在直接进入简化之前,我们应该思考这种简化将看起来是什么样子(图 5.7)。

图 5.7:filter简化是一个从某个大小的列表到某个更小或相等大小的列表的 N-to-X 转换。

filter函数从一个某种类型的序列开始,因此我们知道它是一个很好的归约候选。在这个实例中,我们的输出数据将是一个长度等于或小于我们之前序列长度的列表。例如,如果我们有一个所有数字都是 2(一个偶数)的序列,那么我们的归约应该返回相同的序列作为一个列表。相反,如果所有数字都是奇数,我们的归约应该返回一个空列表。

通过思考这个问题,我们可以对我们的归约行为和如何设置它有一个大致的了解。我们知道在某些情况下,我们需要能够返回一个空列表,因此用空列表初始化我们的归约是有意义的。我们归约的其余部分将取决于我们设计的累积函数。因为我们试图筛选出仅包含偶数的值,所以我将把这个函数命名为keep_if_even

keep_if_even函数将需要接收两个东西:

  1. 累积值(一个偶数列表)

  2. 我们序列中的下一个值

函数还需要返回原始累积值,如果下一个值不是偶数,或者如果下一个值是偶数,则返回累积值加上新值。这个函数在下面的列表中实现。

列表 5.6. 一个“它是偶数吗?”过滤器归约
from functools import reduce

xs = [1, 2, 3, 4, 5, 6, 7, 8, 9]

def keep_if_even(acc, nxt):           *1*
    if nxt % 2 == 0:                  *2*
        return acc + [nxt]            *3*
    else: return acc                  *4*

print(reduce(keep_if_even, xs, []))   *4*
  • 1 我们的累积函数期望一个累积值(一个列表)和下一个项目(一个数字)。

  • 2 检查下一个项目是否为偶数

  • 3 如果它是,我们将其添加到我们的累积中并返回一个新的列表。

  • 4 如果它不是,我们返回原始累积。

列表 5.6 中的大部分代码将与我们之前在这个章节或第四章中看到的代码相似。然而,有一点需要指出的是,我们使用构造acc + [nxt]而不是acc.append(nxt)。我们第一次在这个章节的列表 5.2 中使用accnxt参数名,其中acc代表累积值,而nxt代表我们序列中的下一个元素。

我们在这里不使用.append方法,因为尽管.append是向列表中添加值的推荐方式,但我们的累积函数始终需要返回一个值。按照设计,.append方法会就地修改列表并返回None。这迫使我们使用acc + [nxt],它返回一个新的列表。

你也会注意到,这个过滤器在前面几段中确定的边缘情况下按预期工作。如果我们传入一个包含所有 2 的列表,我们将得到同样大小的列表。如果我们传入一个包含所有奇数(比如,3)的列表,我们将得到一个空列表。

5.3.2. 使用 reduce 创建频率

我们将要解决的下一类归约是频率归约。频率,我们在第四章(frequencies)中看到,是计数序列元素的一种方式。再次,让我们停下来思考这个函数中正在进行的 N-to-X 转换。我们将从一个序列(N)开始,并希望最终得到一个 dict,其中包含一些键,每个键对应于序列中的一个唯一元素,以及一个值总计它们在序列中的计数(图 5.8)。

图 5.8. 我们的频率归约将列表转换为一个 dict,其中键对应于每个唯一元素,值总计它们的计数。我们需要初始化为一个 dict,因为累加函数接受两种不同类型的参数。

我们频率归约的累加函数将接受一个 dict 作为累加值和一个杂项元素作为我们的下一个值。它必须返回一个 dict,这样在我们遍历序列时,我们可以确保我们始终有一个 dict 作为我们的累加值。它还必须计数元素。为此,我们将该元素的值作为键增加 1。此外,这次,让我们将我们的 reduce 操作包装在一个函数中,这样我们就可以重用它。

列表 5.7 提供了累加函数和归约的代码,以及一些测试数据和打印语句,这些语句展示了我们的函数按预期工作。在这些语句中,我们可以看到我们的 frequencies 函数可以用来计数所有不同类型的序列。我们能够做到这一点,因为 reduce 不关心我们正在迭代的对象类型,以及我们的累加函数不依赖于序列中的对象是特定类型。我们还看到了初始化我们的归约为一个空 dict 的重要性,这样我们就可以从开始就使用 .get 方法。

列表 5.7. 使用归约查找频率
from functools import reduce

def make_counts(acc, nxt):                *1*
    acc[nxt] = acc.get(nxt, 0) + 1        *2*
    return acc                            *3*

def my_frequencies(xs):                   *4*
    return reduce(make_counts, xs, {})    *5*

xs = ["A", "B", "C", "A", "A", "C", "A"]
ys = [1, 3, 6, 1, 2, 9, 3, 12]

print(my_frequencies(xs))
print(my_frequencies(ys))
print(my_frequencies("mississippi"))
  • 1 我们的 make_counts 函数具有标准的累加函数参数:acc 和 nxt。

  • 2 对于我们遇到的每个元素,我们将该元素出现的次数增加 1。

  • 3 在函数结束时返回累加值

  • 4 我们的频率归约函数只需要接受某种类型的序列。

  • 5 我们的 reduce 语句使用了我们刚刚创建的 make_counts 函数,以及一个空的字典作为初始化对象。

5.4. 结合使用 map 和 reduce

到目前为止,我们已经介绍了 reduce 的基础知识。如果你可以将一个问题分解为 N-to-X 转换,那么解决该问题的归约与一个精心设计的累加函数之间的所有东西就只是你了。话虽如此,如果我们不讨论如何在 map-reduce 模式中与 map 结合使用 reduce,那就有些疏忽了。

到目前为止,我们一直关注的是我们想要得到的数据中至少有一部分直接来自我们的序列。

  1. 在求和归约(列表 5.3)中,我们需要列表中的值。

  2. 在过滤归约(列表 5.6)中,我们希望得到满足给定条件的值。

  3. 在频率归约(列表 5.7)中,我们使用了序列元素作为我们的 dict 的键。

并非总是如此。有时我们不想处理序列中的数据,只想处理与序列有一定关联的数据。一个经典的例子是我们有一个文件路径序列,想要打开这些文件并对它们进行一些操作。我们在第四章的诗歌谜题示例中看到了这一点。在那个例子中,我们有一堆文件;然而,对我们来说,这些文件的内容才是有趣的,而不是文件本身。

这个问题的另一个版本可能是对第四章末的 Scrabble 练*的一种变体。如果我们不是过滤我们的列表,只保留达到某些得分阈值的单词,而是将列表中单词所代表的分数全部相加会怎样?在那个例子中,列表可能包含我们迄今为止评分的单词,它们的总和就是我们的总分。为了找到我们的总分,我们希望将单词转换为它们的分数(一个 N 到 N 的转换),然后将这些分数归约成一个总分(一个 N 到 X 的转换)图 5.9。因为这个过程代表了 N 到 N 的转换和 N 到 X 的转换,我们可以同时使用 mapreducemap 用于将单词转换为分数,reduce 用于将它们求和。

图 5.9. 我们可以使用 map 和 reduce 模式将单词转换为分数,然后计算这些分数的总和。

图片

要做到这一点,我们需要构建两个辅助函数:一个用于 map,一个用于 reduce。如果你完成了练* 4.6.5,你已经有这两个函数了。(如果你没有,你现在可以完成这个练*,或者在这个书的源代码仓库github.com/jtwool/mastering-large-datasets中找到代码。)map 的辅助函数需要接受一个单词并返回一个分数。就像在练* 4.6.5 中一样,我们将使用简化的评分方案:Z 值为 10 分;F、H、V 和 W 值为 5 分;B、C、M 和 P 值为 3 分;所有其他字母值均为 1 分。reduce 的辅助函数将是 列表 5.2 中的辅助函数或 列表 5.3 中的 lambda 表达式——两者都可以使用。

在这两个辅助函数就绪的情况下,为了找到我们的总分,我们需要将评分函数映射到我们的单词上,并对映射的结果进行reduce操作。我们可以在下面的列表中看到整个流程。

列表 5.8. 使用 mapreduce 评分单词
from functools import reduce

def score_word(word):
    points = 0
    for char in word:
        if char == "z": points += 10
        elif char in ["f", "h", "v", "w"]: points += 5
        elif char in ["b", "c", "m", "p"]: points += 3
        else: points += 1
    return points

words = ["these", "are", "my", "words"]

total_score = reduce(lambda acc,nxt: acc+nxt,    *1*
                     map(score_word, words))     *1*
print(total_score)
  • 1 这种归约与我们在本章开头使用的求和归约相同,只是我们没有向reduce传递一系列数字,而是传递了我们的map操作的结果。

mapreduce的力量在于其执行的简单性。当我们实际执行reducemap语句时,我们只使用一行代码,尽管这一行代码通过调用的辅助函数实现了复杂的行为。我们可以使用mapreduce模式来解耦转换逻辑——我们想要对我们数据进行的事情——与实际的转换本身。这允许我们保持简单,并导致高度可重用的代码。当处理大型数据集时,保持我们的函数简单变得至关重要,因为我们可能需要等待很长时间才能发现我们犯了一个小错误。

5.5. 使用 reduce 分析汽车趋势

在我们离开第五章并开始并行查看reduce之前,让我们尝试一个更复杂的归约场景。

场景

您的客户是一位二手车经销商。他们有过去六个月内购买和销售汽车的数据,希望您能帮助他们找到哪种类型的二手车能带来最大的利润。一位销售员认为高燃油效率的汽车(每加仑超过 35 英里(mpg)的汽车)能带来最多的收入,而另一位销售员认为中等里程的汽车(里程表在 60,000 到 100,000 英里之间)在转售时能带来最高的平均利润。给定一个包含一些二手车各种属性的 CSV 文件,编写一个脚本来找出低(<18 mpg)、中(18–35 mpg)和高(>35 mpg)燃油效率的汽车的平均利润,以及低(<60,000 英里)、中(60,000–100,000 英里)和高(>100,000 英里)里程的汽车的平均利润,以解决这场辩论。

在我们深入探讨问题的细节之前,让我们看看它的基础:数据转换。我们将从一个代表车辆的dict系列开始。默认情况下,这些dict将包含我们不感兴趣的大量信息,并且可能缺少一些我们想要的信息,因此将数据转换成更适合分析的好格式是个好主意。我们将使用map来处理这个问题,因为我们想要清理每个dict。从那里,我们希望将数据汇总到一个dict中,这个dict可以帮助我们了解每种类型的汽车能产生多少利润。这需要归约。

总体来说,整个问题看起来会像图 5.10 那样。在左侧,我们从客户给出的数据开始。我们将构造一个函数来清理每条记录,并将该函数映射到我们的数据上。然后,我们将它传递给reduce函数,该函数本身有一个我们设计的累加器函数,用于收集必要的信息。为此,我们需要按组收集总和和计数——这两个数字是计算平均数所必需的。

图 5.10. 我们可以使用一个map步骤来清理汽车数据,以及一个reduce步骤来将数据累积到一个回答我们问题的单一数据结构中,来解决这个问题。

图片

5.5.1. 使用map清理汽车数据

为了设计我们的清理辅助函数,让我们首先更仔细地看看我们将要工作的单个元素。我们的数据集中的每辆车将看起来像图 5.11。

图 5.11. 我们的数据集中的每辆车都将有许多属性,其中只有四个是我们真正关心的:购买价格、销售价格、mpg 和 odo。我们将使用map和一个辅助函数将这些数值变量转换为分类变量,以便更容易比较。

图片

对于每个条目,我们将有一个包含许多属性(我们不太感兴趣)的dict,以及四个我们感兴趣的属性:price-buyprice-sellmpgodo。在我们的dict中的这四个键代表汽车购买时的价格、销售时的价格、车辆列出的每加仑里程数以及汽车上的里程数。然而,我们实际上并不直接对任何这些变量的值感兴趣。相反,我们感兴趣的是可以从它们计算出的值。

  • 我们对购买和销售价格不感兴趣,我们感兴趣的是总利润。

  • 我们对每加仑绝对里程数不感兴趣,我们感兴趣的是低、中、高 mpg。

  • 我们对绝对里程数不感兴趣,我们感兴趣的是低、中、高里程。

为了这个目的,为了清理每个数据条目,我们想要做三件事:

  1. 从购买和销售价格计算车辆利润

  2. 将车辆分为低、中、高 mpg

  3. 将车辆分为低、中、高里程

为了做到这一点,我们将创建三个单独的函数,每个函数处理问题的一部分,并将它们包装在一个我们可以映射到所有数据的单一函数中。现在让我们设计这三个辅助函数,从计算利润开始。

利润计算函数仅是对基本操作(算术)的微小改动。在其他情况下,这可能是一个使用 lambda 函数的好案例;然而,因为我们计划在另一个函数内部使用这个函数,所以我们将给它一个名字。我们的get_profit函数将找出汽车销售价格和购买价格之间的差异。我们可以在下面的列表中看到它。

列表 5.9. 计算价格差异的 Lambda 函数
def get_profit(d):
    return d.get("price-sell",0) - d.get("price-buy",0)

关于列表 5.9 的一个需要注意的事项是,我们使用dict.get方法而不是[<key>]语法,因为使用get我们可以提供一个默认值。我们这样做是为了防止缺失值抛出的错误(尽管你提供的数据中没有缺失值)。

接下来,我们有两个提供类似功能的有用函数:一个将 mpg 分为三个类别——低、中、高,另一个将里程分为三个类别——低、中、高。因为这些函数非常相似,让我们同时处理它们。

这两个函数共享一个共同的行为:将一个值与一系列分界点进行比较,然后将其分配给低、中或高。我们可以编写一个通用函数,该函数接受一个dict、一个键和两个分界点,当dict在指定键的值低于第一个分界点时返回low,当它低于第二个分界点时返回medium,当它高于两个分界点时返回high。该函数将类似于以下列表中的代码。

列表 5.10. 一个通用的低-中-高函数
def low_med_hi(d, k, low, high):
    if d[k] < low:               *1*
        return "low"
    elif d[k] < high:            *2*
        return "med"
     return "high"               *3*
  • 1 如果感兴趣键的 dict 值低于我们的第一个分界点,我们返回低。

  • 2 如果该值低于第二个分界点,我们返回中等。

  • 3 如果它不低于任何一个分界点,我们返回高。

在编写了这个函数之后,我们可以开始组装所有这些部件。我们想要做三件事:

  1. 接收一个dict

  2. 使用我们的select_keys函数清理dict

  3. 返回一个包含三个键的dict

    1. 一个表示车辆利润的利润键

    2. 一个表示车辆 mpg 类别的 mpg 键

    3. 一个表示车辆里程的 odo 键

该过程的包装函数可能看起来如下所示。

列表 5.11. 将我们的汽车辅助函数包装成一个单一函数
def clean_entry(d):
    r = {}                                         *1*
    r['profit'] = get_profit(d)                    *2*
    r['mpg'] = low_med_hi(d,'mpg',(18,35))         *3*
    r['odo'] = low_med_hi(d,'odo',(60000,100000))  *3* *4*
    return r
  • 1 初始化一个用于输出数据的新dict

  • 2 使用我们的利润函数来获取利润

  • 3 使用低-中-高函数两次来获取我们的 mpg 和 odo 类别

  • 4 每次使用都对应于那些变量的具体参数。

5.5.2. 使用 reduce 进行求和和计数

在编写了我们的map包装函数之后,是时候继续我们的减少(图 5.12)。知道我们的map将开始返回什么,我们可以使用reduce将这些项转换为所需的输出数据。我们想要的是一个包含六个键的dict:每个高、中、低 mpg 一个,每个高、中、低里程一个。每个这些键的值应该包含该类型车辆的平均利润。由于我们需要总利润和总售车数来计算平均利润,我们将跟踪这些值。为了可读性,将这些值放入dict中是有意义的。这将给我们留下一个包含六个键的dict——每个类别一个,每个类别都指向另一个包含三个键的dict:一个用于平均利润,两个用于计算平均利润所需的值。

图 5.12. 我们将遍历利润和车辆类别数据以生成一个包含每个类别的totalcountaverage的单一dict

为了做到这一点,我们的累加函数将把数据集中每个观察到的利润滚入累积值的键中:一个基于其里程类别,另一个基于其 mpg 类别。因为计算总利润、计数和平均值稍微复杂一些——超出了我们用一个表达式就能完成的能力——让我们把这个行为封装在一个辅助函数中。这个辅助函数将接受该类别汽车的累积总和、计数和平均值,并混合新汽车的利润,同时增加计数并计算新的平均值。我们可以在下面的列表中一起看到这两个函数。

列表 5.12. 利润平均值累加器和辅助函数
def acc_average(acc, profit):                               *1*
    acc['total'] = acc.get('total',0) + profit              *2*
    acc['count'] = acc.get('count',0) + 1
    acc['average'] = acc['total']/acc['count']              *3*
    return acc

 def sort_and_add(acc, nxt):                                *4*
     profit = nxt['profit']                                 *5*
     nxt_mpg = acc['mpg'].get(nxt['mpg'],{})
     nxt_odo = acc['odo'].get(nxt['odo'],{})
     acc['mpg'][nxt['mpg']] = acc_average(nxt_mpg,
                                          profit)           *6*
     acc['odo'][nxt['odo']] = acc_average(nxt_odo,, profit)
     return acc
  • 1 定义一个计算平均值的辅助函数

  • 2 使用.get 方法,以防我们找到一个空字典

  • 3 我们的平均值将是利润除以计数。

  • 4 再次,我们的累加函数将接受一个 acc 和一个 nxt。

  • 5 因为我们将使用利润两次,所以我们将它存储在一个变量中以方便访问。

  • 6 我们将修改汽车所属的两个类别中每个类别的累积值。

再次,在列表 5.12 中,正如在本章中多次发生的那样,我们使用dict.get方法来访问dict的键并提供默认值。在这些情况下,我们希望有一个默认值,它向使用结果数据的函数提供预期的数据类型。在我们的acc_average函数中,我们使用get是因为我们的加法操作需要一个数字。在这种情况下,如果我们没有找到相关的键,我们指定整数 0。在我们的sort_and_add累加函数中,我们指定一个空dict,因为我们的acc_average函数期望在第一个位置有一个dict。因为我们在这两个地方都使用了.get方法,所以我们可以在没有任何关于底层数据中类别假设的情况下,从没有数据到拥有完整的数据结构。这是我们frequencies累加示例中使用的相同技巧,只是规模更大。

5.5.3. 将 map 和 reduce 模式应用于汽车数据

在编写了所有的辅助函数,包括map的数据转换和reduce的累加器之后,我们就可以处理我们的数据了。使用 map 和 reduce 风格的一个好处是,这只需要一行代码:

reduce(sort_and_add, map(clean_entry, cars_data), {})

我们使用mapclean_entry函数应用于汽车数据中的每个条目,从而得到一个清理后的数据序列,我们就可以通过它进行累减。然后我们调用reduce,并传递其三个参数:累加函数、数据和可选的初始化器。对于累加函数,我们使用我们设计的累加器:sort_and_add。对于数据,我们使用map操作的结果。对于初始化器,我们使用一个空dict

总体来说,我们的代码将如下所示。运行代码,解决两位汽车销售员之间的争论:哪个汽车类别能带来最大的利润?

列表 5.13. 使用 map 和 reduce 查找平均二手车利润
from functools import reduce

def low_med_hi(d,k,breaks):
    if float(d[k]) < breaks[0]:
        return "low"
    elif float(d[k]) < breaks[1]:
        return "medium"
    else:
        return "high"

def clean_entry(d):
    r = {'profit':None, 'mpg':None, 'odo':None}
    r['profit'] = float(d.get("price-sell",0)) - float(d.get("price-buy",0))
    r['mpg'] = low_med_hi(d,'mpg',(18,35))
    r['odo'] = low_med_hi(d,'odo',(60000,100000))
    return r

def acc_average(acc, profit):
    acc['total'] = acc.get('total',0) + profit
    acc['count'] = acc.get('count',0) + 1
    acc['average'] = acc['total']/acc['count']
    return acc

def sort_and_add(acc,nxt):
    p = nxt['profit']
    acc['mpg'][nxt['mpg']] = acc_average(acc['mpg'].get(nxt['mpg'],{}), p)
    acc['odo'][nxt['odo']] = acc_average(acc['odo'].get(nxt['odo'],{}), p)
    return acc

if __name__ == "__main__":
    import json
    with open("cars.json") as f:
        xs = json.load(f)
    results = reduce(sort_and_add, map(clean_entry, xs), {"mpg":{},"odo":{}})
    print(json.dumps(results, indent=4))

5.6. 加速 map 和 reduce

回顾 第 5.5 节 的练*,我们可以看到我们没有做任何事来使我们的 map 和 reduce 操作更快。从本书中到目前为止我们介绍的技术来看,我们可能会考虑使用 第二章 中的并行 map 来加速这个过程。不幸的是,使用并行 map 反而会使我们的工作变慢,而不是变快。

并行 map 会减慢我们的 map 和 reduce 工作流程,因为它会迫使我们两次遍历数据集,从而产生存储和从内存中检索数据的关联成本。这是因为,正如我们之前提到的,map 是天生的懒加载。它存储指令;它不进行评估。这意味着我们不会在 reduce 循环中评估我们的懒 map。另一方面,我们的并行 map 是急切的:它立即进行评估。这意味着当我们开始进行 reduce 时,我们已经在数据上循环了一次 (图 5.13)。

图 5.13. 在 map 和 reduce 场景中,使用并行 map 可能会比使用懒 map 反而慢——我们希望选择 map 和 reduce 的最佳组合以获得最佳性能。

我们无法使用并行化是一个相当不希望出现的副作用。毕竟,我们探索这些技术的一个重要原因就是它们应该对大数据集有益。如果我们不能使用并行化,我们就不能随着数据扩展我们的处理能力,我们最终将受到数据大小的限制。幸运的是,我们可以始终在 reduce 层而不是在 map 层使用并行化。我们将在下一章,关于并行 reduce 的章节中探讨这一点。

5.7. 练*

这些练*测试你对 reduce 和累加函数的了解,并加强本章的内容。

5.7.1. 使用 reduce 的情况

reduce 函数是一个强大且灵活的工具。在以下哪些情况下你会使用 reduce,而在哪些情况下你应该使用本书中介绍的其他工具?

  • 你有一长串单词,你只返回包含字母 A 的序列。

  • 你有一系列用户,你想要将他们转换成仅包含他们的用户 ID 号的序列。

  • 你有一系列用户,你想要找到购买量最大的五个用户。

  • 你有一系列采购订单,你想要找到采购的平均价格。

5.7.2. Lambda 函数

我们可以使用 lambda 函数来编写简单的函数,我们只计划使用一次;然而,在字节码级别上,这些函数与普通 Python 函数之间没有区别。用 lambda 函数复制以下函数。

def my_addition(a, b):
    return a+b

def is_odd(a):
     return a % 2 == 1

def contains(a, b):
    return b in a

def reverse(s):
     return s[::-1]

5.7.3. 最大的数字

在 Python 中,我们可以使用 max 函数在序列中找到最大值,以及 min 函数在序列中找到最小值。然而,有时我们不仅想要最大或最小的值,我们想要最大或最小的几个值。使用 reduce 编写一个函数,从序列中获取五个最大(或最小)的值。

一旦编写完成,尝试扩展函数以收集最大(或最小)的 N 个值。

示例
five_largest([10,7,3,1,9,8,11,21,15,72])
>>> [72,21,15,11,10]

n_largest([10,7,3,1,9,8,11,21,15,72], n=3)
>>> [72,21,15]

5.7.4. 按长度分组单词

按组分组 是一种有用的归约,其中我们根据对序列元素应用的一些函数的结果将它们分组。使用 reduce 编写此函数的版本,可以根据单词的长度对单词进行分组。

示例
group_words(["these", "are", "some", "words", "for", "grouping"])
>>> {3: ["are","for"],
     4: ["some"],
     5: ["these","words"],
     8: ["grouping"]}

摘要

  • reduce 函数通过累加器函数和初始化器将一系列数据(N)累积到其他东西(X)中。

  • 累加器函数接受两个变量:一个用于累积数据(通常指定为 acclefta),另一个用于序列中的下一个元素(指定为 nxtrightb)。

  • reduce 在你有数据序列并想要返回序列之外的内容的情况下非常有用。

  • reduce 的行为可以根据我们传递给它的累加器函数进行高度定制。

  • 匿名 lambda 函数在累加器函数简洁、清晰且不太可能被重用时非常有用。

  • 我们可以将 mapreduce 结合起来,将复杂的转换分解成小的依赖部分。

  • map,出人意料地,在同时使用 mapreduce 时提供了比并行 map 更好的性能。

第六章. 通过高级并行化加速 map 和 reduce

本章涵盖

  • 使用 mapstarmap 的高级并行化

  • 编写并行 reducemap reduce 模式

  • 累加和组合函数

我们在 第五章 结束时遇到了一个矛盾的情况:使用并行方法和更多的计算资源比使用较少计算资源的线性方法要慢。直观上,我们知道这是错误的。如果我们使用更多的资源,我们至少应该和我们的低资源工作一样快——希望我们更快。我们永远不希望更慢。

在本章中,我们将探讨两种方法来最大限度地发挥并行化的优势:

  1. 通过优化我们的并行 map 使用

  2. 通过使用并行 reduce

并行 map,我在第 2.2 节中介绍过,是一种快速转换大量数据的优秀技术。然而,当我们学*基础知识时,我们确实忽略了一些细微差别。我们将在本章深入探讨这些细微差别。并行 reduce 是在 mapreduce 模式的 reduce 步骤中发生的并行化。也就是说,我们已经调用了 map,现在我们准备累积所有这些转换的结果。使用并行 reduce,我们在累积过程中使用并行化,而不是在转换过程中。

6.1. 充分利用并行 map

回到第二章,当我们介绍并行 map 时,我们讨论了它的一些不足:

  • Python 的并行 map 使用序列化,一种将 Python 对象保存到磁盘的方法,来共享工作;这在使用某些数据类型时会导致问题。

  • 当我们处理具有状态的对象,如类时,并行 map 有时会导致意想不到的后果。

  • 并行 map 操作的结果并不总是按照我们预期的顺序进行评估。

然而,最终我们得出结论,我们可以忍受这些约束的情况比不能忍受的情况要多。确实,直到第五章,我们还没有看到需要担心并行 map 的场景。然后我们遇到了第一个情况,其中并行 map 比懒 map 慢。当

  1. 我们将在工作流程的稍后阶段再次遍历序列

  2. 每个并行实例完成的工作量与并行化带来的开销相比很小

在第一种情况下,当我们打算第二次遍历序列时——也就是说,我们将对序列进行 map 操作,然后稍后处理其所有元素——使用懒 map 允许我们跳过第一次迭代。使用懒 map,我们可以在原本的第二次迭代中执行转换,而不是遍历我们的序列以转换所有元素。我们在图 5.13 中可视化了这一点,再次在图 6.1 中展示。

图 6.1 展示了懒 map 输出懒 map 对象,没有迭代过程,而并行 map 遍历整个序列。我们将在第 6.2 节中探讨使用并行 reduce 解决这个问题。

图 6.1. 当我们打算在 map 语句之后遍历结果时,懒 map 可能比并行 map 更快。

6.1.1. 数据块大小和充分利用并行 map

第二种情况——当序列被分成大量大块,其开销与在这些块上执行的工作量相比很大时——是我们尚未遇到的情况。在这些情况下,并行map将比懒加载慢,因为我们正在向任务添加开销。

如果我们将我们的程序想象成一个软件项目,我们可以将并行化想象成承包商。承包商希望用尽可能少的工人来完成工作,因为每个新工人加入都需要承包商向他们解释任务(这需要时间)并支付他们工资(这需要金钱)。在边缘上,这可能无关紧要。但如果有工人闲着却领工资,或者他们花了太多时间向新工人解释项目以至于无法监督他们,承包商可能更愿意拥有一个更小的团队。

这同样适用于我们的并行处理。例如,假设我们有 100 秒的工作要做,每次我们添加一个新并行工人,我们都需要花费 1 秒与该工人通信。如果我们有

  • 2 个工人每人工作 50 秒,我们可以在 52 秒内完成工作

  • 4 个工人每人工作 25 秒,我们可以在 29 秒内完成工作

  • 25 个工人每人工作 4 秒,我们将在 29 秒内完成任务

  • 100 个工人每人工作 1 秒,我们将花费 101 秒

在某个点上,进行的工作量太小,不足以证明传递它的成本。我们需要确保当我们分配工作给我们的并行作业时,我们分配的工作量足够大,使得处理器有足够的时间进行工作,从而证明将它们传递给它们的时间是合理的。我们这样做是通过指定一个块大小

块大小指的是我们将任务分解成并行处理的不同块的大小。较大的块大小任务将需要处理器花费更多时间来处理它们,而较小的块大小任务将更快完成。

注意

选择一个大的块大小是理想的——我们将在本章后面学*如何选择正确的大小——但仍然允许所有处理器在大约相同的时间内完成最终任务。如果我们选择一个过小的块大小,我们就会陷入本章开头描述的情况:传递指令的时间比处理我们的工作的时间更长。如果我们选择一个过大的块大小,我们最终会处于一个位置,只有一个处理器在处理最终的块,而其他处理器在等待。

我们可以通过思考它们的极限情况来直观地理解这些极限行为。如果我们要求我们的每个处理器一次只处理一个元素,那么我们就需要

  • 将该元素及其处理指令

  • 处理它,

  • 并将该元素传回。

然后我们必须为每个单独的元素重复这些步骤。假设一个合理大小的任务,这肯定比逐个处理每个元素要费时得多。在线性处理中,我们没有并行处理中存在的额外通信步骤。

对于大块大小问题,首先考虑无限大的块大小是有帮助的。嗯,这相当于只使用单个处理器,因为我们只有一个块。如果我们的块大小是序列大小的一半,我们只会使用两个处理器。如果它是序列大小的三分之一,我们只会使用三个。这看起来可能不是问题,特别是如果我们只有几个处理器的计算机,但想想当第二个处理器完成所有简单的工作而第一个处理器完成所有困难的工作时会发生什么。第一个处理器将在第二个处理器停止工作后继续工作。

最佳块大小位于这两个极端之间。不幸的是,除了这个普遍的观念,即块太小和块太大都不好,给出具体的块大小建议是困难的。正是出于这个原因,Python 将chunksize作为选项提供,因为我们希望根据任务的不同而调整它。我建议从默认值开始,然后逐渐增加块大小,直到你看到运行时间开始下降。

6.1.2. 变量序列和块大小的并行映射运行时间

现在我们对块大小和并行map与惰性map的行为差异有了更多的了解,让我们看看一些代码。我们将从观察惰性和并行map在不同大小的序列上的行为开始,以及对于简单的操作和少量数据,实际上并行化并没有带来任何好处。然后我们将测试不同块大小的并行map,看看这对我们的性能有何影响。

序列大小和并行映射运行时间

我们应该在多大的时候开始考虑并行化?嗯,这很大程度上取决于我们的任务有多复杂。

小贴士

当我们的任务复杂时,我们很快就能从并行化中受益。当我们的任务简单时,只有在有大量数据的情况下我们才能从中受益。

考虑到第二章结尾的例子,当我们从网络抓取数据时,每次请求都会有与网络相关的延迟。在这些情况下,并行化几乎总是有意义的。

但当我们的任务很小,比如进行算术运算或调用 Python 数据类型的方法时,情况又如何呢?在这种情况下,情况并不明朗,这取决于序列的大小。如果我们运行一个惰性map和一个并行map,我们就可以证明这一点。以下列表显示了如何进行这种比较,使用times_two函数作为简单的操作,并在包含 1 到 100 万元素之间的序列上比较并行map和惰性map

列表 6.1. 比较不同大小的序列上的并行map和惰性map
from time import clock
from multiprocessing import Pool

def times_two(x):
  return x*2

def lazy_map(xs):
  return list(map(times_two, xs))

def parallel_map(xs, chunk=8500):
  with Pool(2) as P:
    x =  P.map(times_two, xs, chunk)
  return x

for i in range(0,7):
  N = 10**i
  t1 = clock()
  lazy_map(range(N))
  lm_time = clock() - t1

  t1 = clock()
  parallel_map(range(N))
  par_time = clock() - t1
  print("""
-- N = {} --
Lazy map time:      {}
Parallel map time:  {}
""".format(N,lm_time, par_time))

在该代码的输出中,我们可以看到一个模式的出现。

-- N = 100 --
Lazy map time:      6.0999999999991616e-05
Parallel map time:  0.007081000000000004

-- N = 1000 --
Lazy map time:      0.0003589999999999982
Parallel map time:  0.007041999999999993

-- N = 100000 --
Lazy map time:      0.037799999999999986
Parallel map time:  0.019601000000000007

对于小的序列大小或快速完成的进程,不仅使用并行map没有好处,反而会适得其反。懒惰map实际上更快。然而,当我们开始注意到我们的代码运行缓慢——当我们开始面临秒或分钟的延迟时——使用并行映射会更快。

块大小和并行映射运行时间

我们也可以用块大小进行相同的实验。对于这个实验,我们不会改变序列的大小,而是保持序列不变,只改变并行化方法使用的块的大小。我们必须使用足够大的序列,以便我们看到一些变化,但不会太长,以至于我们永远等不到结果。根据我们之前的实验,大约 1000 万就足够了。这个实验的代码如下所示。

列表 6.2. 比较块大小对并行map运行时间的影响
from time import clock
from multiprocessing import Pool

def times_two(x):
  return x*2+7

def parallel_map(xs, chunk=8500):
  with Pool(2) as P:
    x =  P.map(times_two, xs, chunk)
  return x

print("""
{:<10}  |  {}
-------------------------""".format("chunksize","runtime"))

for i in range(0,9):
  N = 10000000
  chunk_size = 5 * (10**i)

  t1 = clock()
  parallel_map(range(N), chunk_size)
  parallel_time = clock() - t1

  print("""{:<10}  |  {:>0.3f}""".format(chunk_size, par_time))

此代码的结果出现在以下输出片段中。我们可以看到,对于小的块大小,我们的运行时间很高。这是因为所有工作者之间通信所花费的时间相对于获得性能来说很高。通过将问题分成太多的部分,我们使其变得低效。然而,如果块大小太大,我们会遇到相反的问题:我们没有使用足够的工作者来有效地解决问题。然而,中间的大部分大小与两个极端相比,给出了相当合理的性能。

chunksize   |  runtime
-------------------------
5           4.849
50          0.753
500         0.192
5000        0.188
50000       0.195
500000      0.146
5000000     0.167
50000000    0.171
500000000   0.168

6.1.3. 更多并行映射:.imap 和 starmap

我们应该熟悉 Python 中另外两种类型的并行映射:

  1. .imap用于懒惰的并行映射

  2. starmap用于并行映射tuple序列

我们可以使用.imap方法有效地在非常大的序列上并行工作,并使用starmap处理复杂的可迭代对象,特别是那些我们可能使用zip函数创建的对象。

使用.imap 和.imap_unordered 处理大型序列

我们在第四章讨论了懒惰的好处,并且当并行工作时,我们没有理由放弃它们。如果我们想既懒惰又并行,我们可以使用Pool().imap.imap_unordered方法。这两个方法都返回迭代器而不是列表,如下面的列表所示。除此之外,.imap的行为就像并行map

列表 6.3. 并行map的变体
from multiprocessing import Pool

def increase(x):
  return x+1

with Pool() as P:
  a = P.map(increase, range(100))

with Pool() as P:
  b = P.imap(increase, range(100))

with Pool() as P:
  c = P.imap_unordered(increase, range(100))

print(a)                                                 *1*
# [1, 2, 3, ... 100]
print(b)                                                  *2*
# <multiprocessing.pool.IMapIterator object at            *2*
 0x7f53207b3be0>                                        *2*
print(c)                                                  *2*
# <multiprocessing.pool.IMapUnorderedIterator object at   *2*
 0x7fbe36ed2828>                                        *2*
  • 1 我们的常规并行映射返回一个列表。

  • 2 懒惰的并行映射都返回迭代器对象。

.imap_unordered的行为相同,但它不一定将序列放入我们的迭代器的正确顺序。这就是为什么它被称为无序:值被放置在迭代器中的确切顺序与我们的处理器处理它们的顺序相同。当我们处理大型数据集时,这两种方法的懒惰性可以意味着我们的程序运行时间的显著减少。

使用 starmap 并行处理 zip

我们已经看到 map 在转换数据方面是多么有用,以及我们如何可以使用它并行地加速大数据集上的操作;然而,map 有一个令人失望的缺点:它只能用于接受单个参数的函数。有时,这还不够。我们希望使用接受两个或更多参数的函数。在这些情况下,我们可以使用 starmap 来获得相同的好处。

starmap 函数将 tuple 解包为映射函数的位置参数,我们可以将其用作一个惰性函数(来自 itertools.starmap)或一个并行函数(作为 Pool() 对象的方法,通常为 P.starmap)。如果我们像在第四章中学到的那样将两个序列一起 zip,那么我们就得到了一个准备就绪的可迭代对象,可以与 starmap 一起使用。

例如,我们可能希望在每个位置找到两个序列中的最大元素。而不是遍历序列并比较它们,我们可以将序列 zip 在一起并对它们进行 map。列表 6.4 展示了这两种方法的比较。在第一种方法中,我们使用列表推导式和 enumerate 来比较相同位置上的元素。在第二种方法中,使用 starmap,我们将参数 zip 在一起,然后对它们上的相关函数进行 map

列表 6.4. 使用 starmap 来使用带有多个变量的 map
from itertools import starmap                            *1*
xs = [7, 3, 1, 19, 11]                                   *2*
ys = [8, 1, -3, 14, 22]

loop_maxes = [max(ys[i], x) for i,x in enumerate(xs)]    *3*
map_maxes = list(starmap(max, zip(xs, ys)))              *4*

print(loop_maxes)
# [8, 3, 1, 19, 22]
print(map_maxes)
# [8, 3, 1, 19, 22]
  • 1 要使用 starmap,我们需要从 itertools 中导入它。*

  • 2 首先,让我们创建一些测试数据。*

  • 3 一个列表推导式来展示如何在不使用 map 的情况下完成这个操作*

  • 4 使用 starmapzip 实现相同的效果*

除了简化代码并将其带入我们现在已经熟悉的模式之外,starmap 还带来了我们从 map 中期待的所有好处。zipstarmap 都是惰性的,因此我们可以更加放心地处理大数据集,因为我们只保留内存中需要的数据。我们还可以通过将其作为 Pool() 对象的方法调用来快速将 starmap 转换为并行工作。

6.2. 解决并行映射和归约悖论

在第五章的结尾,我们注意到一个问题——我们的并行 mapreduce 比我们的惰性 mapreduce 慢。然后在第 6.1 节中,我们更深入地探讨了并行 map 的行为。尽管这有助于我们更好地理解问题,但它并不一定有助于我们解决问题。为了解决问题,我们不得不做些不同的事情:使用并行 reduce。在本节中,我们将探讨实现并行 reduce 以加快我们的归约操作。

6.2.1. 并行归约以实现更快的归约

将并行 reduce 思考为我们的并行 map 和线性 reduce 之间的交叉。并行 reduce 将共享并行 map 的成本和好处,同时具有线性 reduce 的签名。就像并行 map 一样,并行 reduce

  • 将问题分解成块

  • 不保证顺序

  • 需要序列化数据

  • 对有状态的对象要挑剔

  • 在小数据集上比其线性对应物运行得更慢

  • 在大数据集上比其线性对应物运行得更快

与线性reduce一样,并行reduce

  • 需要一个累积函数、一些数据和初始值

  • 执行 N 到 X 的转换

考虑到所有这些,我们可以使用并行reduce来解决我们在第五章结尾遇到的问题。我们可以以时间友好的方式执行转换并累积结果。

拆分并行 reduce 参数

当我们在第五章中首次查看reduce时,我们查看的其中一个图形显示了我们的reduce函数的各个部分。我们看到reduce有三个部分:

  1. 一个累积函数

  2. 一个序列

  3. 一个初始化值

那个图表,如图 6.2 所示,概述了我们能够使用reduce所需的内容。与map相比,reduce稍微复杂一些——map有两个部分,而reduce有三个,但并不过分复杂。并行reduce增加了难度。

图 6.2。reduce函数有三个参数:一个累积器、一个序列和一个初始值。

06fig02_alt.jpg

我们将要查看的并行reduce实现有六个部分:

  1. 一个累积函数

  2. 一个序列

  3. 一个初始化值

  4. 一个map

  5. 一个chunksize

  6. 一个组合函数

你应该能认出这六个部分中的大部分,它们在图 6.3 中有所展示。前三个——累积函数、序列和初始化值——直接来自reduce。我们刚刚在第 6.1.2 节中谈到了chunksize。这留下了两个新参数,而且这两个参数也只是新*出现的。

图 6.3。并行reduce有六个参数:一个累积函数、一个序列、一个初始化值、一个map、一个chunksize和一个组合函数——比标准reduce函数多三个。

06fig03_alt.jpg

并行reducemap参数正是我们根据其名称所期望的:它是一个map函数。我们将使用的并行reduce实现将利用我们在并行map中实现的并行性。这就是为什么我们的并行reduce将共享其所有优点和缺点——大部分行为是直接继承的。

话虽如此,我们不必将并行reduce传递一个并行map。我们可以自由地传递一个惰性map。例如,我们可以传递 Python 标准库中的惰性map。如果我们这样做,我们不会有一个并行的reduce,而是一个惰性reduce。然而,这比惰性map要少用得多,因为reduce只产生一个累积值——即使这个值是一个复杂的数据结构——而且我们必须在整个序列上操作才能知道它是什么。

最后一个参数是一个组合函数。组合函数类似于累加函数,但针对我们的并行归约问题。为了理解组合函数是如何工作的,让我们更深入地看看并行reduce的工作流程。

6.2.2. 组合函数和并行归约工作流程

因为并行reduce基于并行map,所以并行reduce工作流程具有与我们的并行map工作流程相同的主体部分(图 6.4)。我们将

  1. 将我们的问题分解成块

  2. 执行一些工作

  3. 合并工作

  4. 返回一个结果

图 6.4. 并行reduce工作流程涉及在原始序列的块上并行执行一个操作,使用聚合函数,并在聚合(组合)结果的数据上执行另一个操作。

图片

对于并行map,我们需要理解所有这些步骤,但大部分的代码编写工作将投入到第二步:执行将我们的数据转换的工作。在某些情况下——当我们指定块大小时——我们也会关注第一步:将问题分解成块。对于并行reduce,我们还需要考虑第三步:合并工作。这正是我们的组合函数发挥作用的地方。

并行map中的隐式组合函数

在并行map中,我们不需要调用组合函数,因为数据总是以相同的方式连接。因此,组合函数被硬编码到并行map操作本身中。因为map执行的是 N 到 N 的数据转换——这是一个在第二章中引入的概念,它描述了map如何将序列转换成具有不同元素的相同大小的序列——我们知道我们的组合函数将始终是某种将两个序列相加的形式。

对于我们的并行map函数完成的任何两个工作块,主节点可以通过以正确的顺序合并这些块来重新组装这些块。对应于序列中较早元素的块先出现,对应于序列中较晚元素的块随后出现。我们可以将这个函数想象成图 6.5 中的图像和以下列表中的代码。

列表 6.5. 并行map中的隐式组合函数
def map_combination(left, right):      *1*
  return left + right

xs = [1, 2, 3]
ys = [4, 5, 6]
print(map_combination(xs, ys))
# [1,2,3,4,5,6]
  • 1 注意函数的签名看起来像我们的累加器的签名——它接受一个左对象和一个右对象,并返回与左对象相同类型的对象。

在列表 6.5 中,我们可以看到如果我们自己编写它,一个map组合函数会是什么样子。我们可以想象,两个序列——在这种情况下是xsys——是我们并行map操作返回的部分,我们可以使用map_combination函数来组合它们。我们还看到,map_combination函数类似于一个累积函数。我们甚至使用了累积函数的两个变体参数名称:左和右。

图 6.5. 并行reduce求和工作流程是一个简单的例子,其中我们在累积步骤和组合步骤中使用了相同的函数。

并行reduce的自定义组合函数

然而,使用并行reduce,我们以更多可能变换的灵活性为代价,换取了总是有相同组合函数的简单性。让我们考虑三种情况,看看我们如何在每种情况下处理组合函数:

  1. 求和

  2. filter

  3. 频率

我们在第 5.2 节中实现了使用reduce进行求和——这个函数的目的是将一系列数字相加。当我们使用reduce进行求和时,我们累积一个部分和,并持续将新值添加到这个部分和中,直到我们的序列中没有更多元素。结合我们的并行工作流程,我们得到一个类似于图 6.5 的过程。

这个过程遵循我们在第 6.2.2 节开头概述的基本并行工作流程步骤。我们首先将问题分解成几部分,将我们的序列转换成几个较小的序列,然后进行一些工作:

  • 首先,我们求出每个较小序列的总和。

  • 然后,我们组合我们的结果。组合部分和需要我们取和的和。

  • 最后,我们可以将这个值作为我们的结果返回。

在求和过程中,我们很幸运,因为组合函数与累积函数相同。累积函数接受两个值——都是数值——将它们相加,并返回它们的和以得到一个中间和。组合我们的子序列和是一个相同的过程:我们将和的成对数值相加。以下列表*似这个过程,并展示了我们如何使用累积函数再次对reduce进行组合以合并我们的部分结果。

列表 6.6. 并行reduce求和的*似
from functools import reduce

def my_add(left, right):                      *1*
  return left+right

xs = [1,2,3,4]                                *2*
ys = [5,6,7,8]
zs = [9,10,11,12]

sum_x = reduce(my_add, xs)                    *3*
sum_y = reduce(my_add, ys)                    *3*
sum_z = reduce(my_add, zs)                    *3*

print(my_add(my_add(sum_x, sum_y), sum_z))    *4*
# 78
  • 1 我们的累积函数是简单的加法。

  • 2 我们将长序列分成三部分。

  • 3 我们独立地处理这些部分。

  • 4 最后,我们将这些部分组合起来——注意我们在最后两个步骤中都使用了 my_add。

然而,我们并不总是幸运到可以使用同一个函数。接下来,我们将探索filter函数的并行reduce工作流程。我们第一次在第四章中看到filter,并在第 5.3.1 节中实现了基于reduce的版本。filter背后的想法是我们有一个大序列,我们想要创建一个子序列,它只包含导致函数返回True的该序列的元素。

我们标准的filter工作流程是从一个空序列开始,逐个元素地通过我们的序列,只将使我们的条件函数返回True的元素添加到累积序列中。为了使其并行,我们将

  1. 将我们的序列拆分成更小的序列

  2. 在一个新序列中累积那些使我们的条件返回True的小序列的元素

  3. 将这些新序列连接起来

  4. 返回组合序列

我们可以在图 6.6 中看到整个流程。注意,步骤 2 的函数,它接受一个序列并生成一个子序列,与步骤 3 的函数不同,该函数将序列连接起来。获取序列并返回子序列的函数是我们在第五章中filter归约的累积函数。连接序列的函数实际上是map的隐式组合函数。

图 6.6. 在我们的并行filter工作流程中,我们需要在累积步骤和组合步骤中使用不同的函数。这使得操作比我们的并行求和更复杂。

我们可以将列表 6.6 中的示例*似并行求和修改为*似并行filter以观察其作用。首先,我们必须创建一个新的累积函数。在这里,我们将使用我们在第 5.3.1 节中编写的keep_if_even函数。我们还需要添加一个组合函数。因为我们已经确定这个函数与并行map的隐式组合步骤中的相同函数,所以让我们使用我们在列表 6.5 中编写的函数。我们可以在以下列表中看到这两个函数的组合,使用并行reduce*似filter函数。

列表 6.7. 使用不同的累积和组合函数的并行filter
from functools import reduce

def map_combination(left, right):             *1*
  return left + right

def keep_if_even(acc, nxt):                   *2*
    if nxt % 2 == 0:
        return acc + [nxt]
    else: return acc

xs = [1,2,3,4]
ys = [5,6,7,8]
zs = [9,10,11,12]

f_acc = keep_if_even                          *3*
f_com = map_combination                       *3*

res_x = reduce(f_acc, xs, [])                 *4*
res_y = reduce(f_acc, ys, [])                 *4*
res_z = reduce(f_acc, zs, [])                 *4*

print(f_com(f_com(res_x, res_y), res_z))      *5*
# [2, 4, 6, 8, 10, 12]
  • 1 创建我们的组合函数

  • 2filter创建我们的累积函数

  • 3 分配我们的累积和组合函数以区分它们

  • 4 使用我们拆分的序列上的累积函数,返回中间结果

  • 5 使用我们的组合函数处理这些结果,返回最终结果

在 列表 6.7 中,我们可以看到我们的累积函数(用 f_acc 表示)和我们的组合函数(用 f_com 表示)是不同的。正如我们之前提到的,累积函数是来自 第五章 的 keep_if_even,组合函数是来自 列表 6.5 的 map_combination。我们需要这两个函数来处理我们拆分的工作并达到预期的结果。

重要的是要注意,这些函数期望不同的参数类型。keep_if_even 函数接受一个列表作为第一个位置和一个数值作为第二个位置。map_combination 函数期望两个位置都是列表。在我们的 filter 情况中,我们知道累积步骤总是产生一个列表,因此我们的组合函数接受两个列表。

组合函数总是接受两个相同类型的参数,因为每个参数都是同一过程的结果。

我们也可以在我们的 frequencies 示例中看到这个规则。我们首先在 第 5.3.2 节 实现了 frequencies 函数,该函数在提供序列时返回一个包含元素及其计数的 dict。在其线性形式中,我们遍历序列中的每个元素,每次遇到它时,将每个元素的计数增加一。在并行情况下,我们需要做四件事情:

  1. 将我们的序列拆分成更小的序列

  2. 从这些较小的序列中获取计数

  3. 将计数合并在一起

  4. 返回我们的合并计数

图 6.7 显示,与 filter 类似,frequencies 处理过程将使用不同的函数进行累积和组合步骤。对于累积步骤,我们将使用来自 列表 5.7 的 make_counts 函数。对于组合步骤,我们必须编写一个全新的函数。这个函数必须遍历我们两个 dict 的唯一键,并将这些键的值相加到一个新的 dict 中。我们可以看到,尽管我们的 frequencies 处理过程可以接受任何类型的可迭代对象,但我们总是将 dict 传递给我们的组合函数,因为这是 make_counts 累积函数返回的类型。

图 6.7. 并行 frequencies 减少工作流程可以接受多种类型的输入,但它将始终将 dict 传递到其组合步骤,并以 dict 作为结果返回。

列表 6.8 展示了 filter 的并行 reduce 版本的*似实现。我们可以看到原始的 make_counts 累积函数和我们的新组合函数,这与我们在求和示例和 filter 示例中看到的一般模式相同。再次强调,我们看到了采用 map 和 reduce 风格的一个主要好处:我们可以使用相同的编程模式来解决各种各样的问题。

列表 6.8. 平行 reduce frequencies 的*似
from functools import reduce

def combine_counts(left, right):            *1*
  unique_keys = set(left.keys()).\          *2*
                union(set(right.keys()))    *2*
  return {k:left.get(k,0)+right.get(k,0)    *3*
          for k in unique_keys}             *3*

def make_counts(acc, nxt):                  *4*
    acc[nxt] = acc.get(nxt,0) + 1
    return acc

xs = "miss"
ys = "iss"
zs = "ippi"

f_acc = make_counts                        *5*
f_com = combine_counts                     *5*

res_x = reduce(f_acc, xs, {})              *6*
res_y = reduce(f_acc, ys, {})              *6*
res_z = reduce(f_acc, zs, {})              *6*

print(f_com(f_com(res_x, res_y), res_z))   *7*
# {'i': 4, 'm': 1, 's': 4, 'p': 2}
  • 1 通过找到一个表示两个键集合并集的集合来创建一个唯一的键序列

  • 2 因为字典键是键类型,我们将不得不使用显式的集合转换。

  • 3 遍历键并返回一个字典,将键映射到每个字典中值的总和

  • 4 make_counts 函数是我们在第五章中的旧累加器。

  • 5 将 make_counts 作为累加函数,combine_counts 作为组合函数

  • 6 使用我们的累加函数处理分割后的序列

  • 7 使用我们的组合函数合并中间结果

我们可以在类似 列表 6.7 和 6.8 的相似性中看到这个可重用的模式。通过将组合和累加抽象为 f_accf_com,我们需要的所有变化只是这些函数如何解决。现在我们已经看到求和、filterfrequencies 将如何在并行中工作,让我们看看我们如何实际上使用并行 reduce 来实现这三个函数。

6.2.3. 使用 fold 实现并行求和、过滤和频率

到目前为止,在本章中,我们探讨了并行化的实现细节。具体来说,我们探讨了何时应该使用并行工作流程,以及并行 reduce 工作流程与我们已经熟悉的并行 map 工作流程有何不同。现在我们已经掌握了这些,我们最终可以解决我们在第五章末尾注意到的问题,即 reduce 在并行中工作得更慢。我们终于准备好使用并行 reduce

就像我们的标准 map 和并行 map 一样,从标准 reduce 转向并行 reduce 有点令人失望。假设我们已经有了我们的累加和组合函数,实现并行 reduce 只需要三个步骤:

  1. 导入适当的类和函数

  2. Rounding up some processors

  3. 将正确的辅助函数和变量传递给我们的 reduce 函数

对于这三个步骤中的第一个,我们必须超越 Python 基础提供的功能。Python 并没有原生支持并行 reduce。我们将需要的库之一是 pathos 库,我们在介绍并行性和讨论与序列化相关的一些问题时讨论了它第二章。我们可以使用 pathos 来克服 Python 在序列化方面的弱点,并将问题分割成块以进行并行 reduce

我们还需要从 toolz 库中获取并行 reduce 的实现。我们在第二章 chapters 2 和第四章 chapters 4 中使用过 toolz 库,当时我们借用了适合 map 和 reduce 编程风格的便捷函数。toolz 库中的并行 reduce 实现称为 foldfoldreduce 的另一个名称,它作为 reduce 的隐喻很有用:逐个将每个元素折叠到累加器中,直到只剩下累加器。

toolz 库

toolz 库旨在成为 Python 从未附带的功能性实用库。许多功能性编程语言——Scala、Clojure、Haskell 和 OCaml——都附带了一些方便的通用序列转换模式工具。Python 没有,而 toolz 补充了这些便利函数。该库的高性能版本称为 CyToolz。您可以使用 pip install cytoolz 安装 CyToolz。

一旦我们有了这些导入,我们只需要调用 Pool 来汇总一些处理器,并使用所有正确的参数调用我们的并行 reducefold)。例如,对于求和,我们需要进行导入,调用 Pool,并将我们的加法函数传递给并行 reducefold)。我们可以在以下列表中看到所有这些操作。

列表 6.9. 使用 reduce 进行并行求和
import dill as pickle                        *1*
from pathos.multiprocessing                  *1*
    import ProcessingPool as Pool          *1*
from toolz.sandbox.parallel import fold      *1*
from functools import reduce                 *1*

def my_add(left, right):                     *2*
  return left+right

with Pool() as P:                            *3*
    fold(my_add, range(500000), map=P.imap)  *4*

print(reduce(my_add, range(500)))            *5*
# 124750
  • 1 我们将需要 dill、pathos 和 toolz 库的功能来执行并行 reduce。

  • 2 创建我们的累加和组合函数,这些函数对于求和是相同的

  • 3 将我们想要使用的处理器数量汇总

  • 4 将参数传递给我们的并行 reduce 函数:fold

  • 5 包含一个线性 reduce 以进行比较

列表 6.9 显示,就像在并行调用 map 与调用我们常规的懒 map 相比,并行调用 reduce 几乎不需要修改我们的基础代码。当然,我们需要导入一些基础 Python 中未包含的功能,但工作流程没有实质性的变化。重要的是,我们在每种情况下都使用完全相同的累加函数。

提示

列表 6.9 还显示了如何将并行 map 作为参数传递给并行 reduce。这是因为 toolz 库中的并行 reduce 实现实际上并没有实现并行性。这个函数必须位于并行 map 之上才能执行其并行魔法。如果我们愿意,我们可以将我们的常规懒 map 函数传递给 fold 函数,我们就会得到一个线性 reduce。如果我们只是在测试较大数据集的一个小子集时,这可能很有用,因为我们可以在处理大数据集时添加并行性,而无需并行性即可使用 fold 函数。

对于并行 filter,我们看到过程基本上是相同的,但现在我们需要添加我们的组合函数和一个初始化器。我们可以在以下列表中看到这个过程。

列表 6.10. 使用reduce并行实现filter
import dill as pickle                               *1*
from pathos.multiprocessing import ProcessingPool as Pool
from toolz.sandbox.parallel import fold
from functools import reduce

def map_combination(left, right):                   *2*
  return left + right

def keep_if_even(acc, nxt):                         *3*
    if nxt % 2 == 0:
        return acc + [nxt]
    else: return acc

with Pool() as P:
    fold(keep_if_even, range(500000), [],
         map=P.imap, combine=map_combination)       *4*

print(reduce(keep_if_even, range(500), []))         *5*
# [0, 2, 4, 6, 8, 10, 12, ... 484, 486, 488, 490, 492, 494, 496, 498]
  • 1 我们并行reduce的实现需要与之前相同的导入。

  • 2 如列表 6.7 所示,map_combination 是我们的组合函数。

  • 3 keep_if_even,来自第五章,是我们的累加函数。

  • 4 注意到用作初始化器的空列表和组合函数 map_combination。

  • 5 我们的标准reduce工作流程进行比较

列表 6.10 展示了并行filter工作流程如何结合组合函数和初始化器。就像我们的线性filter一样,我们将初始化器——一个空列表——放在第三个位置。再次,我们使用空列表作为filter,因为我们想返回一个列表。我们还可以看到组合函数是如何作为命名参数传递给我们的并行reduce函数的最终位置的。这个组合函数和并行map参数是区分我们的线性reduce和并行reduce的唯一因素。

我们可以看到线性frequencies和并行frequencies之间相同的有限变化,如下面的列表所示。再次,重要的是我们传递了组合函数和并行map

列表 6.11. 使用并行reduce并行实现frequencies
import dill as pickle                                           *1*
from pathos.multiprocessing import ProcessingPool as Pool
from toolz.sandbox.parallel import fold
from random import choice                                       *2*
from functools import reduce

def combine_counts(left, right):                                *3*
  unique_keys = set(left.keys()).union(set(right.keys()))
  return {k:left.get(k, 0)+right.get(k, 0) for k in unique_keys}

def make_counts(acc, nxt):                                      *4*
    acc[nxt] = acc.get(nxt,0) + 1
    return acc

xs = (choice([1, 2, 3, 4, 5, 6]) for _ in range(500000))        *5*

with Pool() as P:                                               *6*
    fold(make_counts, xs, {},
         map=P.imap, combine=combine_counts)
rand_nums = (choice([1, 2, 3, 4, 5, 6]) for _ in range(500))
reduce(make_counts, rand_nums, {})                              *7*
# {6: 87, 1: 59, 5: 88, 4: 85, 3: 93, 2: 88}
  • 1 使用相同的三个导入:dill、ProcessingPool 和 fold

  • 2 实现了选择以生成一些示例数据

  • 3 combine_counts 将作为我们的组合函数。

  • 4 make_counts 将作为我们的累加函数。

  • 5 使用生成器表达式创建大量虚拟数据

  • 6 在此数据上调用我们的并行reduce,传递我们的累加函数、数据、作为初始化器的字典、我们的并行map和组合函数

  • 7 包含一个线性reduce进行比较

我们再次看到并行reduce和线性reduce是多么相似。除了组合函数和并行map之外,它们都使用相同的参数:相同的累加函数、相同的数据输入和相同的初始化器。在所有三个示例——并行reduce求和、并行reduce过滤和并行reduce频率——中,我们都看到了我们的组合函数复杂性增加。正确实现组合函数是成功使用并行reduce的关键。

摘要

  • 有时,并行map可能比懒map慢,尤其是在数据量小或要完成的工作简单时。

  • 有几种map的变体,如starmap.imap,在适当的情况下可能很有用。

  • 我们可以将并行reduce与懒map结合使用,以实现快速的 map 和 reduce 工作流程。

  • 并行reduce需要五个参数:一个累加函数、一个序列、一个初始化器、一个并行map函数和一个可选的合并函数。

  • 并行map函数告诉并行reduce如何分配工作负载。

  • 可选的合并器告诉 reduce 如何将并行完成的工作块连接起来,这些工作块的数据类型可能与序列中项的数据类型不同。

  • 要使用并行 reduce,我们需要设计一个合并函数,该函数可以将不同的累积块合并在一起。

第二部分

第二部分 介绍了如何使用两个流行的开源分布式计算框架:Hadoop 和 Spark。Hadoop 是当代分布式计算的创始者和基础。我们将探讨如何使用 Hadoop 流和如何使用 mrjob 库编写 Hadoop 作业。我们还将学* Spark,这是一个现代分布式计算框架,可以充分利用最新的、高内存的计算资源。您可以使用本部分中的工具和技术来处理类别 2 和 3 中的大数据:需要并行化以在合理时间内完成的任务。

第七章. 使用 Hadoop 和 Spark 处理真正的大数据集

本章涵盖

  • 识别 reduce 模式进行 N-to-X 数据转换

  • 编写用于归约的辅助函数

  • 编写用于简单归约的 lambda 函数

  • 使用 reduce 汇总数据

在本书的前几章中,我们专注于开发一组基础编程模式——以 map 和 reduce 风格——允许我们扩展我们的编程。我们可以使用我们迄今为止所介绍的技术来充分利用我们的笔记本电脑的硬件。我已经向您展示了如何使用 map (第二章)、reduce (第五章)、并行性 (第二章) 和惰性编程 (第四章) 等技术来处理大型数据集。在本章中,我们开始探讨在笔记本电脑之外处理大型数据集。

在本章中,我们介绍了分布式计算——即在多台计算机上发生的计算——以及我们将用于进行分布式计算的两个技术:Apache Hadoop 和 Apache Spark。Hadoop 是一套工具,通过 Hadoop MapReduce 支持分布式 map 和 reduce 风格的编程。Spark 是一个分析工具包,旨在使 Hadoop 现代化。我们将专注于 Hadoop 的批处理大型数据集,并专注于在分析和机器学*用例中应用 Spark。

7.1. 分布式计算

在本章中,我们将回顾分布式计算的基础——这是一种计算方法,我们不仅共享单个工作流程,而且长期在计算机网络上共享任务和数据。以这种方式进行计算具有挑战性,例如跟踪所有我们的数据和协调我们的工作,但当我们能够并行化我们的工作时,它提供了速度上的巨大好处。

在 第一章 中,我概述了三种数据集的大小。那些

  1. 足够小,可以在单台计算机的内存中处理

  2. 太大,无法在单台计算机的内存中处理,但足够小,我们可以用单台计算机处理它们

  3. 既有太大无法适应单台计算机的内存,又有太大无法在单台计算机上处理

第一个数据集大小不会带来固有的挑战:大多数开发者可以很好地处理这些数据集。然而,在第二个大小——太大而无法存入内存,但我们仍然可以本地处理——和第三个大小之间,大多数人会开始说他们在处理大数据集。换句话说,他们开始遇到问题,无法用数据集做他们想做的事情,有时这是合理的——如果我们有一个第三个大小的数据集,而只有一台计算机,我们就无能为力了。

分布式计算解决了这个问题(图 7.1)。这是编写和运行程序不是为单台计算机,而是为计算机集群的行为。这个计算机集群协同工作以执行任务或解决问题。当我们与并行编程结合使用时,我们可以有效地使用分布式计算。

图 7.1. 分布式计算涉及多台计算机协同工作以执行单个任务。

07fig01_alt.jpg

如果我们回顾一下我们关于并行编程的讨论,我们讨论的主要优势是并行编程允许我们同时完成许多不同的工作。我们将手头的任务分成几部分,一次处理几部分。对于小问题,这几乎没有好处。然而,随着任务的增大,我们看到了并行化的价值。通过使用分布式计算,我们可以放大这种效果(图 7.2)。

图 7.2. 分布式计算使我们能够通过在多台机器上并行化我们的工作来减少我们的计算时间。我们可以使用分布式计算在几天、几小时或几分钟内解决那些原本需要几周时间的问题。

07fig02_alt.jpg

当我们将计算机添加到我们的工作流程中时,我们正在添加这些计算机的所有处理能力。例如,如果我们添加的每台计算机都有四个核心,每次我们向我们的集群添加一台新机器时,我们就会增加四个额外的核心。如果我们从一台四核心的机器开始,并行运行可能会将我们的处理时间缩短到原来的四分之一,但如果有两台机器,我们可能会缩短到八分之一。再添加两台机器可能会将我们处理数据所需的时间缩短到原来的十六分之一。

尽管在单台机器上我们可以合理拥有的处理器数量有物理限制,但在分布式网络中我们可以拥有的处理器数量没有限制。专门的超级计算机可能拥有分布在数万台机器上的数十万个处理器,而科学计算网络为从事严肃数值计算的科研人员提供了数十万台计算机。更常见的是,公司、政府机构、非营利组织和研究人员都在转向云服务以获取按需集群计算。我们将在第十二章中更多地讨论这一点。

当然,分布式计算并非没有缺点。通信的诅咒再次出现。如果我们过早地分配我们的工作,我们最终会因在计算机和处理器之间花费太多时间交谈而失去性能。在分布式计算的高性能极限上,许多性能改进都围绕着优化机器之间的通信。

然而,对于大多数用例,我们可以放心,当我们考虑分布式工作流程时,我们的问题已经如此耗时,以至于分配工作肯定能加快速度。一个指标是,分布式工作流程通常以分钟或小时来衡量,而不是我们传统上用来衡量计算过程的秒、毫秒或微秒。

7.2. Hadoop 用于批量处理

在本节中,我们将讨论 Apache Hadoop 的基本原理。Hadoop 是一个突出的分布式计算框架,你可以用它来解决甚至最大的数据集。我们首先将回顾 Hadoop 框架的不同部分,然后我们将编写一个 Hadoop MapReduce 作业来看到框架的实际应用。

Hadoop 框架专注于在分布式集群上处理大数据集。Hadoop 的基本前提是我们可以将我们迄今为止看到的映射和归约技术结合起来,再加上将我们的代码(而不是我们的数据)移动到我们的想法,以解决大小数据集的问题。

我们可以在 Hadoop 和我们在这本书中迄今为止思考计算方式之间找到很多相似之处。我一直宣扬我们应该从小(和本地)开始,然后随着我们需要更多资源而扩展。Hadoop 承诺了同样的事情。你可以在单个本地机器上开发和测试,然后扩展到托管在云中的千机集群。Hadoop 以我们相同的方式倡导这一点,通过映射和归约风格的编程。

7.2.1. 了解五个 Hadoop 模块

Hadoop 框架包括五个用于大数据集处理和集群计算的模块(图 7.3):

  1. MapReduce— 将工作划分为可并行处理块的方法

  2. YARN— 调度器和资源管理器

  3. HDFS— Hadoop 的文件系统

  4. Ozone— 用于对象存储和语义计算的 Hadoop 扩展

  5. Common— 在前四个模块之间共享的一组实用工具

图 7.3. Hadoop 框架由五个软件组件组成,每个组件都针对不同的大数据集处理问题。

07fig03_alt.jpg

MapReduce 是对本书中已经看到的映射和减少步骤的实现,旨在在分布式集群上并行工作。YARN 是一个具有集群管理功能的作业调度服务。HDFS 或 Hadoop 分布式文件系统是 Hadoop 的数据存储系统。Ozone 是一个新(版本 0.3.0,在我撰写本文时)的 Hadoop 项目,提供了语义对象存储功能。Common 是一组适用于所有 Hadoop 库的通用工具。

我们现在将简要介绍前三个——MapReduce、YARN 和 HDFS。这三个库是经典的 Hadoop 栈。Hadoop 分布式文件系统管理数据,YARN 管理任务,MapReduce 定义数据处理逻辑(图 7.4)。

图 7.4. 经典的 Hadoop 栈是 MapReduce,运行在 YARN 之上,运行在 HDFS 之上。

Hadoop 对映射和减少的独特处理

本书我们将关注的 Hadoop 的主要方面是 MapReduce 库。Hadoop MapReduce 是一个庞大的数据处理库,我们可以通过扩展到数十、数百甚至数千个工作机来扩展其映射和减少编程风格,使其能够处理高达数十太字节甚至拍字节的数据。MapReduce 将编程任务分为两个任务:一个 map 任务和一个 reduce 任务——就像我们在第五章末看到的那样。

YARN 用于作业调度

YARN 是一个作业调度器和资源管理器,将资源和作业管理分为两个组件:调度和应用程序管理。调度器,或 资源管理器,监督所有正在进行的工作,并在资源如何在集群中分配方面作为最终决策者。应用程序管理器,或 节点管理器,在节点(单机)级别工作,以确定在该机器内部如何分配资源(图 7.5)。应用程序管理器还监控其节点内发生的事情,并将该信息报告给调度器。

图 7.5. YARN 资源管理器监督整个作业,而节点管理器监督单个节点内发生的事情。

在极端高需求的使用案例中,我们可以将资源管理器连接起来,其中数千个节点不足以满足需求。这个过程被称为 联邦化。当我们联邦化 YARN 资源管理器时,我们可以将多个 YARN 资源管理器视为单个资源管理器,并在多个子集群中并行运行,就像它们是一个单一的庞大集群一样。

Hadoop 的数据存储骨干:HDFS

Hadoop 框架的基础是其分布式文件系统抽象,恰如其名,称为 Hadoop 分布式文件系统。Hadoop 作者设计 HDFS 以适应用户希望的情况

  • 处理大型数据集(数太字节及以上;太大,无法进行本地处理)

  • 在硬件选择上要灵活

  • 防止硬件故障——一个常见的集群计算问题

此外,HDFS 还基于另一个关键观察:移动代码比移动数据更快。当我们介绍并行化第二章时,我们讨论了 Python 的基本 map 如何同时移动代码和数据。这在某种程度上是有效的,但最终移动数据的成本——尤其是如果数据文件很大或很多——变得过高,以至于无法证明并行化的好处。我们在第五章结尾遇到的问题同样存在:并行化的成本超过了并行工作的好处。

通过在集群中分布数据并将代码移动到数据处,我们避免了这个问题。代码——即使在其最长、最晦涩的形式中——也将比它需要处理的数据更小,且移动成本更低。在典型情况下,我们的数据量很大,而代码量很小。

分布式文件系统

HDFS 是高性能分布式计算的一个可靠、高效的基石,但这也带来了复杂性。由于本书不专注于数据工程,我选择省略 HDFS 的细节。《Hadoop in Action》一书(Manning,2010)对 HDFS 进行了更深入的探讨,并包含了常见 HDFS 操作的食谱式配方。本书的作者 Chuck Lam 在第 3.1 节介绍了 Hadoop 的分布式文件系统,并在第八章中对 HDFS 进行了深入探讨。

7.3. 使用 Hadoop 查找高分词

现在我们已经了解了 Hadoop 的基础知识,让我们深入一些代码,真正看看它是如何工作的。考虑以下场景。(您可以在本书的在线代码库中找到场景所需的数据:github.com/jtwool/mastering-large-datasets。)

场景

你的两个朋友——一个是护士,另一个是流行文化评论家——已经就一个奇特的话题争论了几天:看似无关的两位人物佛罗伦萨和机器(一支当代英国摇滚乐队)以及弗洛伦斯·南丁格尔(一位传奇的英国护士)的相对复杂程度。为了解决他们的争论,你被要求计算佛罗伦萨和机器的歌曲以及弗洛伦斯·南丁格尔的著作中超过六个字母的单词出现的频率。

要做到这一点,我们需要做几件事情:

  1. 安装 Hadoop

  2. 准备一个 mapper——一个用于我们 map 转换的 Python 脚本

  3. 准备一个 reducer——一个用于我们归约的 Python 脚本

  4. 从命令行调用 mapper 和 reducer

7.3.1. 使用 Python 和 Hadoop Streaming 进行 MapReduce 作业

在我们深入了解实现细节之前,让我们看看 Hadoop 的 MapReduce 做了什么。Hadoop 的 MapReduce 是一个用 Java 编写的软件组件,我们可以用它来在分布式系统上执行 MapReduce。当我们谈论使用 Python 运行 Hadoop MapReduce 时,我们(一般而言)是在谈论运行 Hadoop Streaming,这是一个 Hadoop 实用程序,用于使用 Java 以外的编程语言来使用 Hadoop MapReduce。

要运行该实用程序,我们将从命令行调用它,并附带选项,例如

  • 映射器

  • 算子

  • 输入数据文件

  • 输出数据位置

Hadoop 提供了一个示例代码片段来演示此命令。此代码片段的注释版本出现在图 7.6 中。

图 7.6. 使用 Hadoop 流和 Unix 工具的 Hadoop 单词计数示例。

图 7.6 中的代码片段调用了两个 Unix 命令来充当其映射器和归约器。/bin/cat指的是 Unix 的连接软件,而/bin/wc指的是 Unix 的单词计数软件。这样一起使用,cat将打印文本,而wc将计数单词。Hadoop 将确保这些操作在位于输入位置的目录中的文档上并行执行,并将结果写入输出目录。

一旦运行,结果就是我们可以在我们指向的任何目录中进入,并检索单词计数。在我们继续到完整示例之前,让我们用 Python 实现单词计数的映射器和归约器。为了模拟cat功能,让我们将每个单词打印到新的一行。为了模拟wc功能,我们将为遇到的每个单词增加一个计数器。我们需要将这两个功能包装在单独的可执行脚本中。

映射器可能看起来像列表 7.1,而归约器可能看起来像列表 7.2。

列表 7.1. Python 中的单词计数映射器
#!/usr/bin/env python3
"""Print words to lines"""
import sys

for line in sys.stdin:
  for word in line.split():
    print(word)
列表 7.2. Python 中的单词计数归约器
#!/usr/bin/env python3
"""Count words"""
import sys
from functools import reduce

print(reduce(lambda x, _:x+1, os.stdin, 0))

在这两个示例中,有一些奇怪的新情况正在发生。首先,我们正在从stdin读取。这是因为 Hadoop 为我们处理文件的打开,以及将大文件切割成更小的部分。Hadoop 旨在用于处理大规模文件,因此能够将大文件分割到多个处理器上是很重要的。我们还可以使用 Hadoop 来处理压缩数据——它原生支持如.gz、.bz2 和.snappy(如表 7.1 所示)这样的压缩格式。

表 7.1. Hadoop 开箱即用的可用压缩格式的比较
格式 描述 用例 Hadoop Codec
.bz2 慢速压缩,但比旧算法更能缩小文件大小 半长期存储,人与人之间的文件传输 BZip2Codec
.gz 快速,支持良好的压缩算法 进程间文件传输(如 Hadoop 步骤) GzipCodec
.snappy 新的、快速的压缩算法;比 .gz 支持少,但压缩效果更好 进程之间(如 Hadoop 步骤)的文件传输 SnappyCodec

第二,我们的两个脚本都将输出打印到终端。这同样是因为 Hadoop 的方向。Hadoop 将捕获打印到 stdout 的内容,并在工作流程的后续步骤中使用它。这在我们标准工作流程之上创建了一个额外的步骤,并可能导致我们必须将字符串转换为 Python 对象。

最后,这两个脚本都以 Python 的 shebang 开头。这一行告诉计算机将这些脚本作为可执行文件使用。Hadoop 将尝试使用指定 shebang 路径上的程序调用这些脚本,在这种情况下,是 Python。

如果你还没有尝试过,用我们的两个脚本替换之前的映射器和归约器将允许我们运行我们的 MapReduce 作业。这将在下面的列表中展示。

列表 7.3. 使用 Python 运行 Streaming MapReduce
$HADOOP_HOME/bin/hadoop  jar $HADOOP_HOME/hadoop-streaming.jar \
    -input myInputDirs \
    -output myOutputDir2 \
    -file ./wc_mapper.py \
    -mapper ./wc_mapper.py \
    -file ./wc_reducer.py
    -reducer ./wc_reducer.py \

此命令的输出将在我 OutputDir2 目录下的一个名为 results 的文件中。结果应该与我们在图 7.6 中调用的命令返回的第二个数字相同。

7.3.2. 使用 Hadoop Streaming 计分单词

让我们回到我们寻找长单词计数的例子。对于 Hadoop,我们将只关注 Florence and the Machine 的单词。(我们将在本章后面将 Florence Nightingale 的文本保存到 Spark。)要使用 Hadoop 获取特定单词的计数——而不是简单地获取单词的总计数——我们必须修改我们的映射器和归约器。在我们直接跳入代码之前,让我们看看这个过程将如何与我们的词计数示例进行比较。我已经将这两个过程,一步一步地,图解在图 7.7 中。

图 7.7. 计算单词和获取单词子集的频率具有相似的形式,但需要不同的映射器和归约器。

07fig07_alt.jpg

在我们的词频映射器中,我们必须从文档中提取单词并将它们打印到终端。对于我们的长单词频率示例,我们将做类似的事情;然而,我们希望添加一个检查以确保我们只打印出长单词。请注意,这种行为——执行我们的过滤和将文档分解成单词序列——与工作流程可能在 Python 中执行的方式非常相似。当我们遍历序列时,转换和过滤将懒加载地应用于文档的行。

对于我们的词计数归约器,我们有一个计数器,每次我们看到一个单词时都会增加。这次,我们需要更复杂的行为。幸运的是,我们已经有这种行为在手上了。我们已经多次实现了频率归约,并且可以在这里重用那段归约代码。让我们修改我们的归约器从列表 7.2,使其使用我们在第五章中首次编写的make_counts函数。我们的映射器将类似于列表 7.4,我们的归约器将类似于列表 7.5。

列表 7.4. Hadoop mapper 脚本用于获取和过滤单词
#!/usr/bin/env python3
import sys

for line in sys.stdin:
  for word in line.split():
    if len(word)>6: print(word)
列表 7.5. Hadoop reducer 脚本用于累计计数
#!/usr/bin/env python3
import sys
from functools import reduce

def make_counts(acc, nxt):                      *1*
    acc[nxt] = acc.get(nxt,0) + 1
    return acc

for w in reduce(make_counts, sys.stdin, {}):    *2*
    print(w)
  • 1 这是来自第五章的 make_counts 函数。

  • 2 我们将其应用于 sys.stdin 流,这是我们数据将进入的地方。

我们 MapReduce 作业的输出将是一个包含一系列单词及其计数的单个文件。结果应该看起来像图 7.8。我们还应该看到一些打印到屏幕上的日志文本。我们可以快速检查,看看所有单词的长度都超过六个字母,正如我们所希望的。在第八章中,我们将更深入地探讨 Hadoop,并处理超出单词过滤和计数的场景。

图 7.8. 我们 MapReduce 作业的输出是一系列单词。

7.4. Spark 用于交互式工作流程

到目前为止,在本章中,我们一直在谈论用于处理大数据集的 Hadoop 框架。在本节中,我们将把注意力转向另一个流行的用于大数据集处理的框架:Apache Spark。Spark 是一个面向分析的数据处理框架,旨在利用现在可用的具有更高 RAM 的计算集群。

从大多数 Python 程序员的视角来看,Spark 提供了其他一些优势:

  • Spark 有一个直接的 Python 接口——PySpark。

  • Spark 可以直接查询 SQL 数据库。

  • Spark 有一个DataFrame API——一个行列数据结构,对于有pandas经验的 Python 程序员来说应该很熟悉。

7.4.1. Spark 中的内存中大数据集

如我们在第 7.3 节的介绍中简要提到的,Spark 在分布式网络上内存中处理数据,而不是将中间数据存储到文件系统中。这可以在某些工作流程中相对于 Hadoop 提高处理速度高达 100 倍,更不用说 Spark 任务与线性 Python 任务之间的差异了。这个缺点是 Spark 需要具有更大内存容量的机器。

选择 Spark 还是 Hadoop

因为 Spark 充分利用了集群的 RAM,所以当我们

  • 正在处理流数据

  • 需要几乎瞬间完成任务

  • 愿意为高 RAM 计算集群付费

Spark 使用内存处理,这意味着我们不一定需要将数据保存到任何地方。这使得 Spark 非常适合流数据——这是大数据传统定义的一个方面。我们应该将 Hadoop 保留用于批量处理。

因为 Spark 可以比 Hadoop 快得多,所以当我们需要几乎即时处理数据时,我们应该使用 Spark。当然,这只有在一定范围内才是真正可行的。最终,数据将太大而无法立即处理,除非我们向问题投入不合理的资源。

这种情况直接关联到我们列表中的最后一个因素:如果资金不是问题,我们可以自由选择 Spark。因为 Spark 在访问许多高 RAM 机器时运行得更快,如果我们能够负担得起组装一个高 RAM 机器的集群,那么 Spark 显然是最佳选择。Hadoop 旨在充分利用低成本计算集群。

正如你所想象的那样,关于使用哪个分布式计算框架的答案并不总是那么明确;然而,我们在整本书中开发的 map 和 reduce 风格将很好地服务于你在两个框架中处理大数据集。

7.4.2. PySpark 用于 Python 和 Spark 的混合

Spark 是为数据分析而设计的,我们可以从 Spark 设计团队致力于开发 Python 和 R 的 API 中看到这一点。像 Hadoop 一样,Spark 被编写为在 Java 虚拟机(JVM)上运行,这通常会使科学家、研究人员、数据科学家或业务分析师难以使用,他们通常使用 Python、R 和 Matlab 等语言。我们在 Hadoop 中遇到了这个问题。我们无法直接通过 Python 与 Hadoop 交互。相反,我们必须通过 Hadoop Streaming 调用我们的 Python 函数,并且我们必须使用一些笨拙的解决方案来处理 Python 数据,而不仅仅是字符串。当我们使用 Spark 时,我们可以使用其 Python API PySpark 来解决这个问题。

使用 PySpark,我们可以像调用正常的 Python 库一样通过 Python 调用 Spark 的 Scala 方法,通过导入所需的模块和函数。例如,我们经常使用SparkConfSparkContext函数来设置我们的 Spark 作业。我们将在深入探讨 Spark 的第九章中更多地讨论这些函数。现在,我们可以通过从 PySpark 导入它们来在 Python 中使用它们,如下面的列表所示。

列表 7.6. 从 Spark 导入到 Python
from pyspark import SparkConf, SparkContext

config = SparkConf().setAppName("MyApp")
context = SparkContext(conf=config)

当我们深入到第 7.5 节中的 PySpark 时,我们将在本章的后面看到这一点。

7.4.3. 使用 Spark SQL 进行企业数据分析

Spark 的一个重要优势是它通过 Spark SQL 支持 SQL 数据库。Spark SQL 建立在广泛使用的 Java 数据库连接(通常缩写为 JDBC)之上,这使得处理结构化数据变得容易。如果我们正在处理企业数据,这一点尤为重要。企业数据指的是常见的商业数据——如人力资源或员工数据、财务或工资数据,以及销售订单或运营数据——以及存储这些数据最常见的方式——关系数据库,尤其是 Oracle DB 或 Microsoft SQL Server。

由于 Spark 首先是为 Scala 设计的,因此 Spark SQL Python API 不符合 Python 数据库连接的 PEP 249 规范。尽管如此,其核心功能直观易懂,并且我们可以使用它与任何具有 JDBC 连接的数据库一起使用,包括 MySQL、PostgreSQL 和 MariaDB 等流行的免费和开源数据库。在最简单的情况下,使用 Spark 查询数据库与将我们的 SQL 查询传递给 SparkSession 对象的 .sql 方法一样简单。

7.4.4. Spark DataFrame 的数据列

当我们使用 Spark 查询数据时,我们的数据最终会变成一个名为 DataFrame 的 Spark 类,我们可以将其视为与 SQL 表或 pandas.DataFrame 等效。然而,与 SQL 表或 pandasDataFrame 不同,Spark 中的 DataFrame 优化了分布式计算工作流程。

与 SQL 和 pandas 一样,Spark DataFrames 是围绕具有名称的列组织的。如果我们想为机器学*或统计摘要制作数据的条件子集,这很有帮助。例如,如果我们想获取拥有超过 20 个订单的客户的平均购买大小,我们可以使用 DataFrame .filter.agg 方法,结合 Spark 对我们列名的了解,来获取这些信息。我们可以在 图 7.9 中看到这个示例。

图 7.9. Spark DataFrames 有一个 .filter 方法,我们可以用它来快速获取大数据集的子集。

DataFrame.filter 版本与我们在 第 4–6 章节中看到的 filter 函数的作用类似。事实上,许多以 map 和 reduce 为导向的数据处理函数都进入了 pyspark.sql.functions 库,包括 zip 作为 arrays_zipDataFrame API 是一个更通用的 API,它提供了一个便利层在核心 Spark 数据对象之上:RDD 或弹性分布式数据集。RDDs 是 Hadoop 的抽象,为 Spark 的内存分布式处理提供动力,PySpark RDD API 提供了对我们熟悉的所有函数的访问,包括 mapreducefilterzip。我们将在下一节中看到这些函数的示例。

7.5. Spark 中的文档单词得分

现在我们已经了解了 Spark 的基础知识,让我们深入一些代码。在本章前面的示例中,我们找到了来自 Florence and the Machine 乐队歌曲中超过六个字母的所有单词。这证明了他们的歌词复杂性和帮助我们了解 Hadoop。在本节中,我们将通过在 Spark 中对 Florence Nightingale 的文档运行相同的过程来完成 Florence and the Machine 与 Florence Nightingale 之间的比较。

如 第 7.3 节 中所述,我们将这个过程分解为三个领域:

  1. 一个映射器

  2. 一个减少器

  3. 在 Spark 中运行代码

我们的映射器将负责将文件转换为包含六个以上字符的单词序列,而归约器将负责计算我们找到的单词。在 Spark 中运行代码将并行化我们的工作流程。我们可以在图 7.10 中看到这个过程是如何展开的。

图 7.10. 在 Spark 中,计算弗洛伦斯·南丁格尔使用过的关键词涉及三个步骤。

7.5.1. 设置 Spark

在我们开始 Spark 作业之前,让我们花点时间来设置 Spark。与设置 Hadoop 不同——如果您不熟悉 Java,这可能是一个棘手的过程——安装 Spark 相当直接。访问spark.apache.org/downloads.html,按照页面上的下载说明操作,就这样!您已经拥有了使用 Spark 所需的一切。

Spark 集群

正如我们在本书中没有深入探讨如何设置 Hadoop 集群一样,我们也不会深入探讨如何设置 Spark 集群——尽管我们将在第十二章中向您展示如何为这些技术配置云资源。如果您在接下来的两章半时间里对 Spark 感兴趣,Manning 有几本书是专门介绍 Spark 的,包括Spark in Action(2016)和Spark GraphX in Action(2016)。

现在我们已经安装了 Spark,我们可以运行 Spark 作业并使用 PySpark 与 Spark 交互。执行这些操作的最简单方法是通过 Spark 提供的工具。就像 Hadoop 为我们提供了 Hadoop Streaming 工具一样,Spark 提供了两个工具:一个用于设置交互式 Python shell 的pyspark,另一个允许我们运行 Spark 作业——类似于 Hadoop streaming 的spark-submit

交互式探索大数据

Spark 之所以如此受欢迎,其中一个原因是可以通过 PySpark shell REPL 交互式地探索大数据。这种更轻松的开发风格,即我们逐行迭代问题,比一次性编写大量代码更受许多数据科学家的欢迎。它还允许我们在开发过程中查看我们的中间结果或查阅 Python 文档。

我们通过运行实用程序 pyspark 来启动这个过程。这个过程会弹出一个屏幕——就像图 7.11——在这里我们可以输入 Python 命令。一开始,我们就有了对 SparkContextSparkSession 实例的访问权限,分别以 scspark 的形式。(pyspark 实用程序为我们导入了它们;当我们编写自己的 Spark 脚本时,我们将需要自己导入它们。)sc 变量具有构建我们在 7.4.4 节中提到的弹性分布式数据集实例的方法。我们可以使用 spark 变量将数据带入 DataFrames——我们在 7.4.4 节中也提到过的并行优化的表格数据抽象。如果我们在这交互式会话中对这些变量运行 python 的 help 命令,我们将看到每个变量可用的方法列表。我们将在本书中介绍其中的一些,但每个变量的方法完整列表可在在线文档中找到。

图 7.11. Spark 提供了一个交互式终端,我们可以在这里运行 Python 命令,并利用 Spark 集群的全部功能。

![07fig11_alt.jpg]

运行作业

当我们不与 Spark 交互式工作的时候,我们将通过运行 Spark 作业来与之工作。这个过程与我们在 Hadoop 中运行 MapReduce 作业的过程类似。我们编写一些代码,然后将它们作为参数传递给一个实用程序。在 Spark 的情况下,我们将使用 spark-submit 实用程序,并将单个 Python 脚本传递给它。

在那个 Python 脚本中,我们可以创建我们需要的任何 Spark 对象的实例。一旦我们导入了 pyspark 模块,我们就可以访问它们。让我们看看这种与 Spark 一起工作的方法是如何实施的。

7.5.2. 使用 spark-submit 的 MapReduce Spark 作业

将我们的注意力转回到手头的问题——弗洛伦斯·南丁格尔的词汇优秀——我们将我们的工作分为三个步骤:

  1. 将文档转换成一系列单词

  2. 将这些单词过滤成超过六个字符的单词

  3. 收集剩余的计数

当我们在 Hadoop 中完成这个过程时,我们在映射器中一起完成了步骤 1,将文档转换成一系列单词,以及步骤 2,过滤掉小单词。在 Spark 中,这三个步骤都将独立存在。

在 Spark 中完成这个过程,我们首先想要做的是将我们的数据带入一个 RDD——Spark 强大的并行数据结构。这对于 Spark 中的大多数工作来说是一个好的起点。为了做到这一点,我们需要一个 SparkContext,因此我们必须实例化一个 SparkContext 实例。然后我们可以使用 SparkContext.textFile 方法从我们的文件系统中读取文本文件。此方法创建一个包含那些文档行作为元素的 RDD

我们可以通过调用RDD.flatMap方法将这个数据集转换成一个单词序列。.flatMap方法类似于map,但结果是一个扁平序列,而不是嵌套序列。.flatMap也返回一个RDD,因此我们可以使用RDD.filter方法来过滤出只有大单词,然后使用该结果RDD.countByValue方法来收集计数。我们可以在以下列表中仅用几行代码看到整个流程。

列表 7.7. 在 Spark 中统计六个字母或以上的单词
#! /usr/bin/env python3
import re
from pyspark import SparkContext

if __name__ == "__main__":                           *1*
  sc = SparkContext(appName="Nightingale")           *2*
  PAT = re.compile(r'[-./:\s\xa0]+')                 *3*
  fp = "/path/to/florence/nightingale/*"
  text_files = sc.textFile(fp)                       *4*
  xs = text_files.flatMap(lambda x:PAT.split(x))\    *5*
                 .filter(lambda x:len(x)>6)\         *6*
                 .countByValue()\                    *7*

  for k,v in xs.items():                             *8*
    print("{:<30}{}".format(k.encode("ascii","ignore"),v))
  • 1 因为这是一个脚本,大部分代码只有在作为这样的调用时才会运行。

  • 2 初始化 SparkContext,其中 appName 是一个可选但有用的参数

  • 3 使用正则表达式来提高我们的分割质量

  • 4 .textFile 将加载所有匹配的文件作为一个 RDD。

  • 5 然后,我们可以使用 RDD 的.flatMap 将每一行转换为单词。

  • 6 这些单词可以被过滤成只有大单词。

  • 7 最后,我们可以使用内置方法进行计数。

  • 8 为了方便起见打印结果

当你运行完代码后,你应该会看到一个包含大量单词的长列表输出。如果一切正常,这些单词的长度都应该超过六个字母。还会有一系列与运行此代码的 Spark 作业相关的输出。最终结果将类似于以下列表。

列表 7.8. Spark 的代码输出,统计大单词
hurting                       10
Englishman                    1
Conceit                       1
contain                       1
deficient                     1
especially                    9
weekend                       2
pretend                       1
weaknesses,                   1
servants                      1
suppose                       2
forever                       4
stagnant                      2

与 Hadoop 不同,在 Hadoop 中我们可以自由地将结果打印出来以便写入输出文件,而在 Spark 中,我们通常希望直接将结果写入文件。这样,我们就不必从大量的终端消息中挖掘它们。在接下来的三章中,我们将通过更深入的示例来探讨使用 Hadoop 和 Spark 的一些最佳实践。

7.6. 练*

7.6.1. Hadoop streaming scripts

我们为 Hadoop Streaming 作业编写的脚本叫什么?(选择一个。)

  • Mapper 和 Reducer

  • 应用程序和累加器

  • 函数和文件夹

7.6.2. Spark interface

当我们与 Spark 交互时,我们将通过 PySpark 进行,PySpark 是围绕用哪种编程语言编写的 Spark 代码的 Python 包装器?(选择一个。)

  • Clojure

  • Scala

  • Java

  • Kotlin

  • Groovy

7.6.3. RDDs

Spark 的创新集中在一种称为RDD的数据结构上。RDD代表什么?(选择一个。)

  • 弹性分布式数据集

  • 可靠定义数据

  • 可减少的持久定义

7.6.4. Passing data between steps

使用 Hadoop Streaming 时,我们需要手动确保数据可以在 map 和 reduce 步骤之间传递。每个步骤结束时我们需要调用什么?(选择一个。)

  • return

  • yield

  • print

  • pass

摘要

  • Hadoop 是一个 Java 框架,我们可以用它来在分布式集群上运行代码。

  • 当为 Hadoop MapReduce 作业编写 Python 时,我们为 mapper 和 reducer 编写一个脚本。

  • Python mapper 脚本和 Python reducer 脚本都需要将结果 print 到控制台。

  • 在 Spark 中,我们可以编写一个单一的 Python 脚本,处理我们问题的 map 和 reduce 部分。

  • 我们通过 pyspark API 使用 Python 与 Spark 交互。

  • 我们可以以交互模式或运行作业的方式与 Spark 一起工作——这为我们提供了灵活的开发工作流程。

  • Spark 有两种高性能数据结构:RDDs,适用于任何类型的数据,以及 DataFrames,针对表格数据进行了优化。

第八章. 使用 Apache Streaming 和 mrjob 的大型数据集最佳实践

本章涵盖

  • 使用 JSON 在 Apache Streaming 步骤之间传输复杂的数据结构

  • 编写 mrjob 脚本以与 Hadoop 交互,而不使用 Apache Streaming

  • 将 mapper 和 reducer 视为键值消费者和生产者

  • 使用 Apache Hadoop 分析网络流量日志和网球比赛日志

在 第七章 中,我们学*了两个用于处理大型数据集的分布式框架:Hadoop 和 Spark。在本章中,我们将深入探讨 Hadoop——一个基于 Java 的大型数据集处理框架。正如我们在上一章中提到的,Hadoop 有很多好处。我们可以使用 Hadoop 来处理

  • 快速处理大量数据——分布式并行化

  • 重要的数据——低数据丢失

  • 完全巨大的数据量——PB 级别

不幸的是,我们也看到了与 Hadoop 一起工作的缺点:

  • 要使用 Python 与 Hadoop 一起工作,我们需要使用 Hadoop Streaming 工具。

  • 我们需要反复从 stdin 读取字符串。

  • Java 的错误信息并不特别有帮助。

在本章中,我们将通过分析一些场景来探讨如何处理这些问题。我们将分析网球运动员随时间的变化技能,并找出体育中最有才华的运动员。

8.1. 非结构化数据:日志和文档

Hadoop 的创建者设计了 Hadoop 以处理 非结构化数据——这是一个指代文档形式数据的术语。尽管它们通常会包含有用的、可解释的元数据——例如,作者或日期——但重要内容通常不受形式限制。非结构化数据的经典例子是一个网页。

网页是用 HTML 编写的,并有一些通用的格式要求:

  • 页面以 head 标签开始。

  • 在 head 标签内包含 CSS 和 JavaScript 导入。

  • head 标签内也应包含一些元数据——可能是对页面或页面标题的描述。

  • 然后是 body 标签,它是页面的主要内容。

网页包含有用的元数据——我们可以快速编写一些代码来找到任何网页的标题,甚至其关键词和描述(如果它们作为元数据列出),然而,页面的这些方面并不是人们访问它的原因。用户感兴趣的全部都在 body 部分。当然,网页的主体可以包含作者喜欢的任何内容,如文本、图像、视频、音乐等。

将此与其他常见的非结构化数据形式进行比较,例如社交媒体内容、文本或办公文档、电子表格和日志。如果我们考虑一条社交媒体帖子,我们知道这些帖子有需要或推断的字段(如发布时间),但更重要的是,它们还有自由格式字段,如推文或 Facebook 状态更新中的文本。如果我们考虑办公文档,我们知道我们的办公软件会记录诸如最后保存时间和编辑文档的用户姓名等信息,但文档的主体可以是情书到商业报告的任何内容。如果我们考虑日志数据,我们通常有更多的结构——机器比人做更多的日志记录——然而,日志通常以被认为是非结构化的文件格式保存,如纯文本,因此归入这一类别。

非结构化数据因其难以处理而闻名。它不适合大多数数据分析师所熟悉的那种表格分析。这使得涉及非结构化数据的问题对分析师来说更加令人沮丧,因为他们的标准技巧通常不起作用,对客户来说也不那么令人满意,因为分析过程更长,可能收获更少。

同时,非结构化数据是周围最常见的几种数据形式之一。那些努力评估他们有多少结构化数据和非结构化数据的公司发现,非结构化数据通常占他们数据的 80%以上,有时甚至高达 95%。当我们考虑到个人网页、社交媒体、电子邮件、博客和其他自出版平台都会产生非结构化数据时,这一点是有道理的。

将数据以非结构化格式存储确实有一些优点,主要是有利于它与依赖它的系统松散耦合。如果数据的格式不在你的控制之下(例如,它来自另一个组或另一个公司拥有的系统),或者你正在处理多种不同格式的数据,那么保持数据非结构化将提供优势,因为你永远不需要重新结构化数据存储以适应变化。这些关于非结构化数据的事实——尤其是其普遍性——使得拥有一个像 Hadoop 这样的工具变得重要,Hadoop 是为非结构化数据设计的。

8.2. 使用 Hadoop 进行网球分析

为了展示 Hadoop 的力量——以及我们如何使用它将日志风格的数据转换为可用的信息——我们将从网球界的一个例子入手。

场景

一个新的职业网球联赛正在形成,他们聘请你来为职业网球运动员制定技能评估,以便他们可以针对新联赛招募球员。他们已经为你提供了几年的比赛数据,并希望你能提供一份球员及其对应技能的名单。

解决这个问题将涉及三个步骤。我们需要

  1. 读取每场比赛的数据

  2. 更新每场比赛胜者和败者的排名

  3. 在所有工作完成后对排名进行排序

我们将把这些步骤分解成 Hadoop MapReduce 作业,采用我们在第七章中学到的流式处理风格。回顾第七章,我们知道我们需要一个映射器脚本和一个减少器脚本。我们的映射器脚本将处理第一步,我们的减少器脚本将处理第二步和第三步(图 8.1)。

图 8.1. 网球分析问题需要三个步骤,这些步骤在映射器和减少器脚本之间分割。在映射器中,我们组装所需的信息,在减少器中,我们排名和排序球员。

图片

图 8.1 显示了数据在通过此过程时的样子。我们将从我们的输入数据文件开始,从那些文件中读取比赛,然后我们将比赛减少为每个网球球员的评级。最后,我们将对比赛进行排序,以按顺序返回球员。随着数据移动,你会注意到它从逗号分隔的字符串变为键值对,再到一系列键值对。

8.2.1. 用于读取比赛数据的映射器

因为第一步被巧妙地包含在我们的映射器脚本中,所以让我们从那里开始我们的过程。我们将首先检查比赛是如何包含在文件中的,部分内容已在图 8.2 中预览。

图 8.2. 网球比赛日志包含以逗号分隔的字符串形式的比赛。

图片

在文件中,我们可以看到每场比赛都是一行——就像我们为 Hadoop 所需要的那样——并且每一行包含描述比赛的多个属性,例如胜者、败者、场地和更多。就我们的目的而言,我们可以关注这三个元素:胜者、败者和场地。

为了访问每场比赛的这些元素,我们需要在每个行上分割逗号,然后通过编号调用我们想要的元素:场地在第 2 位,胜者在第 10 位,败者在第 20 位。这对阅读我们脚本的人来说并不特别清晰,所以我们将使用键值对将数据传递给我们的减少器函数。键值对比逗号分隔值数据提供了更大的可解释性,但存储起来更庞大。键值对存储成本更高,因为除了值之外,还必须存储键,而逗号分隔值字符串不需要键。

用于在映射器和减少器之间传递数据的 JSON

为了在映射器和减少器之间传递键值对,我们将使用一种称为 JSON 的数据交换格式。JSON——或 JavaScript 对象表示法——是一种用于在文本中移动数据的数据格式,从一个地方(通常是计算机)移动到另一个地方(再次,通常是计算机)。现代网络开发者喜欢 JSON,因为它

  • 对人类和机器来说都易于阅读

  • 提供了多种有用的基本数据类型(例如字符串、数字和数组)

  • 侧重于键值对,有助于系统的松散耦合

作为一名 Python 开发者,您可以使用 Python 的内置 JSON 模块将 Python 对象转换为 JSON 数据,反之亦然。我们将使用 json.dumps(导出字符串)函数将 Python dict 转换为可以由我们的 mapper 打印到 stdout 的 JSON 字符串。然后我们将使用 json.loads(加载字符串)函数在我们的 reducer 中读取它。

总的来说,我们的 mapper 脚本如下所示。

列表 8.1. 分析网球分数的 mapper
#! /usr/bin/python3
import json
from sys import stdin

def clean_match(match):
  ms = match.split(',')
  match_data = {'winner': ms[10],
                'loser': ms[20],
                'surface': ms[2]}
  return match_data

if __name__ == "__main__":
  for line in stdin:
    print(json.dumps(clean_match(line)))

我们将每一行输入并使用一个名为 clean_match 的辅助函数进行处理。这是我们将在所有数据上映射的函数。为了处理每一场比赛,我们将其按逗号分割,并选择行中的第 10、20 和第 2 个元素。这些是获胜者、失败者和表面的位置。然后我们使用这三个元素填充一个 dict,并为每个元素分配一个适当的键。最后,我们的 clean_match 函数返回该 dict

如果我们只使用 Python,这已经足够了;然而,为了使用 Hadoop streaming,我们必须将数据作为字符串通过终端传输。因此,我们将数据传递给 json.dumps 函数,该函数将我们的 Python dict 转换为相应的 JSON 格式——在这种情况下,是一个对象。

脚本的其他元素与您已经编写的 Hadoop Streaming 脚本完全相同。我们使用 Python3 的 shebang 来声明脚本应该如何执行,并从 stdin 读取数据。shebang #! /usr/bin/python3 告诉您的机器使用 Python 处理脚本。

8.2.2. 计算网球运动员评分的 reducer

在完成 mapper 后,我们准备处理 reducer。reducer 负责将比赛转换为对球员技能的评估。为此,我们将依赖一个简化的公式,该公式最初是为了评估棋手而开发的:Elo 评分系统。

根据比赛表现评估球员

Elo 评分系统有一个简单的目标:通过比赛结果来更新参与该比赛的球员的评分。为此,该系统会根据球员在直接对抗中击败另一评分球员的频率做出声明。通常,两名球员之间 200 分的评分差距意味着高分球员有 75% 的机会击败低分球员。

从数学上讲,我们将使用简化的 Elo 公式来更新玩家的评分,该公式计算每个玩家获胜的预期概率,然后授予获胜者对手所押注的分数。每个玩家必须押注与他们在比赛中获胜的可能性成比例的分数,因此评分较高的玩家风险更大,但也预期会赢得更频繁。我们还将使用计算 Elo 评分的常见启发式方法,例如,从未见过的玩家开始时设定为 1,400 分。我们可以在图 8.3 中看到 Elo 评分的概念。

图 8.3. Elo 评分方法通过调整玩家在每场比赛后的排名来工作,他们的评分在胜利时上升或在失败时下降。劣势方在胜利时获得的分数要多于他们在失败时失去的分数。

图 8.3 展示了在一个 1600 评分的玩家和一个 1550 评分的玩家之间的比赛中,1550 评分的玩家有更多的收益和更少的损失。这是因为评分系统预期 1550 评分的玩家会经常输给 1600 评分的玩家。当 1600 评分的玩家获胜时,他们仍然会获得分数,但数量会很少。

使用我们在第五章中学到的技术,我们将以 reduce 模式结构化得分累积。我们将引入新的比赛并使用它们的结果来调整每个玩家的评分,这些评分被存储在一个 dict 中,我们在 reduce 步骤中一直保留这个 dict。我们可以在图 8.4 中看到这个过程。

图 8.4. 要计算玩家评分,我们可以对比赛进行 reduce 操作,为胜利者加分,为失败者减分。

在图 8.4 中,我们可以看到玩家得分的累积过程。

  • 我们引入下一场比赛的数据。

  • 我们计算比赛对每个玩家评分的影响。

  • 我们将赢得比赛的选手获得的分数给予他们。

  • 我们从比赛失败者那里扣除他们失去的分数。

  • 如果获胜者或失败者都是新的观察结果,我们将它们从 1,400 分开始。

  • 我们返回 dict 以用于下一场比赛。

这个过程对每一场比赛都会发生,直到我们将所有比赛 reduce 到一个单一的 dict:我们的 N(许多比赛)到 X(单个评分 dict)转换。最后,当我们完成时,我们可以将我们的 dict 打印到屏幕上作为 JSON 对象,这样我们就可以轻松地用于后续分析。这个过程的相关代码出现在列表 8.2 中。

列表 8.2. 通过减少匹配次数来计算玩家评分
#! /usr/bin/python3
import json
from sys import stdin
from functools import reduce

def round5(x):
  return 5*int(x/5)

def elo_acc(acc,nxt):
  match_info = json.loads(nxt)
  w_elo = acc.get(match_info['winner'], 1400)
  l_elo = acc.get(match_info['loser'], 1400)
  Qw = 10**(w_elo/400)
  Ql = 10**(l_elo/400)
  Qt = Qw+Ql
  acc[match['winner']] = round5(w_elo + 100*(1-(Qw/Qt)))
  acc[match['loser']] = round5(l_elo - 100*(Ql/Qt))
  return acc

if __name__ == "__main__":
  xs = reduce(elo_acc, stdin, {})
  for player, rtg in xs.items():
    print(rtg, player)

在列表 8.2 中,我们通过一个我们称之为 elo_acc 的函数来减少匹配。关于 elo_acc 函数的第一个要注意的事情是,我们使用 json.loads 将行读取为 JSON 字符串。因为我们以 JSON 字符串的形式输出代表匹配的 dict,我们可以使用 JSON 字符串读取器来重新构建它们。这给了我们 match_info,一个包含我们想要关于匹配的数据的 dict。此外,因为我们已经为 winnerloser 创建了键,我们可以通过相应的键快速检索值。

从那里,我们可以使用这些信息来计算玩家评分的调整。简而言之,这个过程涉及取每个玩家的评分,除以 400,并将这两个值进行比较,以得出每个玩家在比赛中涉及的分数。我根据个人偏好将这个数字四舍五入到最*的五点间隔。如果你想省略这个步骤或四舍五入到更大的数字,比如 10、25 或甚至 100,都可以。最后,我们将通过解包 reduce 创建的 tuple 来打印玩家及其评分。

最后,我们可以将这些脚本设置为可执行文件,并从命令行运行它们。命令将如下所示。

列表 8.3. 运行我们的评分计算器的 Hadoop streaming 命令
$HADOOP/bin/hadoop jar /home/<user>/bin/hadoop/hadoop-streaming-3.2.0.jar \
  -file ./elo-mapper.py -mapper ./elo-mapper.py \
  -file ./elo-reducer.py -reducer ./elo-reducer.py \
  -input '/path/to/wta/files/wta_matches_200*.csv' \
  -output ./tennis_ratings

运行完成后,应该只需要几秒钟,你应该能够打开 tennis_ratings 目录中包含的结果文件,并看到如下输出:

{
  "Julia Helbet": 1360,
  "Glenny Cepeda": 1400,
  "Hana Sromova": 1075,
  "Sophie Ferguson": 1130,
  "Anne Mall": 1360,
  "Nuria Llagostera Vives": 1120,
  "Maria Vento Kabchi": 1050,
  "Roxana Abdurakhmonova": 1380,
  "Zarina Diyas": 1405,
  "Stephanie Vogt": 1430,
  "Soumia Islami": 1390,
  "Pei Ling Tong": 1380,
  "Shikha Uberoi": 1160,
  "Amani Khalifa": 1410,
...
}

正如我们计划的那样,我们的输出是玩家及其对应 Elo 评分的映射,反映了这些玩家在我们分析期间估计的技能水平。在我们继续之前,这里有一个关于这种分析的警告,我们在本书的几个地方都看到了(包括第二章和第六章)。你会注意到,如果你多次运行这个分析,每次都会得到不同的结果。这是因为比赛进行的顺序会影响每个玩家(及其对手)积累的评分,改变他们在每场比赛中涉及的分数。这是我们第一次在第二章中学*并行处理时看到的问题之一。对于真实的 Elo 评分,我们希望按顺序处理比赛。

8.3. mrjob for Pythonic Hadoop streaming

除了关于顺序的保证之外,与 Hadoop 流式处理一起工作的最引人注目的事情可能是它并不真正感觉像是在编写 Python。当然,我们编写了两个 Python 脚本,但我们仍然需要将数据打印到stdout而不是在代码内部传递。我们必须求助于像json.loadsjson.dumps这样的技巧来以任何方式处理复杂文件格式。我们真正想要的是一种 Python 风格的 Hadoop 工作方式。为此,我们可以转向 mrjob——这是一个专注于云兼容性的 Python 库,用于 Hadoop 流式处理,以实现真正可扩展的分析。

Yelp 最初创建 mrjob 库是为了满足其自身的 Hadoop MapReduce 需求,包括几个高重要性的推荐系统,这些系统为餐饮评论网站提供动力:

  • “查看此内容的人还看了”推荐

  • 评论亮点

  • 文本自动完成

  • 餐厅搜索

  • 广告

公司开发 mrjob 框架是因为该框架允许其工程师使用 Python——一种易于编写、易于调试的语言——通过 Hadoop 处理大量分布式数据。实际上,“大量”是关键词。当公司开发该框架时,Yelp 的数据系统每天处理超过 100 GB 的数据。可扩展性很重要——这就是我们最初想要使用 Hadoop 和分布式计算的原因——但在这里我们将重点放在 Python 上。

8.3.1. mrjob 作业的 Python 结构

mrjob 的主要好处之一是我们能够编写更多的 Python 代码。确实,我们不必编写两个脚本并在命令行中调用它们(而且当我们出错时,会收到基于 Java 的错误),使用 mrjob,我们可以用 Python 编写整个 Hadoop Streaming 作业。mrjob 库消除了直接与 Hadoop 交互的需求。

Yelp 创建 mrjob 是为了分析网络日志,我们将使用它来熟悉 mrjob 语法。例如,让我们考虑找到网站上抛出最多 404 错误的页面的问题。404 错误表示找不到的页面,因此这些错误出现在我们的日志中直接反映了用户的不便。在一个标准的 map 和 reduce 工作流程中,我们会将这个任务分成两个步骤(图 8.5):

  1. 一个map步骤,我们将日志的每一行转换为我们感兴趣的错误

  2. 一个reduce步骤,我们统计错误并找到违规页面

图 8.5。为了找到 404 错误违规者,我们会按照标准的 map 和 reduce 风格来分解任务。

08fig05_alt

如图中所示,我们首先会使用map来摄取我们的日志文件。然后,我们将每个文件转换成一系列错误和违规页面。最后,我们会对这个序列进行reduce操作并统计错误消息。

要使用mrjob实现这一点,我们需要采用稍微不同的方法。mrjob保留了 mapper 和 reducer 步骤,但将它们封装在一个名为mrjob的单个工作类中。mrjob的方法直接对应于我们熟悉的步骤:有一个.mapper方法用于map步骤,一个.reducer方法用于reduce步骤。然而,这两个方法的必需参数与我们所熟知的mapreduce函数略有不同。在mrjob中,所有方法都接受键和值参数作为输入,并以键和值的元组作为输出(图 8.6)。

图 8.6. mrjob版本的mapreduce具有相同的类型签名,接收键和值并输出键和值。

图片

起初,将mapreduce视为键值对的消费者和生产者可能会有些困惑,尤其是因为我们一直在谈论这两种过程可以作用于任何形式的序列,而不仅仅是键值对形式的序列。然而,在底层,这正是 Hadoop 处理mapreduce的方式。

mapreduce的键值方法

在 Hadoop 中,mapreduce被实现为两个方法:.mapper.reducer。每个方法接收一系列键值对,并返回键值对。.mapper方法产生中间键值对。换句话说,它接收数据作为键和值,并将它们输出给.reducer。因为.reducer期望一个键值对,所以这是完美的。实际上,在 Hadoop 的mapreduce步骤之间有一个隐藏的步骤,它会根据键对 Hadoop 消耗的键和值进行排序。这使得.reducer作业变得更加容易。

使用键可以让 Hadoop 在分配工作时充分利用我们的计算资源。map输出具有相同键的中间记录往往会倾向于发送到同一位置进行处理。

对于标准 MapReduce 作业中的.mapper步骤,我们的.mapper的键将是None,值是我们消耗的行。正因为如此,我们关于map.mapper的思考不需要有太大的变化。我们可以通过简单地忽略第一个参数来忽略.mapper的键值期望。

对于.reducer来说,我们则需要关注键值结构。通过mrjob,Hadoop 为我们做了大量的键和值组织工作。我们可以通过将.mapper输出视为一个填充了键和序列的dict而不是一个序列来利用这一点。在我们的错误分析示例中,我们将这些键设置为页面 URL,这样我们就可以快速计算与这些页面相关的 404 错误数量。我们可以在列表 8.4 中看到这一点。

8.3.2. 使用 mrjob 计数错误

列表 8.4 是一个小例子,但由于这是我们第一次在书中看到 mrjob 代码,我们想要仔细地查看它。在第一行,我们正在从 mrjob 库的job模块导入一个名为MRJob的类。这个类包含我们与 Hadoop 交互所需的所有核心 MapReduce 功能。当我们使用 mrjob 库时,我们将主要做的事情是创建从MRJob类继承的新类。

列表 8.4。查找流量日志中 404 错误消息的MRJob脚本
from mrjob.job import MRJob

class ErrorCounter(MRJob):

  def mapper(self, _, line):
    fields = line.split(',')
    if fields[7] == '404.0':
      yield fields[6], 1

  def reducer(self, key, vals):
    num_404s = sum(vals)
    if num_404s >5:
      yield key, num_404s

if __name__ == "__main__":
  ErrorCounter.run()

不出所料,我们接下来要做的事情是创建一个新的名为ErrorCounter的类,它继承自MRJob类。我们还将为这个类定义.mapper.reducer方法。如第 8.3.1 节所述,这两个方法都期望键和值,你可以看到它们都使用了三个参数:self、一个键和一个值。

对于我们的.mapper,我们将忽略这些参数中的第一个(键),正如前一部分所建议的。为此,我们将使用下划线变量,这是我们不使用这个变量的方式。我们将第二个参数命名为line,因为我们的输入值将是日志文件的每一行。从直接使用 Hadoop Streaming 的工作经验来看,这应该感觉非常熟悉。我们接收数据就像它来自stdin一样。

因为我们所得到的数据是以逗号分隔的字符串形式传入的,所以我们将使用 Python 的.split方法将输入行分割成字段。我们将检查 HTTP 响应代码字段,它恰好位于第 7 位,看看它是否是 404 错误,如果是,我们将返回页面名称——它位于第 6 位——和一个 1。我们可以将这个过程可视化,如图图 8.7 所示。

图 8.7。我们的.mapper消费行,将它们分割成字段,检查错误消息字段的值,然后返回页面名称和 1。

图 8.7

mrjob 方法返回值

在本章前面部分,我们讨论了当处理复杂的数据结构时,将数据作为 JSON 传递通常很有帮助,这样我们可以快速轻松地从字符串中重新构建它。mrjob 库的作者认为这是一个非常好的主意,因此他们要求每个.mapper.reducer输出都必须是 JSON 可序列化的。这意味着当你可以使用时,最好使用简单的 Python 数据结构,例如floatintstringlistdict。这些数据结构在基本的 Python 中是可序列化的。尽管如此,你可以通过实现自己的方法将任何 Python 数据结构转换为 JSON。

我们的.mapper将数据作为键(页面名称)和值(这些页面的指示计数)发送出去,我们现在可以继续到我们的.reducer。对于我们的.reducer,我们将汇总.mapper报告的 404 错误数量,并返回这个值以及原始键。我限制这个操作只针对有超过五个 404 错误的页面,因为我个人只对高频违规者感兴趣,但如果你愿意,可以省略这一步。

这个.reducer是我们真正看到关键值期望如何改变我们对 map 和 reduce 步骤思考的第一个地方。我们仍然可以思考reduce通过一个序列移动,但这次我们是通过一个键值对的序列移动。每个键的值是一个序列。在这个特定的情况下,我们的键将是一个页面名称,如index.html,我们的值将是一系列 404 错误指示器,例如[1, 1, 1, 1]。图 8.8 展示了这看起来是什么样子。

图 8.8. 我们的.mapper生成键值对,然后.reducer遍历这些键值对,对键及其相关值进行操作。

图片

当我们思考num_404s = sum(vals)这一行时,这一行之所以有效,是因为 Hadoop 已经将我们的数据排序成一种格式,其中键是页面,而vals变量包含所有指示器(1)的序列。然后,将这些 1 相加,就得到了 404 错误的计数。然后我们可以返回这个值以及键,以得到与每个页面相关的错误计数。

最后,在列表 8.4 中,我们看到了脚本末尾的 Pythonic main调用。要运行我们的 mrjob MapReduce 作业,我们将从命令行调用此脚本,并将我们的输入数据作为附加参数。我们需要从第七章安装 Hadoop,以便运行 mrjob 操作:

python3 common_errors.py traffic-logs.txt

之后,我们应该会在屏幕上看到页面及其对应的错误计数:

Using configs in /etc/mrjob.conf
No configs specified for inline runner
Creating temp directory /tmp/common-errors.jt-w.20191108.012559.032175
Running step 1 of 1...
job output is in /tmp/common-errors.jt-w.20191108.012559.032175/output
Streaming final output from /tmp/common-errors.jt-
     w.20191108.012559.032175/output...
".hdr.sgml"    2
".txt"    191
"form448073_20120210093319-.xml"    1
Removing temp directory /tmp/common-errors.jt-w.20191108.012559.032175...

从这个输出中,我们可以看到我们网站上大部分的错误都来自指向名为“ .txt”的文件的链接。

8.4. 使用 mrjob 进行网球比赛分析

在看到了一个小型的 mrjob MapReduce 工作流程之后,让我们回到我们的网球比赛数据,深入探讨两个更多例子,每个例子都围绕历史上最伟大的网球运动员之一:塞雷娜·威廉姆斯。

8.4.1. 按球场类型计算塞雷娜的统治地位

在这个场景中,我们将分析塞雷娜·威廉姆斯的历史统治地位,并学*以 mrjob 期望的关键值风格进行思考。

场景

网球最有趣的一点是,它是有少数几个比赛场地会改变的体育项目之一。成功的职业选手学会在由草地、红土和混凝土制成的球场上打球。无论球场类型如何,塞雷娜·威廉姆斯一直是历史上最令人印象深刻的网球运动员之一。一位体育记者要求我们分析比赛日志,并计算她在每种球场类型上的胜负。

如果我们用我们旧的 map 和 reduce 思维方式来考虑这个过程,我们可以想象将每个文件映射到包含我们感兴趣信息的dict中,然后对那些信息进行 reduce 以获取计数(图 8.9)。但是,要使用 mrjob,我们想要考虑键和值。我们希望最终输出中的键是什么数据,值中应该有什么数据?

图 8.9. 一个传统的 map 和 reduce 解决方案会将信息映射到dict中,以便我们统计塞雷娜的胜利次数。

![08fig09_alt.jpg]

好吧,我们知道我们希望数据按场地组织,所以这作为一个键是有意义的。作为值,我们希望得到塞雷娜的记录:她的胜利次数和失败次数。我们可以在.reducer中使用我们的frequencies函数来累积这些计数——这是我们之前在第五章介绍reduce时编写的同一个函数——如果我们有一个胜负列表。我们希望从.mapper中输出的将是场地和W(胜利)或L(失败)。考虑一下这与我们的传统 map 和 reduce 风格方法图 8.9 的比较。

为了实现这种安排,我们将创建一个新的类,它从MRJob继承,称为SerenaCounter,它有一个.mapper方法,该方法返回场地和W或场地和L。这个类还需要有一个.reducer方法,该方法获取每个场地的胜负频率。为此,我们将从第五章中带回我们的frequencies代码。我们可以在以下列表中看到这个过程的样子。

列表 8.5. 使用MRJob按场地统计塞雷娜·威廉姆斯的胜负次数
from mrjob.job import MRJob
from functools import reduce

def make_counts(acc, nxt):
    acc[nxt] = acc.get(nxt,0) + 1
    return acc

def my_frequencies(xs):
    return reduce(make_counts, xs, {})

class SerenaCounter(MRJob):

  def mapper(self, _, line):
    fields = line.split(',')
    if fields[10] == 'Serena Williams':
        yield fields[2], 'W'
    elif fields[20] == 'Serena Williams':
        yield fields[2], 'L'

  def reducer(self, surface, results):
    counts = my_frequencies(results)
    yield surface, counts

if __name__ == "__main__":
  SerenaCounter.run()

我们的.mapper方法处理我们的比赛日志中的行,并检查胜者和败者字段中是否有塞雷娜的名字。如果我们发现她在胜者字段中,我们将输出场地类型和W。如果我们发现她在败者字段中,我们将输出场地类型和L。如果我们在这两个字段中都没有找到她,我们不会输出任何内容。

.reducer方法通过键接收该信息,我们通过surface参数接收,以及值,我们通过results参数接收。因为我们在.mapper中第一个位置传递了场地类型,所以这个值将被读取为键。结果将作为序列对每个场地类型都是可访问的,例如['W', 'L', 'W', 'W', ...]。我们可以使用我们的frequencies函数来获取每个唯一元素的计数字典。我们将在.reducer的末尾输出场地和计数,以查看与每个胜负组合关联的场地类型。

当我们准备好运行脚本时,我们可以从命令行运行它:

python3 serena_counter.py '/path/to/tennis/matches/wta_*.txt'

稍后,我们应该在屏幕上看到类似以下内容打印出来。

"Carpet"  {"W": 15, "L": 3}
"Clay"    {"L": 34, "W": 145}
"Hard"    {"W": 418, "L": 67}
"Grass"   {"W": 84, "L": 10}

MRJob 会遍历所有比赛日志文件中的每条记录,总结出塞雷娜的所有胜利和失败,提供她按球场类型的记录。从这些记录中,我们可以看到她是一位主导草地球员,每输掉一场比赛就能赢得超过八场比赛。相比之下,在红土场上,她表现得最像人类,她的胜利次数接* 75%(在 90 场比赛中赢得了 67 场)。在硬地上,她大多数比赛都在这里进行,她已经赢得了超过 240 场胜利,超过 80%的时间(在 290 场比赛中赢得了 243 场)取得了胜利。

8.4.2. 持久性的姐妹竞争

塞雷娜·威廉姆斯在网球运动中的统治地位并非威廉姆斯家族中唯一。

场景

塞雷娜·威廉姆斯的故事因她与另一位网球巨星的竞争而更加有趣,这位巨星就是她的姐妹:维纳斯·威廉姆斯,奥运金牌得主和五次温布尔登冠军。考虑到关于塞雷娜在所有球场类型上的统治地位的故事不会很有趣——她太出色了,我们明白这一点——同一位体育记者还要求我们评估每位姐妹在每种球场类型上的优势。

我们将像上次一样处理这个场景:从我们想要的结果反向工作到我们需要进行的转换。我们知道我们需要按球场类型组织我们的数据——这就是报告者想要看到的内容——我们还需要统计每位姐妹在这些球场上的胜利次数。听起来我们的球场类型应该是键,而胜者和胜利次数应该是我们的值。

因为我们知道威廉姆斯姐妹会在她们之间进行的所有比赛中都参赛,所以我们只需要计算胜者——输掉比赛的人将是那些没有获胜的人。因此,我们的 .mapper 将会检查是否是姐妹之间的比赛,如果是的话,它将输出比赛表面和胜者。我们的 .reducer 将会根据表面类型统计每位姐妹的胜利次数。这个过程在图 8.10 中得到了说明。

图 8.10. MRJob 工作流程使用键和值来统计威廉姆斯姐妹在不同表面上的胜利次数。

从程序的角度来看,我们不得不创建另一个从 MRJob 类继承的类。我们将这个类称为 WilliamsRivalryWilliamsRivalry 类将需要两个方法:一个 .mapper 和一个 .reducer.mapper 将将行分割成字段,检查维纳斯和塞雷娜·威廉姆斯是否参赛,并输出获胜的姐妹和她们比赛的表面。.reducer 需要统计每位姐妹在不同类型球场上的胜利次数。代码看起来如下所示。

列表 8.6. 使用 MRJob 评估威廉姆斯姐妹的竞争关系
from mrjob.job import MRJob
from functools import reduce

def make_counts(acc, nxt):
    acc[nxt] = acc.get(nxt,0) + 1
    return acc

def my_frequencies(xs):
    return reduce(make_counts, xs, {})

class WilliamsRivalry(MRJob):

  def mapper(self, _, line):
    fields = line.split(',')
    players = [fields[10], fields[20]]
    if 'Serena Williams' in players and 'Venus Williams' in players:
      yield fields[2], fields[10]

  def reducer(self, surface, results):
    counts = my_frequencies(results)
    yield surface, counts

if __name__ == "__main__":
  WilliamsRivalry.run()

列表 8.6 中的大部分代码与列表 8.5 中的代码相似。事实上,唯一的实质性变化是在.mapper方法中。新的.mapper方法将每一行分解成像我们旧的.mapper那样的字段,然后创建一个players变量——一个包含获胜者和失败者名字的字符串。我们使用这个变量来检查威廉姆斯姐妹是否在比赛。如果我们发现players变量中包含两位姐妹的名字,我们就可以输出表面类型,它存储在第 2 个位置,以及获胜者,它存储在第 10 个位置。

然后,因为我们的my_frequencies函数计算传递给它的任何内容,我们可以不改变我们的.reducer就能达到预期的结果。而不是按表面类型计算胜负,计数器将按表面类型计算获胜者。最终,.reducer将输出表面类型和一个包含每个姐妹的名字以及她们在该表面类型上击败对方次数的dict

我们可以从命令行运行此代码,记得传递数据的路径作为参数,我们应该看到如下输出:

"Clay"    {"Serena Williams": 2}
"Grass"   {"Venus Williams": 2, "Serena Williams": 4}
"Hard"    {"Serena Williams": 10, "Venus Williams": 6}

从输出中,我们可以看到兄弟姐妹在草地和硬地上的竞争都很激烈。维纳斯赢得了两场草地比赛,而塞蕾娜赢得了四场(66%)。在硬地比赛中,塞蕾娜在与她妹妹比赛的比赛中以相似的比例击败了她的妹妹(64%)。对于塞蕾娜来说,赢得 64%的比赛表现不佳——记住她在硬地上的比赛中有* 80%是对抗职业巡回赛,在草地上有* 90%。

8.5. 练*

8.5.1. Hadoop 数据格式

MRJob使用哪种数据格式在map步骤和reduce步骤之间共享数据?(选择一个。)

  • 二进制

  • 原始文本

  • JSON

  • Pickle

8.5.2. 更多 Hadoop 数据格式

正确错误:像 Hadoop MapReduce 作业这样的并行进程是确定性的——它们的输出总是以相同的顺序产生。

8.5.3. Hadoop 的本地语言

Hadoop 是用以下哪种语言编写的?(选择一个。)

  • Haskell

  • C++

  • JavaScript

  • Java

8.5.4. 在 MRJob 中设计常见模式

当使用MRJob时,如果我们尝试以MRJob风格进行编码——使用键和值以及mapperreducer,我们将获得更好的性能。实现我们迄今为止看到的常见映射和归约风格模式。

注意

这里提供的代码片段展示了所需函数的功能。您将需要为每个片段实现MRJob类。

  • 过滤(Filter)——取一个序列并返回该序列的子集。

    >>> filter(is_even, [1,2,3,4,5])  # [2,4]
    
  • 频率(Frequencies)——取一个序列并计算该序列中的事物。

    >>> frequencies([1,2,1,1,2])  # {1:3, 2:2}
    
  • 分组(GroupBy)——根据函数的结果值对序列进行分组。

    >>> group_by(is_even, [1,2,3,4,5])  # {True: [2,4], False: [1,3,5]}
    
  • 计数(CountBy)——获取函数结果的键的计数。

    >>> count_by(is_even, [1,2,3,4,5])  # {True: 2, False: 3}
    

摘要

  • JSON 是一种数据格式,我们可以用它来在 Apache Hadoop Streaming MapReduce 作业的mapper步骤和reducer步骤之间传递复杂的数据结构。

  • 我们使用 Python 的 json 库中的 json.dumps()json.loads() 函数来实现这种转换。

  • 我们可以使用 mrjob 库来编写 MapReduce 作业,而无需直接与 Hadoop 交互。

  • mrjob 库迫使我们思考我们的 mapreduce 步骤是接受和输出键值对。

  • Hadoop 在底层使用这些键来分配数据到正确的位置。

  • mrjob 库强制在 mapperreducer 阶段之间进行 JSON 数据交换,因此我们需要确保我们的输出数据是可序列化的 JSON。

  • mrjob 库是为云中的大数据处理而设计的——它对亚马逊网络服务(Amazon Web Services)的弹性映射和减少(Elastic MapReduce)提供了出色的支持,我们将在 第十二章 中介绍。

第九章. 在 PySpark 中使用 map 和 reduce 实现 PageRank

本章涵盖

  • PySpark 中并行 map 和 reduce 程序的选项

  • PySpark 的 RDD 类的便捷方法,用于常见操作

  • 在 PySpark 中实现历史悠久的 PageRank 算法

在 第七章 中,我们了解了 Hadoop 和 Spark,这两个是分布式计算框架。在 第八章 中,我们深入研究了 Hadoop 的细节,仔细查看我们如何使用它来并行化我们的 Python 工作以处理大型数据集。在本章中,我们将熟悉 PySpark——基于 Scala、内存中、用于处理大型数据集的框架。

如 第七章 中所述,Spark 有一些优势:

  • Spark 可以非常快。

  • Spark 程序使用我们在 第二章 到 第六章 中学到的所有相同的 map 和 reduce 技术。

  • 我们可以完全用 Python 编写我们的 Spark 程序,利用 PySpark API 的全面性。

在本章中,我们将探讨如何通过关注其基础类 RDD(弹性分布式数据集)来充分利用 PySpark。我们将探索 RDD 的 map 和 reduce 类似方法,我们可以使用这些方法并行执行熟悉的 map 和 reduce 工作流程。我们将了解一些使我们的生活更轻松的 RDD 类便捷方法。而且,我们将通过实现 PageRank 算法来学*所有这些——这是一个简单但优雅的排名算法,曾经是谷歌搜索的骨干。

9.1. 深入了解 PySpark

在 第七章 中,我们介绍了 Spark 并看到我们可以用它来编写 Python 代码,并将这些代码转换为快速的并行 map 和 reduce 程序。这种从 Python 到 Scala 的转换过程反映在我们的 Python 代码风格中。在本章中,我们将探讨通过 PySpark 的 RDD 类可用的 map 和 reduce 风格实用工具。

RDD 类有方法,我们可以将其分为三个类别:

  1. map-类似的方法—— 可以用来复制 map 功能的方法

  2. reduce-类似的方法—— 可以用来复制 reduce 功能的方法

  3. 便利方法—— 解决常见问题的方法

在这本书中,我们已经看到了属于这些类别中的函数;例如,map 变体(imapstarmap)都属于类似map的方法,而像filterfrequencies这样的函数属于便利方法。PySpark 有自己的工具,提供了类似的便利性以及基于 PySpark RDD的并行化。

9.1.1. PySpark 中的类似 map 的方法

我们将开始更深入地研究 PySpark,首先检查类似map的方法:.map.flatMap.mapValues.flatMapValues.mapPartitions.mapPartitionsWithIndex。你已经熟悉前两个——我们在前面的章节中见过它们。后两个是 Spark 独有的,需要我们更深入地了解 Spark 的工作原理。在本节中,我们将探讨如何使用这些方法来复制我们在前面章节中看到的map行为。

复*

弹性分布式数据集(Resilient Distributed Dataset)对象是 Spark 强大功能的基础。它们是一种抽象,允许程序员使用高级方法(如.map.reduce)在分布式系统内执行内存中的并行操作。因为RDD尽可能多地保留内存中的数据,所以 Spark 可以比 Hadoop 快得多。在 PySpark 中,我们想要利用的大多数并行操作都作为RDD类的方法实现。这个类代表我们正在操作的可恢复分布式数据集。

如我们所期待的,RDD.map方法接受一个函数并将其应用于RDD中的每个元素。例如,如果我们使用SparkContext.textFile方法(我们在第七章中介绍了这个方法)打开一些文本文件,我们将对生成的字符串映射一个函数。我们可以想象一个make_words函数,它将字符串分割成单词,并将我们的文本字符串列表转换成单词列表的列表,每个字符串对应一个单词列表。我们可以在图 9.1 中看到这个过程。

图 9.1. RDD.map方法在RDD上映射,在这种情况下将每个字符串中的单词转换成字符串列表。

就像我们在列表 7.7 中看到的那样,然而,有时我们可能想要一个大的序列而不是序列的序列。为此,我们可以使用RDD .flatMap方法。.flatMapmap等效,但它返回一个扁平化的元素序列。使用与图 9.1 相同的示例,.flatMap将返回一个单词的单长序列,不考虑它们来自哪个字符串。图 9.2 展示了这样一个示例。

图 9.2. RDD.flatMap方法返回一个扁平化的序列,当我们对每个分区的元素整体感兴趣时非常有用。

.mapValues.flatMapValues方法类似于.map.flatMap,但它们只对键值对中的值进行操作。例如,我们可能对我们网站上的网页和访问它们的 IP 地址有数据,我们感兴趣的是每个页面的独立访问者数量。假设我们把这些数据存储为键值对,然后我们可以使用.mapValues来保留键(网页)中的信息,但改变每个值,将 IP 地址列表转换为计数。我们可以在图 9.3 中看到这个例子。

图 9.3。您可以使用RDD.mapValues方法在改变值的同时保留键。

图片

最后,RDD类的.mapPartitions.mapPartitionsWithIndex方法是map的变体,它们是分区感知的——它们知道正在处理的数据位于我们的RDD的哪个分区上。正如我们在第七章中提到的,分区是RDDs 用来实现并行化的抽象。RDD中的数据被分割到不同的分区中,每个分区都在内存中处理。在(非常)大的数据任务中,通常通过键对RDD进行分区。例如,如果我们有一个非常受欢迎的网页——回到我们刚才在图 9.3 中讨论的例子——我们可能会使用RDD.partitionBy方法按页面来分区网站。使用这种分区策略,我们可以在单个分区(换句话说,我们会快速)内对每个页面进行内存操作。.mapPartitions.mapPartitionsWithIndex.map.mapValues在分区上的等价方法。一个例子可以在图 9.4 中看到。

图 9.4。通过逻辑键对大型数据集进行分区优化了我们的计算过程,并使得未来的连接操作更容易。

图片

9.1.2. PySpark 中的类似reduce的方法

当然,虽然我们需要map来进行数据转换,但我们还需要reduce来汇总数据。RDD类有三个我认为类似于reduce的方法:

  • .reduce

  • .fold

  • .aggregate

每种方法都有一个byKey变体:

  • .reduceByKey

  • .foldByKey

  • .aggregateByKey

方法.reduce.fold.aggregate都与您所熟知的 Python 的reduce函数类似,但它们各自有不同的假设级别——其中.reduce假设最多,.aggregate最灵活,而.fold介于两者之间。

RDD .reduce 方法提供了 reduce 功能——接受一个序列并将其累积到其他数据结构中——然而,我们无法为 RDD.reduce 方法提供初始化值或组合函数。这意味着要使用 RDD .reduce 方法,我们期望在整个操作过程中数据类型保持一致——包括我们的最终数据结构。这种操作的一个很好的例子是求和。在求和中,所有我们的元素都将是有数字数据类型,我们的输出值也将是数字数据类型。

略微复杂一些,.fold 方法允许我们除了聚合操作外,还可以提供一个初始化值。这使得 .fold 在我们可能想要有一个保证值,而这个值在我们的序列中不存在的场景下非常适用。例如,如果我们想要找到一个数字序列的最小值,但同时又想确保它至少和 1 一样小,我们可以使用 .fold 方法结合 min 函数和 1 作为初始化值。

.aggregate 方法提供了并行 reduce 的所有功能。我们可以提供一个初始化值、一个聚合函数和一个组合函数。(我们在第六章介绍了组合函数,关于并行 reduce。它们提供了如何将累积函数在并行中累积的工作合并的指令,并且可能不同于复杂工作流中的累积函数。)我们可以使用这个方法来完成.reduce.fold能做的任何事情,以及我们可能想要使用并行 reduce 的任何其他事情。表 9.1 总结了这些方法之间的差异。

表 9.1. RDD.reduce.fold.aggregate方法之间的差异
方法 聚合 初始化 组合
RDD.reduce()
RDD.fold()
RDD.aggregate()

如前所述,我们刚才查看的三个方法——.reduce.fold.aggregate——也都有一个 byKey 变体:.reduceByKey.foldByKey.aggregateByKey。这些方法的工作方式与之前的方法类似,但它们操作的是键值对序列的值,并且每个键只累积一个值。例如,如果我们有一个键值对序列,表示页面和用户在单个访问中在该页面上花费的秒数,我们可以使用 .reduceByKey 方法获取每个页面的总计。这如图 9.5 所示,并在列表 9.1 中展示。

图 9.5. 你可以使用.reduceByKey方法(以及.foldByKey.aggregateByKey)来累积序列中每个键的特定值。

列表 9.1. 使用.reduceByKey计算页面访问时间
>>> page_visits = sc.parallelize([("index.html", 3), ("cart.html", 11),
                                 ("checkout.php", 2), ("index.html", 6),
                                 ("search.html", 2), ("cart.html", 3)])
>>>> page_vists.reduceByKey(sum)
("index.html", 9)
("cart.html", 14)
. . .

9.1.3. PySpark 中的便利方法

最后,PySpark 为操作 RDD 提供了许多便利方法。其中许多与我们在第四章(kindle_split_013.html#ch04)和第六章(kindle_split_015.html#ch06)中看到的 functools、itertools 和 toolz 库中的便利函数相对应。其他的是 Scala 语言中存在的方法的 Python 版本——Spark 就是用这种语言编写的。你应该注意的方法包括

  • .countByKey()

  • .countByValue()

  • .distinct()

  • .countApproxDistinct()

  • .filter()

  • .first()

  • .groupBy()

  • .groupByKey()

  • .saveAsTextFile()

  • .take()

Spark 的 RDD 的 .filter.first.take 方法

让我们从那些在前面章节中有直接对应的方法开始:.filter.first.takeRDD 类的 .filter 方法的行为类似于 Python 的 filter 函数:它使用一个函数来返回一个新序列,只包含通过过滤函数返回 True 的元素。RDD 类的 .first 方法返回序列中的第一个值。而 .take 方法,就像 toolz 中的 take 方法一样,允许我们检索序列中的前若干个元素。

使用 .countByKey.countByValue 计数 RDD 的元素

接下来,我们介绍 .countByKey.countByValue 方法。这两个方法的行为类似于 frequencies 函数——无论是我们自己构建的还是 toolz 中实现的。我们可以使用这些方法来获取事物的键值序列及其计数。.countByKey 返回 RDD 中键的计数,而 .countByValue 返回值的计数。以下列表展示了这两个方法如何不同。

列表 9.2. PySpark RDD.countByKey.countByValue 方法
>>> xs = sc.paralellize(["Spark", "is", "great"])
>>> xs.map(lambda x:(x, len(x))).countByKey()
[("Spark", 1), ("is", 1), ("great", 1)]

>>> xs.map(lambda x:(x, len(x)).countByValue()
[(5, 2), (2, 1)]

列表 9.2 展示了,如果我们有一个包含单词及其长度作为 tupleRDD,我们可以使用 .countByKey 方法来获取所有唯一单词的计数,以及使用 .countByValue 方法来获取唯一长度的计数。在这种情况下,单词作为键,因为它们位于第一个位置,而长度作为值,因为它们位于第二个位置。

使用 RDD 的 .countApproxDistinct 计数唯一元素

另一个有用的计数方法——尤其是在处理大型数据集时——是 .countApproxDistinct 方法。我们通常想知道数据集中有多少唯一的元素。在一个文档集合中使用了多少唯一的单词?在我们的日志中有多少唯一的 IP 地址?有多少唯一的会话访问了我们的网站?当数据集足够小,可以计算确切数字时,Spark 提供了 .distinct 方法。当我们有大型数据集时,这些计数是时间消耗昂贵的;它们需要遍历通常非常长的序列。.countApproxDistinct 允许在允许一定误差窗口的情况下加速和并行化这个过程,它使用一个可并行化的*似算法,使我们能够从并行化中获得时间节省的好处。

使用.groupBy 和.groupByKey 收集 RDD 的元素

我们还想要了解的另一类便利方法包括两个方法——.groupBy.groupByKey——我们可以使用它们来重构我们的RDD。这两个方法都会收集RDD中所有项目的实例,并返回一个键值tupleRDD。对于.groupByKey,项目使用现有的键值tuple的键进行组织。对于.groupBy,项目在应用于RDD每个元素的一个函数(我们将提供)产生的新键下进行组织。

例如,如果我们有一个单词序列,并希望根据它们的首字母收集它们,我们会传递一个函数到.groupBy,该函数返回字符串的第一个字符。

>>> xs = sc.parallelize(["apple", "banana", "cantaloupe"])
>>> xs.groupBy(getFirstLetter)
[("a",["apple"]), ("b", ["banana"]), ("c", ["cantaloupe"])]

如果我们有一个已经包含键值tuple的序列,我们可以使用.groupByKey,同样地,来获取具有相同键的元素分组。

>>> xs = sc.parallelize([("pet", "dog"), ("pet", "cat"),
                         ("farm", "horse"), "farm", "cow")])
>>> xs.groupByKey()
[("pet", ["dog", "cat"]), ("farm", ["horse", "cow"])]

有些反直觉的是,.groupBy.groupByKey方法的一种特殊实现,所以当你在两者之间做出选择时,最好使用.groupByKey

将 RDD 保存到文本文件

最后,有.saveAsTextFile方法,正如其名称所暗示的那样,它将RDD保存到文本文件。RDD的每个元素将以字符串形式写入文本文件,每个元素之间由换行符分隔。这有几个优点:

  1. 数据是以可读、持久的形式存在的。

  2. 我们可以轻松地将这些数据使用SparkContext.textFile方法读回到 Spark 中。

  3. 数据结构非常适合其他并行工具,例如 Hadoop 的 MapReduce。

  4. 我们可以指定一个压缩格式以实现高效的数据存储或传输。

首先,将数据以持久、可读的格式保存,使我们能够长时间拥有高质量的数据。因为数据是可读的,它可以被手动检查——甚至是非程序员也可以检查——以确保它没有错误。因为数据是纯文本而不是字节码,所以我们有某种保障,即操作系统或我们的运行时环境的变化不会使数据过时。

第二,我们可以使用SparkContext.textFile方法快速读取数据。如果我们有文本数据,我们希望处理字符串,或者我们只是有结构化的数据,这将是极好的。如果我们有复杂的数据,我们可能不想以这种格式存储数据;重新构成它的过程可能会很痛苦。我们在 Spark 中做的绝大部分工作将使用直接的数据结构。

第三,这种格式非常适合 Hadoop 的 MapReduce,它期望一个包含要处理的行的文件。如果你有喜欢的 MapReduce 代码,并且你也在 Spark 中工作,这可以是在两个进程之间共享数据的一种很好的方式。这是一个常见的用例,许多团队都有他们喜欢的遗留 MapReduce 作业,但开始越来越多地融入 Spark 到他们的工作中。

第四点,也是最后一点,我们可以指定文本文件的压缩格式,以便以空间高效的方式保存。为此,有多种编解码器可供选择,包括两种常见的编解码器:bz2 和 gzip。在 bz2 和 gzip 之间,bz2 是较慢、压缩程度更高的格式,而 gzip 是较快、压缩程度较低的格式。指定压缩格式将使数据在解压缩之前对人类不可读。然而,我们不需要在 Spark 或 Hadoop 作业中使用数据之前对其进行解压缩。

要指定压缩格式,我们必须调用格式的完整 Hadoop 编解码器名称。bz2 的完整名称为 org.apache.hadoop.io.compress.BZip2Codec,gzip 的完整名称为 org.apache.hadoop.io.compress.GzipCodec

>>> my_rdd.saveToText("./path/to/file.bz2",
                      "org.apache.hadoop.io.compress.BZip2Codec")

>>> my_rdd.saveToText("./path/to/file.gz",
                      "org.apache.hadoop.io.compress.GzipCodec")

保存以 .bz2 结尾的 bz2 压缩文件和以 .gz 结尾的 gzip 压缩文件是惯例。

9.2. 使用 PySpark 中的 Elo 和 PageRank 进行网球排名

现在我们已经掌握了 Spark 的基础知识,让我们用它来构建一个经典的大数据集算法:PageRank。考虑第八章的场景 chapter 8。

场景

一个新的职业网球联赛正在形成,他们聘请你来为职业网球运动员制定技能评估,以便他们可以针对新联赛招募球员。他们为你提供了多年的比赛数据,并希望你能返回一个包含球员及其对应技能的列表。

9.2.1. 使用 PySpark 重新审视 Elo 评分

我们研究了如何使用 Elo 评分——一种在每次胜负后迭代调整玩家分数的排名系统——结合 Hadoop MapReduce 解决这个问题。我们也可以在 Spark 中实现这个解决方案,利用 Spark 的 reduce 功能。为此,我们需要

  1. 编写 Spark 代码以导入数据

  2. 从 列表 8.2 复制 elo_acc 累加器函数

  3. 使用正确的 Spark reduce-like 方法调用 elo_acc 函数

我们可以在下面的列表中看到这将是什么样子。

列表 9.3. Spark 中的 Elo 评分减少
#! /usr/bin/env python3

import re
from pyspark import SparkContext

def round5(x):
  return 5*int(x/5)

def clean_match(match):
  ms = match.split(",")
  match_data = {"winner": ms[10],
                "loser": ms[20],
                "surface": ms[2]}
  return match_data

def elo_acc(acc,nxt):
    w_elo = acc.get(nxt["winner"],1600)
    l_elo = acc.get(nxt["loser"],1600)
    Qw = 10**(w_elo/400)
    Ql = 10**(l_elo/400)
    Qt = Qw+Ql
    acc[nxt["winner"]] = round5(w_elo + 25*(1-(Qw/Qt)))
    acc[nxt["loser"]] = round5(l_elo - 25*(Ql/Qt))
    return acc

def elo_comb(a,b):
    a.update(b)
    return a

if __name__ == "__main__":
  sc = SparkContext(appName="TennisElos ")
  text_files = sc.textFile("/path/to/my/data/wta_matches*")
  xs = text_files.map(clean_match) \
                 .aggregate({},elo_acc, elo_comb)

  for x in sorted(xs.items(), key=lambda x:x[1], reverse=True)[:20]:
      print("{:<30}{}".format(*x))

列表中的大部分代码来自 第八章,需要进一步解释的很少。我们在 第 8.2.2 节中回顾了 clean_matchelo_acc 函数。这些是 列表 9.3 中的代码(为 PySpark 编写)与 列表 8.2 中的代码之间的两个主要区别:

  1. 这段代码无需使用 MapReduce 必须关注的 stdin/stdout 和 JSON。

  2. 我们使用 Spark 方法读取数据,清理数据,并对数据进行聚合。

我们对 PySpark 版本的 Elo 评分代码最满意的事情可能是代码完全是 Python 编写的——无需通过stdoutstdin与终端交互,也无需将数据类型转换为 JSON。通过使用 PySpark,我们可以在任何地方使用 Python 数据类型,甚至不需要导入JSON模块。这在便利性方面是一个很大的优势,尤其是如果我们处理的是可能无法整洁地转换为 JSON 的更复杂的数据结构时。

同样重要的是,我们可以使用 Spark 方法来处理所有我们的数据处理:

  1. 我们首先调用的方法是.textFile,它用于引入文本数据。

  2. .textFile方法返回一个RDD,我们可以使用它来通过clean_matches函数清理数据,所以我们接下来就做这件事。

  3. 然后,最后,我们使用.aggregate方法和我们的elo_acc函数以及一个新的elo_comb函数来评分运动员。

在这个例子中,我们使用.aggregate方法,因为它是最简单的符合我们需求的reduce类似方法。我们需要一个空的dict来开始,所以不能使用.reduce方法,因为那个方法没有初始化器的空间。我们还需要一个不同于aggregate函数的合并函数,所以不能使用.fold——.fold没有合并函数的空间。唯一剩下的reduce类似方法就是.aggregate,它给我们提供了指定我们并行reduce所有三个部分的机会。

这涵盖了代码的实质性更改;然而,你可能已经注意到了列表 9.3 中的一个细微的变化。这是微妙的,但在我们的 map 和 reduce 工作流程中,我们在中间插入了一个反斜杠字符并移到下一行。这是一个 PySpark 约定,也是 Scala 编程语言的一个方面,它被用于 Python。在 Scala 中,你可以链式调用方法,默认情况下每个方法都在新的一行。如果我们这样做在 Python 中,Python 会抛出一个错误。这是因为 Python 以其对空白敏感而闻名。我们可以通过在方法调用后添加一个反斜杠来绕过这个错误,如下面的列表所示。反斜杠字符是 Python 的手动换行符。

列表 9.4. Scala 中的方法链,以及 Python 中的两种方式
# Example Scala code
my_dataset.map(foo)
          .reduce(bar)

# Example Python code (no wrap)
my_dataset.map(foo).reduce(bar)

# Example Python code (with wrap)
my_dataset.map(foo)\
          .reduce(bar)

如果你与其他 PySpark 开发者一起工作,他们很可能已经知道这个约定。然而,对于传统的 Python 开发者来说,这个约定可能看起来很奇怪,甚至是不正确的。

然而,最终我们运行脚本并接收网球运动员的 Elo 评分,看起来像图 9.6。在这里,我们可以看到按顺序排列的运动员及其排名。

图 9.6. 当我们使用 PySpark 计算网球运动员的 Elo 评分时,我们的输出将是一系列运动员及其评分——评分越高,运动员越好。

图片

9.2.2. 介绍 PageRank 算法

现在,我们可以使用 MapReduce 和 Spark 根据他们的 Elo 评级对运动员进行排名,但如果我们不想使用 Elo 评级怎么办?如果我们想要一个不惩罚输掉比赛的运动员,但只奖励击败对手的运动员的系统怎么办?如果我们想要奖励击败高质量对手的运动员,以鼓励排名靠前的运动员之间进行竞争性比赛怎么办?我们可以使用 PageRank 算法的变体来设计这样的系统。

PageRank 因其曾是谷歌排名系统的基础而闻名。得分更高的网站会在谷歌搜索结果中显示得更高,而得分较低的网站可能根本不会显示。随着时间的推移,这个过程已经发生了变化,但该算法简单而强大的假设导致了其持久性,并且它仍在谷歌搜索之外(包括与体育分析相关的各种能力)使用。

PageRank 和谷歌搜索

PageRank 算法是当时斯坦福大学博士生拉里·佩奇(他用自己的名字命名了这个算法)和谢尔盖·布林进行的研究项目的成果。两人将继续使用该算法作为谷歌搜索引擎的基础。历史学家认为,谷歌的成功归功于算法的易于分发,这使得谷歌能够扩展,以及算法与人类对重要性的评估自然对齐。

随着时间的推移,谷歌的搜索引擎变得更加复杂。现在的搜索算法使用数百个特征。谷歌仍然使用 PageRank 版本来评估网站的可靠性和权威性。谷歌知识图谱和谷歌对移动友好型和社会友好型内容的偏好是当代谷歌搜索中最明显的力量。

PageRank 的基本前提是将网站排名视为一次选举,有一些独特的规则。一般规则如下:

  1. 每一页都有一个分数:它的 PageRank 得分。

  2. 每个页面都会对其链接的页面进行投票,将等于链接页面 PageRank 得分的分数分配给每个页面。

  3. 然后,每个页面都会根据它收到的所有投票的总和获得一个新的 PageRank 得分。

  4. 该过程会重复进行,直到分数“足够好”为止。

当然,网球运动员不会链接到其他网球运动员。然而,我们可以使用运动员的输球作为对表现更好的运动员的投票。例如,如果维纳斯·威廉姆斯在网球比赛中击败了她的妹妹塞雷娜,那么塞雷娜会用她的一些分数为维纳斯投票。网球排名的 PageRank 算法的小规模示例显示在图 9.7 中。

图 9.7。我们可以将 PageRank 算法应用于网球运动员,其中每位运动员都会给比他们表现更好的运动员贡献分数。

在图中,我们看到五位网球运动员,每人有 100 分可以分配。

  • 玩家 1 输给了玩家 2、3 和 5(总共 3 人)。

  • 玩家 2 输给了玩家 1 和 4(总共 2 场)。

  • 玩家 3 输给了玩家 1 和 2(总共 2 场)。

  • 玩家 4 输给了玩家 2 和 3(总共 2 场)。

  • 玩家 5 输给了所有人(总共 4 场)。

由于有三个球员有两场败绩,很难立即判断谁是最佳球员,但 PageRank 将帮助我们解决这个问题。玩家 1 将他们 100 分的 1/3(即 33 分)分配给玩家 2、3 和 5。玩家 2、3 和 4 各自用 50 分投票给输掉的球员。而玩家 5 用 25 分投票给其他所有球员。

接下来,我们将把所有投票加起来。玩家 2 最终获得 158 分(50+50+25+33),其次是玩家 1,获得 125 分(50+50+25),然后是玩家 3,获得 108 分(50+33+25),玩家 4 获得 75 分(50+25),玩家 5 获得 33 分,这是他们唯一一次战胜玩家 1。在一个更稳健的例子中,我们将会重复这个过程几次,这样战胜高排名的玩家就会更有价值。例如,玩家 1 和 4 应该因为战胜最佳玩家玩家 2 而获得很多分数,而战胜输给所有人的玩家 5,应该对排名的影响较小。经过三次迭代,球员的排名如下:

  1. 玩家 2——145 分

  2. 玩家 1——125 分

  3. 玩家 3——101 分

  4. 玩家 4——81 分

  5. 玩家 5——47 分

PageRank 算法最大的优点之一,我们将在下一节中看到,是它自然地可并行化。我们可以在最严格的并行化假设下并行地进行所有点数分配和点数求和。这也是它对谷歌工作效果如此之好的原因之一——他们能够并行化他们的问题并将其扩展到他们正在处理的巨大数据集。在下一节中,我们将使用 PySpark 实现一个并行 PageRank 算法。

9.2.3. 使用 PageRank 对网球运动员进行排名

既然我们已经了解了 PageRank 的工作原理,那么我们应该如何使用 PySpark 来实现它呢?嗯,我们知道我们的实现将需要五个步骤:

  1. 读取数据。

  2. 以正确的方式结构化数据。

  3. 进行初始点数分配。

  4. 进行几轮点数分配,直到我们对结果满意为止。

  5. 返回一些评分。

图 9.8 展示了前四个步骤。

图 9.8. 在 PySpark 中使用 PageRank 算法对网球运动员进行评分需要自定义 Python 函数和并行 PySpark 方法。

为了读取数据,我们将使用本书中迄今为止一直在使用的方法:SparkContext.textFiles方法。此方法返回一个RDD,这是 Spark 类,它具有我们构建程序时想要使用的所有优秀的并行映射和归约选项。

接下来,我们将转向数据结构化。为此,我们将使用 RDD.map 方法检索每场比赛的赢家和输家,并使用 RDD.groupByKey 方法获取每个玩家的失败列表。为了确保 .groupByKey 做我们想要的事情,我们将赢家和输家以 tuple 的形式返回:(<输家>, <赢家>)。从那里,我们将使用另一个 .map 语句添加一些在计算 PageRank 分数时将很有帮助的元数据。

在数据格式正确后,我们将多次遍历我们的数据。每次,我们将根据谁打败了谁来计算每个玩家的 PageRank 分数,通过遍历输家并给打败他们的每个玩家分配他们分数的一部分。在每一轮中,我们将使用玩家的最新分数。

最后,经过几轮之后,我们可以对玩家进行排序,返回他们的分数,然后结束。总的来说,我们为这个问题制定的解决方案将是一个非常庞大的程序。您可以在本书的代码仓库中找到完整的脚本(github.com/jtwool/mastering-large-datasets)。在这里,我认为值得我们关注三个主要领域:

  1. 使用 .groupByKey.mapValues 的数据准备过程

  2. 分配分数聚合函数和 combine_scores 组合函数

  3. 迭代分数计算和分配分数函数的局部应用

使用 .groupByKey 和 .mapValues 准备网球比赛数据

第一部分,数据准备,围绕以下代码展开:

xs = match_data.map(get_winner_loser)\
                 .groupByKey()\
                 .mapValues(initialize_for_voting)

在本节中,我们已将数据读入一个名为 match_data 的变量中,因此我们正在处理一个字符串的 RDD。我们知道我们想要的是一个键(玩家名称)的 RDD,其值是 dict。这些 dict 必须包含我们稍后计算 PageRank 分数所需的信息。为此,它们需要包含玩家输掉的对手、这些对手的数量以及玩家的当前页面排名分数。

从比赛字符串到这个值的过程将是一个三步过程:

  1. 我们将比赛数据映射成输家和赢家的元组。

  2. 我们将比赛按输家分组。

  3. 我们将映射一个转换到键和值上,以准备我们的数据用于 PageRank。

整个过程看起来就像 图 9.9 所示。

图 9.9。我们使用 .map.groupByKey.mapValues 在 PySpark 中为 PageRank 准备网球比赛数据。

如图 9.9 所示,我们的第一个映射步骤涉及从匹配数据中提取一个子集并将其排列成tuple。这将返回一个tupleRDD,我们可以使用.groupByKey来返回一个键值对的RDD。这些实例中的键代表输掉比赛的玩家,而值是输掉比赛的玩家输掉的一系列玩家。最后,我们可以使用initialize_for_voting函数添加元数据并将列表转换为dict,以便在未来的工作中有更清晰的流程。

分配分数和组合分数

我们在处理过程中需要特别注意的下一部分是聚合和组合函数。这些是我们调用reduce步骤时的函数,构成了我们程序的重头戏。这些函数是我们实现 PageRank 的方式,我们可以在图 9.10 中看到它们。

图 9.10。我们可以将 PageRank 的排名步骤并行化为两步并行减少工作流程。

图片

我们的聚合函数allocate_points负责接收一个新玩家、他们的输掉的比赛和相关的元数据,并将分数分配给击败他们的玩家。然后,这些分数存储在一个dict中,以玩家的名字作为键,玩家的 PageRank 分数作为值。我们可以在图 9.11 中看到这个过程。

图 9.11。allocate_points函数接收玩家信息并更新累积变量以反映玩家的更新分数。

图片

查看allocate_points函数的代码,我们可以精确地看到它是如何工作的。我们将玩家分为键和值,因为我们之前小节中的.mapValues步骤将玩家数据存储为两个tuple

def allocate_points(acc, nxt):
  k,v = nxt
  boost = v['rating'] / (v['n_losses'] + .01)
  for loss in v['losses']:
    if loss not in acc.keys():
      acc[loss] = {'losses':[], 'n_losses': 0}
    opp_rating = acc.get(loss,{}).get('rating',0)
    acc[loss]['rating'] = opp_rating + boost
  return acc

接下来,我们计算击败当前玩家的每个玩家将获得的提升。每个玩家将他们的整个评分均匀地分配给所有击败他们的玩家。这意味着一个玩家通过击败我们的当前玩家获得的提升量等于该玩家的评分除以他们的输掉的比赛数。为了防止除以零的错误,我在玩家输掉的比赛数中添加了一个小值,以防他们没有输过比赛。

然后,我们将这些分数分配给击败当前玩家的每个玩家——更新累积变量。我们通过将对手玩家的评分设置为他们的当前评分加上提升因子来实现这一点。之后,我们返回累积变量并继续下一个玩家。

这处理了我们的并行reduce的累积步骤。然而,正如我们在第六章中所知,并行reduce有两个部分:并行累积和组合。在我们的组合步骤中,我们必须将我们在并行中累积的所有值连接起来。通常,这是并行reduce的难点,因为我们将构建复杂的数据结构——这里没有这样的问题。

在我们的 reduce 步骤之后,我们希望将 dicts 通过键作为字符串和整数值连接起来,这样生成的 dict 将包含两个 dict 的所有键,而值是值的总和。我们可以在图 9.12 中看到这个过程。

图 9.12. 将球员的 PageRank 评分合并需要将 dicts 合并成一个单一的 dict

图 9.12

在 Python 中,我们将通过遍历一个 dict 的所有元素,并尝试将值添加到另一个 dict 中对应键的当前值来实现这个过程。在这个过程中,我们将更新我们未遍历的 dict。如果我们未遍历的 dict 中没有找到来自遍历 dict 的键,我们将更新另一个 dict,使其键等于遍历 dict 的值。最后,我们将返回未遍历的 dict,因为这是我们一直在更新的 dict。以下是这个过程的样子:

def combine_scores(a, b):
  for k,v in b.items():
    if k in a:
      a[k]['rating'] = a[k]['rating'] + b[k]['rating']
    else:
      a[k] = v
  return a

这两个步骤共同代表了一轮 PageRank。PageRank 算法的一个优点是我们可以在并行中完成整个过程。如果我们需要快速对大量信息进行排名,我们可以利用这个事实。通过增加我们的计算能力,我们可以减少排名所需的时间。

迭代计算分数

我们需要特别注意的最后一步是我们迭代计算这些分数的方式。在 PageRank 过程的第一轮中,每个页面——在我们的例子中,是网球运动员——都被平等地评分。我决定从每个人开始时给 100 分,但任何数量的分数都可以。然而,拥有统一数量的分数并不能反映现实。一些网页比其他网页更重要,一些网球运动员比其他运动员更优秀。来自《纽约时报》网页的链接将比来自高中报纸网页的链接带来更多的流量,而击败塞雷娜·威廉姆斯比击败职业老将更有意义。

为了解决这个问题,我们多次运行 PageRank 过程。每次我们都做同样的事情,但我们将使用上一轮的分数来告知我们的评分。这样,击败塞雷娜或来自《纽约时报》的链接在每一轮后续中都会变得更加重要。

为了做到这一点,我们将 reduce 步骤插入到 for 循环中,并用一些代码来设置下一轮的减少:

  for i in range(7):
    if i > 0:
      xs = sc.parallelize(zs.items())
    acc = dict(xs.mapValues(empty_ratings).collect())
    zs = xs.aggregate(acc, allocate_points, combine_scores)

在我们开始 reduce 步骤之前,我们需要设置我们的累积变量:acc。这是包含所有球员及其更新评分的变量。为了获取这个变量,我们将清空来自我们的 dictdict 中所有键的评分。这将给每个球员在每个 PageRank 步骤开始时一个全新的评分为 0。从那里,我们可以进行 reduce

然后,在每个 reduce 步骤之后(除了第一个之外),我们将创建一个新的球员序列来 reduce。这个序列将包含我们初始化的所有元数据,以及我们可以在下一轮 PageRank 迭代中使用的新的评分。

重要的是,我们的reduce过程——我们通过RDD .aggregate方法来调用它——返回一个dict。我们需要一个RDD,这样我们就可以利用 Spark 的并行化。为了得到一个RDD,我们需要使用 SparkContext 中的.parallelize方法显式地将该dict的项转换为RDDsc

一旦我们的迭代完成,我们将有一个以玩家为键、以分数为值的dict。当你运行此脚本时,请记住使用spark-submit实用程序来利用 Spark 的并行化。你也可以使用你的本地 Python 运行时运行它,但它不会充分利用 Spark 的全部功能。我们可以在图 9.13 中看到脚本的输出。

图 9.13. 我们 PageRank 过程的输出显示了顶级玩家及其 PageRank 分数。

图片

注意,除了 PageRank 分数外,我们还包含了玩家 PageRank 分数的日志。对每个玩家的分数取对数可以将分数相似的玩家分组。当 Google 发布 PageRank 工具栏时,他们揭示了 PageRank 分数的对数刻度版本,而不是 PageRank 分数本身。对数刻度分数可能更好地代表了 PageRank 分数,因为 4100 和 3990 之间的差异相当小。

9.3. 练*

9.3.1. sumByKey

在 Spark 中,你可能会遇到的一个常见情况是拥有包含两个元组的键和值的RDD。对这些键和值的一个常见操作是按键求所有值的和。这个操作可以称为sumByKey。使用RDD的正确的reduce-like 方法按键对RDD中的值求和。

>>> xs = sc.parallelize([("A", 1), ("A", 1), ("A", 2),
...                     ("B", 2), ("A", 1),
...                     ("C", 1),
...                     ("D", 7), ("D", -2)])
>>> sumByKey(xs)
[("A", 4), ("B", 3), ("C", 1), ("D", 5)]

9.3.2. sumByKey with toolz

toolz 库有一个reduceBy函数,它接受一个键函数、一个操作和一个序列,以实现与 Spark 的reduceByKey相同的效果。使用 toolz 的reduceBy函数实现sumByKey,以便在非 Spark 工作流程中使用。

9.3.3. Spark 和 toolz

Spark 的其中一个优点是它拥有许多我们从 toolz 库中已经学会并喜爱的便捷方法。在 Scala 中,复制以下使用 toolz 编写的交易。加分项:使用 Spark 风格的链式方法以增加可读性。

>>> import toolz
>>> xs = [("orange", "O"), ("apple", "A"), ("tomato", "T"),
          ("kiwi", "K"), ("lemon", "L")]
>>> toolz.take(toolz.frequencies((filter(lambda x: "a" in x[0], xs)), 10)
[{"O":1}, {"A": 1}, {"T": 1}]

9.3.4. Wikipedia PageRank

PageRank 可以用于排名网球运动员,但它最初是为了在网络上排名网页而设计的。修改本章中我们编写的代码,以对我们在第二章中收集的 Wikipedia 网络页面进行 PageRank。为此练*提供了一个数据集,方便你在本书的代码库中找到(github.com/jtwool/mastering-large-datasets)。

概述

  • RDD类有三个不同的reduce-like 方法:.reduce,用于数据始终相同的情况,.fold,当我们想要指定一个初始化值时使用,.aggregate,当我们想要一个初始化值和自定义组合函数时使用。

  • RDD类的.saveAsTextFile方法是将RDD持久化到磁盘以进行长期存储或与他人共享的绝佳方式——我们甚至可以使用它以压缩格式保存我们的数据!

  • 为了利用 Spark 的并行化,我们需要确保我们的数据在RDD类中。我们可以使用SparkContext类的.parallelize方法将数据转换为RDD

  • Spark 程序通常在方法链中使用反斜杠字符来提高其可读性。

  • 使用 PySpark 中方法的byKey变体通常会导致显著的加速,因为相同的数据是由相同的分布式计算工作器处理的。

第十章. 使用机器学*和 PySpark 进行更快的决策

本章涵盖

  • 机器学*简介

  • 使用 PySpark 并行训练和应用决策树分类器

  • 匹配问题和适当的机器学*算法

  • 使用 PySpark 训练和应用随机森林回归器

第九章展示了我们如何编写 Python 代码并利用 Spark,这是最受欢迎的分布式计算框架之一。我们看到了一些 Spark 的原始数据转换选项,并且我们使用了 Spark 在本书中一直在探索的 map 和 reduce 风格。然而,Spark 之所以如此受欢迎,其中一个原因就是其内置的机器学*功能。

机器学*是指设计、训练、应用和研究那些根据输入数据自行调整的判断算法。机器学*的一个熟悉例子是垃圾邮件过滤器。垃圾邮件过滤器的设计者将垃圾邮件输入到他们的垃圾邮件过滤器算法中,这些算法要么本身就是机器学*算法,要么包含机器学*算法。然后垃圾邮件过滤器算法学会判断一封电子邮件是否为垃圾邮件(图 10.1)。

图 10.1. 垃圾邮件过滤器是机器学*算法,通过查看大量的垃圾邮件和非垃圾邮件来学*如何判断电子邮件是否为垃圾邮件。

在本章中,我们将探讨如何使用 PySpark 进行机器学*。首先,我们将更深入地探讨什么是机器学*。然后,我们将在 PySpark 中构建两个机器学*器:

  1. 一个使用 PySpark 的决策树分类器——一个通过遵循学*到的是/否规则进行判断的分类器

  2. 其中一个是使用随机森林分类器——一个通过多个决策树对结果进行投票的分类器

10.1. 什么是机器学*?

在我们查看本章后面部分实现机器学*算法之前,深入探讨什么是机器学*是有意义的。我提出了机器学*的定义:

定义

机器学*是指设计、训练、应用和研究基于输入数据自我调整的判断算法。

在本节中,我们将更深入地探讨这个定义,并查看一些你可能已经熟悉的机器学*应用。

10.1.1. 将机器学*视为自我调整的判断算法

让我们通过几个例子来更好地理解我们的定义,它包含四个核心组件(图 10.2):

  1. 必须涉及算法。

  2. 那个算法必须做出判断。

  3. 算法必须自我调整。

  4. 这种调整必须基于数据进行。

图 10.2. 机器学*有四个组成部分:算法、判断、自我调整和数据。

这些组成部分中的第一个坚持认为所有机器学*都必须至少涉及一个算法:一系列我们可以用来解决问题的计算。这是好事,因为它意味着我们将想要进行的任何类型的机器学*都可以使用计算机来解决。

其次,我只考虑做出判断的算法是机器学*算法。这意味着描述数据,如求和,或简单转换数据的算法,如加倍算法,不是机器学*。然而,这并不意味着判断必须是重要的、真实的或困难的。愚蠢、错误和简单的判断也计入其中。

更多关于机器学*的 Manning Publications 内容

机器学*是一个复杂且快速发展的主题。虽然我们不需要深入数学证明来理解机器学*的大图景,但我怀疑这本书的许多读者会对更详细的细节感兴趣。Manning 有一些针对机器学*主题的优秀且易于获取的书籍和其他资源。我特别推荐三本。

首先,对于想要了解机器学*概述的人来说,是路易斯·G·塞拉诺(2020)所著的《Grokking Machine Learning》。这本书通过强调概念理解而不是数学证明来教授机器学*。它是一个进入该材料的绝佳起点。第五章涵盖了决策树。

《机器学*实战》,由彼得·哈灵顿(2012)所著,有一整章——第三章——专门介绍 Python 中的决策树。对于想要了解比这里更详细决策树的人来说,这一章是一个很好的起点。这本书的其他部分也很扎实。

《AWS 机器学*实战》,由凯莎·威廉姆斯(Kesha Williams)所著的 Manning LiveVideo,涵盖了在 AWS 上实施机器学*。该课程扩展了本章以及接下来两章关于云计算中引入的概念的重叠。

第三,机器学*算法必须是自我调整的。这就是它们被称为机器学*算法而不是仅仅机器判断算法的原因。算法必须定义规则,以便它们在判断方面变得更好。以第八章和第九章的 Elo 评级示例(图 10.3)为例:我们定义了一些规则,然后算法应用这些规则来判断谁是最佳选手,并判断他们相互击败的可能性。我们没有告诉算法任何关于选手的信息,它自己学*了所有这些。

图 10.3. 我们可以将计算 Elo 评级视为机器学*:评级规则定义了一个学*过程,算法可以使用输出评级来判断未来比赛获胜的可能性。

图片

第四点,算法必须根据数据自行调整。再次以我们从第八章和第九章的 Elo 评级示例来看,比赛数据对于获得选手的评级是必要的。我们并没有根据个人对选手的看法来编码选手评级。这个最后的部分赋予了机器学*算法其神秘性。商业、科学和政府都对算法能找到人类通常忽视的隐藏洞察力感兴趣。如果这些算法的学*方式与人类不同,理论认为,也许它们能够比人类学*得更好。

当我们将机器学*引入大数据集领域时,这种兴趣变得尤为强烈。大数据集的一个显著特点——那些你可以在笔记本电脑上处理但无法存储的数据,以及更大的数据集——是它们在人工处理时几乎无法穿透。人们有多种认知偏差和捷径,使他们不适合评估大数据集。计算机擅长重复简单的行为,精确地执行它们被告知的事情,而不做其他任何事情,因此在评估大数据集方面表现出色。

10.1.2. 机器学*的常见应用

由于机器学*与大数据集紧密相连,许多常见的机器学*应用都是大数据集应用。考虑以下几个例子:

  • 媒体内容推荐—— 根据你过去听过的或看过的内容判断你可能喜欢的新歌曲、视频或剪辑

  • 在线评论摘要—— 判断哪些词语最能概括餐厅、视频或其他产品评论的意义

  • 网站功能测试—— 判断哪些网页功能最能改善用户体验

  • 图像识别—— 为图像添加元数据或识别图像中的对象

  • 医疗诊断—— 判断哪些疾病最有可能导致患者的症状

  • 语音识别—— 判断说话者意图的词语

这些领域中的大多数只有十多年历史。例如,媒体内容推荐在 Netflix 和 YouTube 等平台上最为著名,这两个平台都在其应用中突出显示了推荐功能。这些组织直到 2005 年中期才真正崭露头角,当时谷歌收购了 YouTube,Netflix 推出了其视频流媒体服务。让我们来看看这五个机器学*的应用,并确定每个应用中涉及的四个机器学*组件。

媒体内容推荐

媒体机构使用机器学*根据他们积累的关于观众口味和兴趣的信息向观众推荐新内容。这些机器学*算法的主要目标是推荐媒体消费者愿意继续消费的新内容(通常是为了卖出更多广告)。

这些算法通过分析哪些用户消费了哪些媒体日志来学*判断用户会喜欢什么(图 10.4)。例如,在 YouTube 的情况下,其算法会将其用户观看的视频与网站上所有用户观看的视频记录进行比较。算法会判断与您相似的用户喜欢的、您尚未观看的视频,作为您应该观看的好视频。

图 10.4。媒体内容推荐算法是机器学*的一个例子,其中算法学*根据之前类似用户喜欢的什么内容来判断用户会喜欢什么内容。

图片

在线评论摘要

另一个机器学*与大数据集重叠的领域是,在线零售商,如亚马逊,总结其产品的评论。这些机器学*算法的目标是判断哪些评论相关,以及如何最好地描述这些评论分组。购物者可以使用这些分组来查找特定的产品信息。

亚马逊的开发者编写程序来学*哪些词汇最能描述哪些评论(图 10.5)。这些程序——机器学*算法——接受大量产品评论数据集,并自我调整,直到它们能够准确地对评论进行分组和总结。然后,一旦这些程序学*足够多,开发者就可以将它们集成到产品页面上,供客户互动。

图 10.5。亚马逊使用机器学*在其网站上找到最能概括产品评论的短语。这些摘要帮助购物者了解其他购物者对产品的看法。

图片

网站功能测试

为了不断改进用户体验,许多网站会向其访客展示正在开发中的功能子集。例如,一家公司可能想测试将购买按钮设置为黄色、红色或绿色是否会导致最多的购买。开发者可以编写程序,从用户在网站上的行为中学*用户的喜好功能。

与媒体内容推荐程序一样,这些程序也从用户日志数据中学*。然而,与将用户分组不同,这些程序学会了判断哪些特征使用户更有可能参与网站设计师重视的行为,例如在网站上花费更多时间,将更多项目添加到购物车中,或从网站上购买更多产品。

图像识别

在机器学*领域,图像识别是一个正在迅速取得进展的领域。这个子领域的目标是仅基于视觉线索来识别图像中的对象,或者生成关于图像的元数据(例如它在哪里被拍摄),例如,Facebook 因其对所有上传到其网站的图片使用图像识别而闻名。例如,当我将我的作者照片上传到 Facebook 时,它提供了以下标签:单人照片,微笑,有胡须,特写(图 10.6)。

图 10.6. 这些照片展示了两种图像识别的例子:图像元数据标记(顶部)和对象检测(底部)。

我们可以在图 10.6 中看到另一个目标检测的例子。这种图像识别形式试图围绕算法识别的项目放置方框。在这种情况下,我们的算法识别了图片中的三艘船。亚马逊正在使用这项技术——以及其他技术——试图创建无需结账的商店,其中机器学*技术可以识别你放在包里的物品。

医疗诊断

另一个机器学*被使用的领域是在医疗诊断的领域。在那里,程序员、医生和科学家正在合作,以改善我们根据症状、病史和检测结果判断某人患有什么疾病的方法。例如,机器学*允许放射科医生处理比以前更低的图像质量,因为机器学*算法可以学会比人类更好地判断不清晰或模糊的图像。

这些算法从大量的电子健康信息数据集中学*,以判断健康结果,这与医生在医学院学*诊断学时的情况类似。然而,与由经验丰富的医生教授的医疗学生不同,这些算法是自我学*的。有时,它们会学*到与训练有素的专家预期的完全不同的模式。

语音识别

最后一个机器学*例子是语音识别。在语音识别中,程序员试图编写代码,可以从人的声音中接收声音并判断说话人意图的词语(图 10.7)。你可能熟悉这项技术,比如在你的智能手机上的语音转文字功能,或者 Amazon Alexa、Google Home 或 Facebook Portal 设备上。

图 10.7. 语音识别机器学*试图通过分析说话人产生的声波来判断说话人意图的词语。

程序员编写这些程序是为了通过处理包含对应转录的大数据集的音频文件来学*哪些声音表示哪些单词。与人们理解彼此声音的相对容易程度相比,这些程序往往表现不佳,这突显了算法学*和人类学*之间的差异。即使最好的语音识别算法也没有像大多数小学生那样学会如何理解单词。

现在我们已经讨论了五个(加上两个)机器学*示例以及如何将它们视为自我学*的判断算法,我们准备尝试一些机器学*。在下一节中,我们将探讨使用 PySpark 的决策树分类器,这是一种从数据中学*是/否规则以判断不同结果的机器学*算法。

10.2. 机器学*基础与决策树分类器

为了介绍机器学*,我们将研究决策树分类器。当我们想要可解释的结果时,决策树分类器是机器学*算法的一个很好的选择,因为是/否规则直观简单。即使数学上不感兴趣的人通常也能沿着决策树追踪,看看算法是如何得出判断的。正因为如此,它们是解决我们在本章中将要处理场景的一个很好的方法。

场景

一群徒步旅行者厌倦了在途中携带零食。他们收集了大量关于蘑菇的数据——例如蘑菇的大小、颜色和帽子形状——他们希望您使用这些数据并想出一个判断蘑菇是否安全的方法。设计一个机器学*算法,可以为徒步旅行者提供选择哪些蘑菇可食用和哪些蘑菇有毒的规则。

| |

警告

本节中关于蘑菇的信息仅用于学*目的,不应用于识别蘑菇。食用野生蘑菇可能具有严重甚至致命的后果。

在这种设置下,我们知道我们处于使用机器学*的良好状态。我们有一个判断问题——判断哪些蘑菇是有毒的,哪些是安全的可以食用——并且我们有历史数据,我们可以从中学*。这样就解决了两个标准。剩下的两个可以通过编写一些代码来学*从数据中做出判断来满足。这部分取决于我们。

10.2.1. 设计决策树分类器

在我们编写决策树分类器代码之前,让我们先看看决策树分类器是如何工作的。表 10.1 展示了蘑菇数据的一个子集可能的样子。

表 10.1. 小型决策树分类器的蘑菇数据子集
是否可食用? 帽子颜色 气味 栖息地
有毒 棕色 杏仁 草地
有毒 红色 辛辣 草地
有毒 紫色 发霉 森林
可食用 棕色 发霉 草地
可食用 灰色 霉味 森林

我们将要编写的决策树分类器通过学*一系列规则来判断新蘑菇。例如,对于表 10.1 中的数据,我们的决策树分类器可能学*提出三个问题(图 10.8):

  1. 那个蘑菇有霉味吗?

  2. 蘑菇是在森林里找到的吗?

  3. 蘑菇是紫色的吗?

图 10.8. 决策树算法学*构建二元规则,以便它们可以判断新数据。

图 10.8

通过回答这三个问题,我们可以判断数据集中的所有五种蘑菇。我们可以将这些规则表示为一系列的if-else语句或是一个是/否问题的树状图。实际上,决策树算法之所以得名,是因为这些规则可以用类似流程图的树状图来表示。

在本节中的微型示例中,学*这些规则的过程会很快。只有三个变量需要测试,每个变量的选项也有限。正如所提到的,我们的自适应算法需要学*根据仅两条规则进行判断。随着数据的增加,需要进行的计算更多,过程也会更长。

在规则制定过程的每一步,决策树算法都会学*创建规则,以最优地分离组别到最大相似类别。在我们的例子中,算法将在每一步学*最大程度地分离可食用和有毒蘑菇。这就是为什么气味会成为我们的算法首先学*的第一个问题。如果蘑菇有霉味,那么它有三分之二的机会是安全的。我们可以在表 10.1 中看到这一点。实际上,在我们的小数据集中,没有霉味的蘑菇都是可食用的。

与我们选择按颜色分割相比。如果我们首先按颜色分割,我们将几乎没有新的信息。每种蘑菇都是不同的颜色!当然,我们可以逐个检查每种颜色,但我们更倾向于按照使组别更快分离的顺序提问。

我们可以将将数据排序到相似分类项的组别的过程称为最大化同质性。作为决策树的使用者,我们经常会面临选择哪种度量用于此过程的选择。你将听到的两个常见度量被称为基尼不纯度信息增益。我不会详细解释这两个术语——对于 PySpark 机器学*能力的介绍来说,了解它们都是数据分组差异的度量就足够了。

图 10.9 显示了我们的算法如何判断新的观察结果。我们可以看到,首先,它检查气味。气味是霉味,所以我们继续到第二个规则。蘑菇是在森林里找到的,所以我们继续到最后一个问题。确实,这个蘑菇是紫色的,所以我们把它放一边:我们的决策树预计这个蘑菇是有毒的。

图 10.9. 决策树算法通过学*将数据分组到最相似的块中工作。算法将根据数据如果应用于树将最终进入的分组来评估新数据。

图片

既然我们已经了解了决策树算法的工作原理,让我们来看看如何在 PySpark 中使用它们。

10.2.2. 在 PySpark 中实现决策树

PySpark 的机器学*功能存在于一个名为ml的包中。该包本身包含几个不同的模块,分类了一些核心机器学*功能,包括

  • pyspark.ml.feature 用于特征转换和创建

  • pyspark.ml.classification 用于判断数据点所属类别的算法

  • pyspark.ml.tuning 用于改进我们的机器学*算法

  • pyspark.ml.evaluation 用于评估机器学*算法

  • pyspark.ml.util 保存和加载机器学*方法的函数

所有这些模块在风格上都与我们在第七章和第九章中查看的 PySpark 方法相似。然而,PySpark 的所有机器学*功能都期望我们的数据在一个 PySpark DataFrame对象中——而不是我们一直在使用的RDDRDD是 Spark 核心中的抽象并行化数据结构,而DataFrame是建立在RDD之上的一个层,它提供了行和列的概念。如果你还记得,在第七章中介绍 PySpark 时,我提到 PySpark DataFrames 是 Spark 与 SQL 数据库交互的首选数据类型。这是因为 Spark DataFrame为存储在RDD中的数据提供了一个表格接口,就像 SQL 数据库提供表格存储和检索一样。

将数据引入DataFrame将是我们的机器学*过程中的第一步。其他步骤包括运行我们的决策树学*器和评估我们构建的决策树。

将数据引入 DataFrame

我们机器学*过程中的第一步是准备好数据以供分析。这一步骤包括我们可能想要进行的任何预处理——例如改变变量的格式和数据清洗。在这种情况下,我们很幸运:我们的数据是干净进入的。

对于RDDs,Spark 提供了一个简单的方法——.textFile——我们可以用它来读取文本数据并处理它。同样,对于DataFrames,我们有几个方便的选项。如果数据已经在RDD中,我们可以在RDD上调用DataFrame并转换它。如果数据在数据库中,我们可以使用SparkSession.sql方法来返回 SQL 查询结果的DataFrame表示。

在我们的例子中,我们的数据存储在一个平面文件中(你可以在本书的在线仓库中找到:www.manning.com/downloads/1961)。为了处理这种格式,PySpark 有一个名为.csv的方法,它返回一个DataFrameReader。我们可以通过调用SparkSession.read.csv并将文件名传递给它,将 CSV 文件转换为 PySpark 的DataFrame。该方法提供了几乎所有你需要确保你的表格平面文件数据正确输入的选项。我最喜欢的一个是inferSchema,这在下面的列表中有所体现。

列表 10.1. 读取文本数据
from pyspark import sql

spark = sql.SparkSession.builder \
                        .master("local") \
                        .appName("Decision Trees") \
                        .getOrCreate()

df = spark.read.csv("mushrooms.data", header=True, inferSchema=True)

.csv方法的inferSchema选项告诉 Spark 猜测我们数据中变量的类型。如果你还记得,在前两章中,除非数据以 JSON 格式传入,否则我们必须显式地将我们的数据转换为想要的类型。对于小型数据集,这并不是一个挑战,但如果我们有数百个变量,这可能是一个繁琐的过程。在这些情况下,inferSchema可以节省大量时间。

为学*组织数据

现在我们已经将数据放入DataFrame中,我们离将其输入 Spark 机器学*器又*了一步。然而,在我们能够这样做之前,我们必须将数据放入 Spark 坚持的特定类型的DataFrame格式中。

Spark 的机器学*分类器在DataFrame中寻找两个列:

  1. 一个label列,表示数据的正确分类

  2. 一个包含我们将要用于预测标签的特征的features

你的DataFrame可以包含你想要的任意多列,以及你想要的任意名称,但这两个列是 Spark 将用于其机器学*的列。label列是 Spark 机器学*分类器学*来判断的——数据算法看到的是这个标签还是那个标签?features列是机器学*算法将学*用于做出这种判断的每个观察的数据。

此外,Spark 期望这些列具有特定的数据类型。对于我们的数值数据——在 Python 中表示为浮点数和整数的数值数据——Spark 知道如何处理。对于分类数据,我们将有一些选择。处理此类数据的最简单方法是使用 PySpark 的StringIndexerStringIndexer将存储为类别名称(使用字符串)的分类数据转换为数值变量,并将名称索引为数值变量。StringIndexer按频率顺序索引类别——从最常见到最不常见,而不是按观察顺序。最常见的类别将是 0,第二常见的类别是 1,依此类推(图 10.10)。

图 10.10. Spark 的StringIndexer将字符串形式的分类变量转换为数值类别。更常见的类别具有较低的索引。

当我们使用StringIndexer时,Spark 返回一个新的DataFrame,包含我们的旧列和我们的新索引列(图 10.11)。Spark 必须返回一个新的DataFrame,因为 Spark 中的大多数数据结构都是不可变的——一旦创建就无法更改。这是 Spark 所使用的 Scala 编程语言的一个特性。对我们来说,这是一个优点,因为它意味着我们可以编写一个小的reduce语句并更新我们的DataFrame,如下面的列表所示。

图 10.11. PySpark 中的Transformer,例如StringIndexer,返回一个包含原始所有列以及由转换指定的新列的DataFrame

列表 10.2. 使用StringIndexer将字符串转换为索引分类变量
from pyspark.ml.feature import StringIndexer

def string_to_index(label, df):                         *1*
     return StringIndexer(inputCol=label,               *2*
                outputCol="i-"+label).fit(df) \         *3*
                .transform(df)                          *3*

categories = ['cap-shape', 'cap-surface', 'cap-color']  *4*
df = reduce(string_to_index, categories, df)            *5*
  • 1 定义了一个用于我们的 reduce 语句的辅助函数——而不是 acc——接下来我们将使用标签和 df

  • 2 以列标签(输入和输出)作为参数,并将输入列的转换版本附加到 DataFrame 中,带有新的标签

  • 3 .fit 和.transform 方法应用更改并返回一个新的 DataFrame。

  • 4 我们需要一个列的序列来转换——这是我们将在其上 reduce 的内容。

  • 5 最后,我们将调用 reduce 并转换我们的数据框。

列表 10.2 展示了这一过程在实际中的应用。我们首先编写一个辅助函数,该函数将应用StringIndexer到指定的列。辅助函数调用StringIndexer,并传递一个输入标签,该标签由第一个位置的参数指定,以及一个输出标签,在这种情况下,该变量前面加上一个"i-"。我们的转换列将被添加到DataFrame中,因此它们需要具有唯一的名称。DataFrame中的所有列都必须具有唯一的名称。

然后,我们选择一些我们想要转换的类别。在列表 10.2 中,我选择使用 cap-shape、cap-surface 和 cap-color。我希望蘑菇的盖子可以告诉我有关蘑菇是否有毒的信息。然后我们可以调用reduce,传递我们的辅助函数、我们的类别和我们的DataFrame

这个过程产生了一个包含三个额外列的DataFrame

  1. i-cap-shape 对 cap-shape 的索引转换

  2. i-cap-surface 对 cap-surface 的索引转换

  3. i-cap-color 对 cap-color 的索引转换

尽管 Spark 的机器学*分类器只想要一个名为features的列。为了使用这三个列作为特征,我们必须将它们收集到另一个列中。幸运的是,PySpark 也有一个用于此目的的类:VectorAssemblerVectorAssemblerStringIndexer类似,它接受一些输入列名称和一个输出列名称,并具有返回一个新DataFrame的方法,该DataFrame包含原始的所有列以及我们想要添加的新列(图 10.12)。

图 10.12. VectorAssembler是一个可以将多个列收集到一个单独列中的Transformer。这个类特别适用于为机器学*准备特征。

与期望一次处理一个列的StringIndexer不同,VectorAssembler期望汇总大量列。对于我们的转换,我们只需要调用一次VectorAssembler,如下面的列表所示。

列表 10.3. 使用VectorAssembler收集机器学*特征
from pyspark.ml.feature import VectorAssembler
df = VectorAssembler(inputCols=["i-cap-shape",      *1*
                                "i-cap-surface",
                                "i-cap-color"],
         outputCol="features").transform(df)        *2*
  • 1 我们使用要组装的列的名称和所需的输出列名称初始化VectorAssembler

  • 2 在 DataFrame 上调用.transform 会返回一个新的 DataFrame,并增加一个额外的列。

在列表 10.3 中,我们可以看到一个VectorAssembler如何工作的例子。我们可以看到,我们将想要用作特征的三个列作为列表传递给inputCols参数,并将outputCol参数设置为"features"。这告诉VectorAssembler将这三个列收集起来,并创建一个名为features的新列。在这个步骤结束时,我们的DataFrame将包含原始DataFrame的所有列,以及四个新列——一个用于每个我们索引的类别变量,还有一个包含所有这些变量的列。

在这一点上,在我们继续到机器学*之前,我们唯一需要的是标签。我们的标签包含在一个名为edible?的列中,有两个标签——ediblepoisonous——都表示为字符串。同样,我们可以使用StringTransformer。不过,我们不需要遍历一系列列名,我们只需要关注一个列:edible?,如下面的代码所示。

df = StringIndexer(inputCol="edible?",
                   outputCol='label').fit(df) \
                                     .transform(df)

在列表 10.4 中,你可以看到我们在初始化StringIndexer时指定了edible?列,以及名称label,这是 Spark 的机器学*分类器将要寻找的。就像我们转换特征列时一样,我们调用.fit.transform,然后将这个DataFrame重新赋值到我们的原始变量上。

标签名称和数据框

由于 Spark 的DataFrame是不可变的,并且我们通常希望在将label列与 Spark 的机器学*一起使用之前对其进行转换,如果原始列名为"label",我们可能会遇到问题。当这种情况发生时,我们需要在转换时重命名列。Spark 不允许你在DataFrame中覆盖列。然而,我们可以通过指定机器学*函数的labelCol参数来选择一个备选列名,例如DecisionTreeClassifier(labelCol="my-column-name")

这些转换完成后,我们的DataFrame就准备好了,就像 Spark 需要的那样。我们有一个label列,其中包含算法将学*的标签,还有一个features列,其中包含算法将用于判断的特征。最后,我们准备好学*。

运行我们的决策树学*器

在 Spark 中运行机器学*分类器会感觉与转换数据相似。我们将使用 Spark 的 ml.classifier 库中的一个名为 DecisionTreeClassifier 的类,并在我们准备好的 DataFrame 上调用其 .fit 方法。对于幕后进行的数学运算量,你可能会认为这个过程比两行简短的代码要复杂得多:

tree = DecisionTreeClassifier()
model = tree.fit(df)

然而,这两行代码展示了运行决策树分类器在 DataFrame 上的所有必要代码。第一行使用默认参数初始化分类器,第二行将分类器拟合到数据上。分类器的 .fit 方法返回一个模型——这是学*根据我们的数据判断标签的树。在我们的例子中,模型是 DecisionTreeClassificationModel 对象的一种类型。PySpark 中的每个分类器都有一个 .fit 方法,该方法生成相应的模型对象。这些模型描述了学*到的模型,并具有方便的检查功能。

Spark 中的 .fit.transform

你可能已经注意到在本章中有很多 .fit.transform。这是因为构建 Spark 机器学*能力的大部分类都共享这些方法。.fit 是从 Spark 的 Estimator 类继承而来的。此类用于学*有关数据的信息,例如当我们学*如何索引数据集或如何使用决策树对数据进行判断时。.fit 方法返回一个 ModelModelTransformer 继承而来,它提供了一个 .transform 方法。此方法执行我们使用 Estimator 学*到的转换。

例如,DecisionTreeClassificationModel 有一个名为 .toDebugString 的方法,它会显示模型用于做出判断的所有规则。我们可以使用 print(model.toDebugString) 将该字符串打印到屏幕上,以查看这些规则。

在以下代码行中,我们可以看到这些规则被写成 if-else 语句。你会注意到没有任何特征名称。这是因为我们使用 VectorAssembler 组装的 features 列不保留输入的名称。要手动使用此决策树,你必须记住变量放置的顺序。如果我们正在编写脚本而不是在终端交互式工作,我们通常可以在脚本中找到这个顺序。

If (feature 1 in {2.0,3.0})
 If (feature 2 in {0.0,2.0,4.0,6.0,7.0})
  If (feature 2 in {0.0,2.0,7.0})
   If (feature 0 in {0.0,1.0,2.0,4.0})
    Predict: 0.0
   Else (feature 0 not in {0.0,1.0,2.0,4.0})
    Predict: 1.0
  Else (feature 2 not in {0.0,2.0,7.0})
   If (feature 2 in {6.0})
    Predict: 1.0
   Else (feature 2 not in {6.0})
    Predict: 0.0
 Else (feature 2 not in {0.0,2.0,4.0,6.0,7.0})
  If (feature 2 in {3.0})
   Predict: 1.0
  Else (feature 2 not in {3.0})
   Predict: 0.0+

例如,我们可以在以下列表中看到变量的顺序。我们指定的第一个列标签,在这种情况下,i-cap-shape 将是变量 0;第二个,i-cap-surface 将是变量 1;依此类推。

列表 10.4. 使用 VectorAssembler 收集机器学*特征
from pyspark.ml.feature import VectorAssembler
df = VectorAssembler(inputCols=["i-cap-shape",           *1*
                                "i-cap-surface",
                                "i-cap-color"],
                     outputCol="features").transform(df)
  • 1 i-cap-shape 将是特征 0,i-cap-surface 将是特征 1,i-cap-color 将是特征 2
评估决策树的判断

在机器学*算法训练完成后,一个值得问的问题是:算法在实际上做出判断方面有多好?这正是 PySpark 的ml.evaluation模块旨在回答的问题。evaluation模块包含用于计算不同机器学*器不同评估指标的类:

  • BinaryClassificationEvaluator 用于评估具有两种可能结果的案例学*器

  • RegressionEvaluator 用于评估连续值判断

  • MulticlassClassificationEvaluator 用于评估多个标签判断

因为我们的情况中只有两个选项——有毒可食用——我们希望使用BinaryClassificationEvaluator。使用这个Evaluator应该感觉与使用我们的机器学*器或Transformers相似。我们首先初始化Evaluator,然后在其DataFrame的模型版本上调用其.evaluate方法:

bce = BinaryClassificationEvaluator()
bce.evaluate(model.transform(df))
# 0.633318

当我们初始化BinaryClassificationEvaluator时,我们有选择评估指标的机会。接收者操作特征(ROC)曲线下的面积(混淆地用两个缩写词:AUC 和 ROC 表示)是默认选择,也是我推荐在大多数问题中使用的选择(图 10.13)。这个指标是评估假阳性评估和假阴性评估之间权衡的一种方式。

图 10.13。接收者操作特征(ROC)曲线使我们能够在谨慎判断有毒蘑菇的同时,合理地判断一定数量的蘑菇为安全。

曲线代表了做出真正阳性判断和假阳性判断之间的平衡。在我们的案例中,它代表了我们在判断有毒蘑菇为有毒,而不将可食用蘑菇误判为有毒方面的能力。模型是一条曲线,因为当我们更倾向于将蘑菇判断为有毒——以防止人们死亡——我们就更有可能将可食用蘑菇误判为有毒。曲线帮助我们找到一个可接受的点。

使用这两个指标——接收者操作特征曲线下的面积和精确率-召回率曲线下的面积——我们希望尽可能得到一个较大的数值。如果我们有接收者操作特征曲线下的面积为 1,这意味着我们可以正确地将所有有毒蘑菇判断为有毒,而不会将任何可食用蘑菇误判为不可食用。任何小于 1 的数值,都意味着还有改进的空间。接收者操作特征曲线下的面积为 0.63 并不理想,但对于初步评估来说是可以接受的。接下来,我们将探讨一些改进模型的方法。

10.3. PySpark 中的快速随机森林分类

在上一节中,我们构建了一个决策树来判断蘑菇是否有毒。然而,接收者操作特征曲线下的面积表明我们可以做得更好。我们可以尝试做得更好的方法之一是使用随机森林分类器——这是一种与决策树密切相关的人工智能算法。在本节中,我们将探讨随机森林,并在 PySpark 中实现它以获得更好的结果。

10.3.1. 理解随机森林分类器

随机森林分类器通过生长许多不同的决策树,然后对它们进行投票来工作。在学*阶段,它们通过随机选择要使用的特征来生长多样化的树。在判断阶段,每一棵树根据其规则对观测进行分类,并为那些规则产生的分类投票——随机森林根据得票最多的类别来判断观测属于哪个类别(图 10.14)。

图 10.14. 随机森林分类器依赖于生长不同的决策树,每棵树都使用不同随机选择的特征作为种子。然后这些树对新的观测进行投票分类。

以蘑菇数据集的一个简化版本为例,它只包含与蘑菇帽和鳃相关的七个特征:

  1. 帽形状

  2. 帽表面

  3. 帽颜色

  4. 鳃附着

  5. 鳃间距

  6. 鳃大小

  7. 鳃颜色

一个简单的随机森林可能会从这些特征中生长出五个分类器。第一个可能包含帽形状、鳃间距、鳃大小和鳃颜色;第二个可能包含帽表面、鳃附着、鳃颜色和鳃大小;等等(表 10.2)。每一棵树都有它可以使用随机选择的特征。

表 10.2. 一个示例随机森林的五个随机种子决策树
树 1 树 2 树 3 树 4 树 5
帽形状 鳃间距 鳃大小 鳃颜色 帽表面
鳃附着 鳃颜色 鳃大小 帽表面 帽颜色
鳃附着 鳃颜色 帽形状 帽颜色 鳃附着
鳃大小 帽颜色 鳃附着 鳃间距 鳃大小

当我们有一个新的观测需要标记时,我们可以将其传递给随机森林,随机森林将对它中的每一棵树进行投票。例如,树 1、2 和 4 可能判断观测是可食用的,而树 3 和 5 可能说它是有毒的。在这五棵树中,投票结果是 3 比 2 支持可食用。这将最终是随机森林判断新观测所属的类别。

这个过程之所以有效,是因为决策树可用的特征随机化使随机森林对过拟合具有弹性:这是机器学*中算法不成比例地使用一个特征进行判断的问题。随机森林模型改进的弹性、高性能和整体通用性——可用于任何类型的判断问题:二元分类、多类分类和回归——使随机森林模型成为流行的机器学*工具。

10.3.2. 实现随机森林分类器

要构建我们的随机森林分类器,我们将从与我们的决策树相同的方式开始:引入数据并将其排列成标签列和特征列。与之前我们使用决策树时的尝试不同,这次我们不会对哪些特征会有用以及哪些不会有用做出任何假设。这次,我们将选择所有特征,并让随机森林来排序。

为了使用所有特征,我们将使用之前相同的减少策略。不过,这次,我们不是传递一个包含我们想要命名的每个特征的列表,而是从DataFrame的列属性中创建列表,并将标签弹出,如列表 10.5 所示。我们还需要构建一个包含新标签的列表。为此,我喜欢使用一个列表推导,将特征指示符添加到特征名称前面。

列表 10.5. 读取和准备随机森林分类数据
    df = spark.read.csv("mushrooms.data", header=True, inferSchema=True)
    categories = df.columns                                              *1*
    categories.pop(categories.index('edible?'))                          *2*
    df = reduce(string_to_index, categories, df)                         *3*
    indexes = ["i-"+c for c in categories]                               *4*

    df = VectorAssembler(inputCols=indexes,
                         outputCol="features").transform(df)
    df = StringIndexer(inputCol='edible?',
                       outputCol='label').fit(df).transform(df)
  • 1 我们的类别将包括 DataFrame 中的所有列。

  • 2 我们不想要的唯一类别是标签,所以我们会将其弹出。

  • 3 将所有这些字符串转换为索引

  • 4 我们可以使用列表推导来获取索引名称列表——我们需要这些来组装索引。

DataFrame处于良好状态时,我们就准备好开始构建我们的随机森林了。我们将以与本章早期构建决策树相同的方式构建随机森林:

  • 首先,我们将导入RandomForestClassifier类。

  • 然后,我们将使用默认设置实例化该类。

但我们也会做一些不同的事情:

  • 我们将使用参数网格来优化超参数。

  • 我们将使用交叉验证器来确保我们的结果更加稳健。

在决策树示例中,你可能已经注意到我们是在我们从中学到的同一个数据集上评估我们的决策树。这对于*惯于编写 PySpark 机器学*代码来说是不错的,但结果并不可靠。为了更好地评估我们的机器学*器对新观察结果的判断能力,我们应该始终通过在从学*过程中排除的数据上测试我们的模型来进行交叉验证。

有两种交叉验证类型值得了解:

  1. K 折交叉验证

  2. 训练-测试-评估验证

如图 10.15 所示,在 k 折交叉验证中,我们将数据集分成 K 部分,然后我们旋转通过这些部分,考虑一个部分为评估数据,所有其他部分为测试数据。如果 K 和你的数据集都很大,这个过程可能会很耗时,因为你最终会在大型数据集上多次训练机器学*模型。K 的常见值包括 5、10、100 以及你数据集中的观察总数。

图 10.15。K 折交叉验证将数据分成 K 组,然后从所有其他组中学*一个模型来评估选定的组。

图片

在训练-测试-评估验证中,数据集被分成三个部分:一个大的训练部分,一个小型的测试部分,以及一个更小的评估部分。训练部分用于训练模型。测试部分在迭代训练周期中使用,如图 10.16 所示。链接。每当我们对如何改进算法有想法时,我们就进行改进,从训练部分重新学*,并在测试部分进行测试。然后,当我们对现有的模型满意时,我们可以判断评估数据,并使用这些数据来评估我们的模型。这里的技巧是尽可能将评估集从过程中分离出来。如果你能坚持很少判断评估部分,那么训练-测试-评估可能适合你。否则,你可能更适合使用 k 折交叉验证。在训练-测试-评估方法中,通常使用 70%的数据集作为训练数据,20%作为测试数据,10%作为评估数据。

图 10.16。训练-测试-评估验证将数据分成三个部分,其中两个用于迭代学*和测试。剩余的一个很少用于评估模型。

图片

要在 PySpark 中实现交叉验证,我们将使用CrossValidator类,我们可以使用它来进行 k 折交叉验证。CrossValidator需要用以下内容初始化:

  • 估计器—— 我们想要使用的分类器

  • 参数估计器—— 一个ParamGridBuilder对象

  • 评估器—— 我们将使用我们在决策树示例中使用的BinaryClassificationEvaluator。除非有充分的理由不这样做,否则我喜欢进行 10 折验证——我们也在初始化时将这个选择传递给CrossValidator类。

列表 10.6 展示了随机森林分类器的训练过程。你会注意到,我们不是直接使用分类器来拟合数据,而是将RandomForestClassifier传递给CrossValidator对象,并使用CrossValidator.fit方法。然而,从那里开始,评估过程是相似的。我们可以使用BinaryClassificationEvaluator找到操作接收器特性曲线下的面积。最后,我们可以打印出交叉验证尝试中的最佳模型,并查看我们最终得到的规则。

列表 10.6。使用 PySpark 的鲁棒随机森林模型
from pyspark.ml.classification import RandomForestClassifier
forest = RandomForestClassifier()                             *1*
grid = ParamGridBuilder().\                                   *2*
           addGrid(forest.maxDepth, [0, 2]).\                 *2*
           build()                                            *2*
cv = CrossValidator(estimator=forest, estimatorParamMaps=grid,
                    evaluator=bce,numFolds=10,
                    parallelism=4)                            *3*
cv_model = cv.fit(df)
area_under_curve = bce.evaluate(cv_model.transform(df))       *4*
print("Random Forest AUC: {:0.4f}".format(area_under_curve))
#
print(cv_model.bestModel.toDebugString)                       *5*
#
  • 1 创建我们所需的分类器实例

  • 2 在一些参数上创建参数网格搜索

  • 3 初始化交叉验证器以训练多个模型

  • 4 训练模型

  • 5 打印最佳模型

在这些规则中,我们可以看到构成决策森林的不同树。在你的输出中,你会注意到一些树有相同的规则——这肯定是一种判断蘑菇的好方法!

Tree 0 (weight 1.0):
   If (feature 7 in {0.0})
    Predict: 0.0
   Else (feature 7 not in {0.0})
    Predict: 1.0
 Tree 1 (weight 1.0):
   If (feature 4 in {0.0,4.0,5.0,8.0})
    Predict: 0.0
   Else (feature 4 not in {0.0,4.0,5.0,8.0})
    Predict: 1.0
 Tree 2 (weight 1.0):
   If (feature 11 in {0.0,2.0,3.0})
    Predict: 0.0
   Else (feature 11 not in {0.0,2.0,3.0})
    Predict: 1.0
 Tree 3 (weight 1.0):
   If (feature 20 in {1.0,2.0,3.0,4.0,5.0})
    Predict: 0.0
   Else (feature 20 not in {1.0,2.0,3.0,4.0,5.0})
    Predict: 1.0

摘要

  • PySpark 的 SQL 模块具有表格 DataFrame 结构,它提供了类似于表的功能,例如列名,同时基于 RDD 的并行化。

  • PySpark 拥有一个机器学*库,其中包括机器学*管道每个步骤的工具,包括数据摄入、数据准备、机器学*、交叉验证和模型评估。

  • PySpark 中的机器学*器表示为使用 .fit 方法进行学*的类。它们返回一个模型对象,可以使用 .transform 方法对数据进行判断。

  • 我们可以使用 PySpark 的特征创建类——例如 StringIndexerVectorAssembler——来格式化 DataFrames 以进行机器学*。

  • 特征创建类是 Transformer 类对象,它们的方法返回新的 DataFrames,而不是就地转换它们。

第三部分。

第三部分解释了如何将本书中涵盖的工具和技术带入云中。我们将涵盖云计算的基本知识、云中的对象存储以及如何在云中设置自己的计算集群。通过实际示例,我们将在云中运行第二部分中涵盖的分布式计算框架——Hadoop 和 Spark。本部分专注于大型数据类别 3:太大以至于无法在本地存储或处理的数据。一旦你掌握了本章的内容,你将能够处理任何大小的数据。

第十一章. 使用 Amazon Web Services 和 S3 在云中处理大型数据集

本章涵盖

  • 理解云中的分布式对象存储

  • 使用 AWS 网络界面设置存储桶和上传对象

  • 使用 boto3 库将数据上传到 S3 存储桶

在第七章至 10 章中,我们看到了 Hadoop 和 Spark 分布式框架的力量。这些框架可以利用计算机集群并行处理大量数据处理任务并在短时间内完成。然而,我们大多数人没有访问物理计算集群的权限。

相比之下,我们可以从亚马逊、微软和谷歌等云服务提供商那里获取计算集群的访问权限。这些云提供商拥有我们可以用于存储和处理数据的平台,以及一系列自动化我们可能想要执行的任务的服务。在本章中,我们将通过将数据上传到亚马逊的简单存储服务(S3)来迈出分析云中大数据的第一步。首先,我们将回顾 S3 的基本知识;然后我们将使用基于浏览器的 AWS 控制台创建存储桶并上传对象;最后,我们将使用 boto3 软件开发工具包将多个对象上传到存储桶。

11.1. AWS 简单存储服务——大型数据集的解决方案

亚马逊网络服务的简单存储服务,更广为人知的是 S3,是一种数据存储服务,用于存储一些最大的数据集,例如通用电气、NASA、Netflix、英国数据服务、Yelp 以及当然还有亚马逊本身的数据集。S3 是大型数据集的首选服务,以下五个原因:

  1. S3 具有无限大的存储容量。 我们永远不必担心我们的数据集变得过大。

  2. S3 是基于云的。 我们可以根据需要快速扩展或缩减。

  3. S3 提供对象存储。 我们可以专注于使用元数据组织我们的数据,并存储许多不同类型的数据。

  4. S3 是一项托管服务。 亚马逊网络服务为我们处理了很多细节,例如确保数据可用性和持久性。他们还负责安全补丁和软件更新。

  5. S3 支持版本控制和生命周期策略。 我们可以利用它们来更新或归档随着时间推移的数据。

云选项:AWS、Azure 和 Google Cloud

三大主要的云服务提供商——亚马逊(AWS)、微软(Azure)和谷歌(Google Cloud)——都提供了一套标准的核心服务。核心服务包括用于计算的虚拟机和基于对象的存储。在本章中,我将详细介绍亚马逊的 S3 服务,因为 AWS 是最受欢迎的云平台。话虽如此,本章中的原则适用于三大云提供商的所有对象存储系统。实际上,我们可以使用本章和第十二章中关于微软 Azure 和谷歌云的所有内容。

在本节中,我将介绍使用 S3 的五个优点,并在过程中解释 S3 是什么以及它是如何工作的。

11.1.1. 使用 S3 实现无限存储

当数据集变得如此之大,以至于我们开始担心存储的位置和方法时,我们知道我们正在处理一个大型数据集。对于这些情况,S3 始终是一个选择,因为它提供了实际上无限的(但可能昂贵的)存储空间(图 11.1)。事实上,S3 对于大型数据集来说是一个如此好的选择,以至于 AWS 甚至有一个服务旨在帮助组织将本地 PB 级数据集迁移到 S3。

图 11.1. 由于 AWS 数据中心相对于我们数据的大小比例很大,S3 提供了实际上无限的存储空间。

S3 为何能实现无限存储?这是因为拥有大量磁盘空间的大型数据中心。在基于云的存储方面,这并没有什么秘密。AWS 将数据存储在磁盘卷上,就像您本地存储数据一样。对我们来说,它之所以如此吸引人,是因为我们不必购买磁盘空间并自行管理,AWS 愿意将其租给我们。而且当我们需要更多时,AWS 也愿意租给我们更多。

11.1.2. 可扩展性的云存储

由于 S3 是一种基于云的存储服务,我们获得了如果自己存储数据所无法获得的可扩展性优势。在云中,我们永远不需要购买更多的物理存储设备,我们只需要为更多的存储服务付费。而且我们可以在任何时候购买更多服务,也可以在任何时候放弃服务(图 11.2)。AWS 将这称为弹性,其他人则称之为可扩展性

图 11.2. 当我们需要灵活性时,基于云的存储很有用,因为随着我们存储的数据越来越多,存储空间可以按需提供。

考虑以下场景:您正在经营一家小型调查公司,您已经购买了一些存储空间来保存前几位客户的调查数据。这有几个缺点:

  • 您需要找到一种好方法来估算调查所需的存储空间。

  • 您需要一次性支付存储空间费用。

由于 S3 位于云中,我们可以避免这两个问题。使用 S3,我们在存储数据时为使用的存储付费,并且可以确信,当我们准备购买时,总会有的存储空间可用。

现在想象一下,你的第一轮调查进行得非常顺利,一家医院要求你为他们进行一次全国性的大规模调查。你需要准备保存他们的数据——而且要快。你面临一个新的挑战:你需要快速找到并设置一个大型数据存储解决方案。

如果你的数据存储在 S3 中,大型数据存储解决方案可以与你的小型数据存储解决方案相同:所有数据都可以存放在 S3 中。因为这项服务是无限的,并且按需提供,当我们有更多数据需要存储时,我们可以支付更多费用来存储它。

11.1.3. 方便异构存储的对象

S3 的另一个优点是它遵循对象存储模式。对象存储——与传统的文件存储相对——是一种关注数据“是什么”而不是“在哪里”的存储模式。在传统的文件存储中,文件通过其名称和所在的目录来引用。在对象存储中,我们通过一个唯一的标识符来识别对象(图 11.3)。

图 11.3。对象存储将数据与一个唯一的标识符关联,我们可以通过这个标识符来对对象执行文件操作。

图片

由于唯一标识符本身通常不足以帮助人类跟踪他们的数据,对象存储支持任意元数据。这意味着我们可以根据我们的需求灵活地对对象进行标记。我们需要按天标记数据吗?按用户或客户?按产品或营销活动?按潮汐或月亮?我们可以应用任何我们想要的标签。此外——尽管我们不会在本书中介绍它们——S3 上有可用的查询工具,允许对这些元数据标签进行类似 SQL 的查询,以进行元数据分析。

将唯一标识符作为调用所有对象的方法意味着我们可以以相同的方式存储异构数据。比如说我们正在运营一个社交媒体平台,我们的用户正在将图片和视频上传到我们的网站上。我们可以在 S3 中存储这两种文件类型,并使用相同的元数据对它们进行标记,尽管它们是不同类型的。

11.1.4. 方便管理大型数据集的托管服务

如果我们自己在管理大型数据集时遇到的一个问题是数据集的日常维护。如果我们希望我们的数据具有高度可用性,我们必须采取步骤在多个存储环境中复制数据,同时设置故障转移,以便当我们的数据在一个位置不可用时,我们可以在另一个位置快速找到它。对于大型数据集来说,这可不是一件小事。

由于 S3 是一项托管服务,亚马逊网络服务处理我们数据的所有底层实现,并确保高可靠性和可用性。这意味着我们可以在需要时期待我们的数据可用,而无需过多考虑。这将使我们能够腾出时间来做其他事情,比如实际上处理我们现在存储在 S3 中的大量数据集。

11.1.5. 管理大型数据集的生命周期策略

我们将面临的一个大问题是大型数据集——我们已经在本节中提到了这一点——大型数据集是增长的数据集。随着时间的推移,我们的数据集会越来越大。尽管如此,那个大型数据集中的所有数据并不都保持相关性。

假设我们是一家运行基于订阅的在线视频服务。我们希望存储用户观看的所有视频的记录,以便我们可以向他们推荐他们可能喜欢的其他视频。话虽如此,我们可能希望限制我们生成的推荐,以便我们只为当前订阅用户生成推荐,并且只使用过去一年的数据。

处理这个问题的一种方法是对数据进行筛选。我们在整本书中使用了筛选操作——从第四章开始——我们已经看到它们与 Hadoop 和 Spark 框架的实现是自然的。筛选仍然需要我们为数据可用性付费,并为其处理付费。另一种选择是存档我们知道不需要的数据,例如我们不会定期分析的老日志文件。

对于此,S3 有一个生命周期策略功能,我们可以使用它来减少我们不太可能需要的数据的可用性,并更便宜地存储它。一种标准的方法 (图 11.4) 是

  • 从 S3 标准存储开始我们的数据

  • 然后当我们不太需要它时,将其降级到 S3 不经常访问

  • 然后当我们准备存档数据时,将其移动到 S3 Glacier

图 11.4. 我们可以使用生命周期策略来确保我们不太可能想要分析的老数据成本更低,同时仍然保持相同的存储策略。

不同的存储格式都有不同的成本结构。表 11.1 总结了存储类别之间的差异。

表 11.1. 根据您需要访问数据频率的不同,S3 提供了三种主要的存储类别。
S3 存储类别 存储成本 使用成本 可用性
S3 标准存储 非常低 非常高
S3 不经常访问存储 非常低 非常高
S3 Glacier 最低 ^([*])中等

^*

包括在使用前将对象从 S3 Glacier 移动到另一个 S3 格式的成本

S3 标准存储的存储成本最高,但每笔交易的成本最低,当我们有大量数据需要使用时,这非常理想。S3 不经常访问存储的成本低于 S3,但交易成本更高——当我们不太可能访问数据,但希望数据可用时,以这种格式存储数据是成本效益的。S3 Glacier 的存储成本最低,但必须提升到另一种 S3 类型才能使用。完成这一过程所需的时间可以在几分钟到几小时之间调整。

通常,使用 S3 标准版就足够了。我建议只有在您有特定需求时才使用 S3 低频访问和 S3 冰川。例如,如果您知道您每个月只需要分析一次数据,您可以考虑将其存储在 S3 低频访问中。如果您只需要数据用于季度或年度分析,并且可以提前规划,您可能希望使用 S3 冰川以节省成本。

11.2. 使用 S3 在云中存储数据

S3 是我们可以存储大型数据集的地方。在本节中,我们将介绍两种我们可以使用的方法来存储这些数据

  • 基于浏览器的图形界面

  • boto Amazon Web Services/Python 软件开发工具包(SDK)

基于浏览器的界面是上传数据和管理工作元数据的便捷且用户友好的方式。我们可以使用 Python SDK 库 boto,利用 Python 的全部力量,并在我们的脚本和软件中嵌入 S3 操作。

11.2.1. 通过浏览器使用 S3 存储数据

我们将从学*如何通过浏览器在 S3 中存储数据开始。S3 的基于浏览器的界面与我们将稍后查看的程序化 SDK 访问相比,提供了一些优势。特别是,浏览器

  • 提供视觉队列,有助于理解 S3 存储的概念

  • 有向导列出可用的选项

这些优势使得基于浏览器的界面成为熟悉 S3 存储的一个好选择。

将数据加载到 S3 是一个两步过程:

  1. 设置一个存储桶——存储数据的地方。

  2. 上传一个对象——要存储的数据。

我们将按顺序处理这些步骤。首先,我们将设置一个存储桶,并讨论我们在这里可用的选项;然后我们将上传一个对象,并讨论对象级别的选项。

在 S3 中设置存储桶

存储桶是 S3 中的区域,我们可以在这里存储数据。当我们上传数据到 S3 时,我们将数据上传到特定的存储桶。当对象上传后,它只能被有权访问该存储桶的人访问。这使得存储桶成为分离我们的数据和控制对其访问的绝佳方式。

在 AWS 上工作

在本节以及本书的其余部分,练*涉及使用真实的亚马逊网络服务资源。这些服务是亚马逊的业务。为了跟上进度,您需要使用信用卡、借记卡或预付现金卡设置一个 AWS 账户。我在编写此内容时,第十一章和第十二章中的示例所需资源成本不到 5 美元。为了节省成本,确保您在不再需要时关闭所有计算资源。闲置的计算集群会迅速提高使用 AWS 的成本。

例如,假设我们是一家航空公司,我们有一个允许用户查看我们所有飞机在任何给定时间飞往何处的应用程序。我们可能希望将完整的飞行位置日志存储在 S3 中,以便我们可以访问这些数据进行未来的分析。同时,我们希望防止好奇的第三方——可能是我们的竞争对手——下载我们的数据。桶及其隐私控制允许我们限制对这类数据的访问。

要开始设置存储桶,我们需要导航到 AWS 中的 S3 页面。您可以在 aws.amazon.com 找到亚马逊网络服务。从那里,您可以在屏幕左上角点击服务下拉菜单并选择 S3,您可以在存储下找到它。此外,我们还可以在服务搜索中搜索 S3(图 11.5)。

图 11.5. 要导航到 S3 登录页面,我们始终可以使用服务导航下拉菜单,进行搜索或在存储下找到 S3。

图片

这将带我们离开主要的 AWS 登录页面,进入 S3 登录页面。一旦我们设置了一个或多个桶,此页面将列出我们拥有的桶。目前,它提供了一个搜索栏和一个我们可以点击以启动 S3 创建桶向导的按钮(图 11.6)。点击该按钮。

图 11.6. S3 创建桶向导和桶搜索都可在 S3 登录页面找到。

图片

一旦我们进入创建桶向导,它将引导我们通过设置 S3 桶的选项。我们将面临的前两个选项是决定桶名并选择桶的区域。我们的 S3 桶名有几个限制。S3 桶名的三个主要限制是它们

  • 必须在所有 S3 桶名中是唯一的(见 图 11.7)

  • 不能使用大写字母或下划线

  • 必须介于 3 到 63 个字符之间

图 11.7. 桶向导有助于选择桶名,该桶名必须在所有 S3 桶中是唯一的,并选择一个区域。

图片

命名 S3 桶的一个常见方法是将其拆分为一系列标签。例如,wolohan.mastering.largedata 可以是这本书的桶。该名称由三个标签组成——wolohan、mastering 和 largedata,每个标签之间由句点分隔。如果我想为类似目的创建第二个桶,我可以创建 wolohan.mastering.largedata2。如果我想为关于小数据的书籍创建一个桶,我可以将其命名为 wolohan.mastering.smalldata。另一种常见的方法是使用连字符而不是句点。

此外,在第一个创建存储桶页面,我们需要为我们的存储桶选择一个区域。区域指的是 AWS 将存储存储桶数据的数据中心组。在 AWS 术语中,一个区域是一组可用区,这些可用区本身是数据中心或数据中心的组合(图 11.8)。可用区级别提供最低的冗余和容错能力(尽管仍然相当不错),而区域级别的服务提供更多的容错能力(非常好),多区域设置提供最大的容错能力(优秀)。

图 11.8. AWS 中的区域和可用区指的是用于运行计算操作或存储数据的数据中心。从小规模(可用区)到大规模(多个区域)的迁移提高了容错能力。

AWS 中的托管服务通常在区域级别运行。我们自行管理的服务,如基本计算和传统块存储,在可用区级别运行。如果我们需要额外的冗余或需要将我们的应用程序提供给世界不同地区的客户,我们可以跨区域复制托管服务和自托管服务。就我们的目的而言,任何区域都适用。选择离您最*的一个,然后点击下一步。

在 S3 创建存储桶向导的下一两个屏幕中,我们可以选择存储桶的可选功能和权限。在配置选项屏幕中,我想引起您注意的是版本控制和标签功能(图 11.9)。

图 11.9. 配置选项提供了在 AWS 浏览器向导中生成 S3 存储桶的选项。

S3 版本控制是一个非常实用的功能,因为它允许我们跟踪对象随时间的变化。例如,我们可以使用 S3 存储数据库快照的所有版本在一个单独的对象中。话虽如此,使用 S3,我们确实需要为上传的对象的每个版本付费存储。如果我们上传一个 10 MB 的对象的四个版本,我们将为 40 MB 的存储付费。如果我们存储一个对象 100 个版本,每个版本 100 GB,我们将为 10 TB 的存储付费。版本控制是一个重要的功能,但如果您正在对大型对象进行版本控制,不要感到措手不及。

S3 存储桶的标签选项允许我们使用任意元数据来跟踪项目。例如,您可能希望为您的 S3 存储桶添加一个键为“project”且值为“chapter-11”的标签。您可以为您的项目添加所需的所有这些标签。例如,如果您有一个用于电影的存储桶,您可能希望添加一个键为“content”且值为“movies”的标签。完成标签添加后,点击进入下一屏幕。

从设置权限屏幕,我们可以对我们的桶的公开访问设置限制。公开访问是指直接从公共互联网进入的访问。对于数据分析,假设我们想在 AWS 上进行分析,就像我在下一章中演示的那样,我们不需要这个(图 11.10)。对于其他用例,公开访问可能很有帮助。亚马逊建议尽可能限制 S3 桶的公开访问。请继续阻止此桶的所有公开访问,点击下一两个页面,并创建该桶。

图 11.10. 对于分析工作流程,通常不需要对 S3 桶进行公开访问。

到目前为止,我们已经创建了一个 S3 桶。您应该在主 S3 登录页面上看到一个桶出现。点击该桶的链接,您将被带到该桶的特定登录页面。只要该桶为空,它将显示一个登录页面,为您提供三个选项(图 11.11):

  • 上传对象

  • 设置对象属性

  • 设置对象权限

图 11.11. 我们将主要使用 S3 桶上传对象。

在这三个中,我们将想要上传一个对象。如果我们点击左上角的蓝色“上传”按钮,我们将被带到另一个类似于我们刚刚经历的向导。这个向导是用来向 S3 桶添加数据的。

桶上传向导(图 11.12)允许我们上传单个文件或多个文件。请继续点击“添加文件”并从您的文件系统中选择一个文件。本书的第十一章存储库(您可以在以下链接中找到:www.manning.com/downloads/1961)包含本章后面程序示例中可以使用的几个文件。

图 11.12. 桶上传向导允许我们将文件上传到 S3 桶。

点击下一屏幕,选择使用桶级别权限,您将被带到设置属性屏幕(图 11.13)。在这个屏幕上,我们可以选择我们上传的对象的存储类别。

图 11.13. S3 中的存储类别都是针对不同的用例量身定制的。标准的 S3 存储类别适用于大多数用例。

我们在第 11.1.5 节中介绍了这三个存储类别:

  1. 标准存储适用于大多数用例。

  2. 不经常访问存储适用于我们希望可用但不会经常需要的数据。

  3. 冰川存储适用于我们想要保留但不太可能频繁使用的数据,并且在我们需要它之前会有足够的通知。

在这个屏幕上,我们可以看到这三个类别,以及更多。你会注意到 AWS 提供了不同存储类别何时有用的描述。在向导页面顶部,当前 S3 定价的链接让我们可以比较不同存储选项的成本。我建议使用 S3 标准版进行此上传和其他不太明显需要其他类别的上传。

此外,通过这个屏幕,我们还有添加元数据标签到我们的对象的选项(图 11.14)。这些标签可以是任何我们想要的键值对。它们可以帮助我们存储数据。对我来说,我正在上传名为 2014-01.json 的数据文件——我知道这是一个包含 2014 年 1 月数据的 JSON 文件。因此,我将给它三个标签:

  • 一个声明对象内容类型的标题

  • 一个自定义标签,表示对象的月份

  • 一个自定义标签,表示对象的年份

图 11.14。向 S3 对象添加元数据有助于我们在需要使用它们时找到这些对象。

11fig14_alt.jpg

我可以用这些标签在未来从所有上传到这个存储桶的对象中找到这个对象。

一旦你添加了你想要的元数据,点击通过这个屏幕,审查你的选择,并将对象上传到你的存储桶。现在,当你处于你的 S3 存储桶的登录页面时,你应该能看到该存储桶中所有对象的列表。应该只有一个对象:你刚刚上传的那个。点击该对象,你将被带到对象页面(图 11.15)。

图 11.15。S3 对象页面显示了对象元数据以及我们可采取的操作列表——例如下载对象或打开对象。

11fig15_alt.jpg

对象页面显示了您刚刚上传的对象的属性,包括

  • 对象的所有者

  • 对象最后修改的日期

  • 对象的存储类别

  • 对象的大小

此外,页面顶部的选项指示我们可以采取的操作。尝试使用打开选项打开对象,你将被带到错误页面。为什么会发生这种情况?

我们得到错误页面是因为我们试图通过浏览器从公共互联网访问对象,而我们阻止了对存储桶中所有对象的公共互联网访问。如果其他人试图访问我们的对象,他们也会看到相同的响应。如果我们想预览 JSON 文件,一个方便的方法是在“选择从”标签页上。

“选择从”标签页为我们提供了查询数据的选择(图 11.16)。如果我们选择 JSON 文件格式和 JSON 行类型,AWS 将为我们提供文档的预览。我们还可以点击并通过使用类似 SQL 的表达式来查询我们的文档。对于大文件,这可能是一种有效的数据预处理方法,尽管我们也可以使用本书中学到的映射和过滤技术。

图 11.16。我们可以使用 S3 Select 预览我们已上传到 S3 的 JSON、CSV 或 Apache Parquet 文件。S3 Select 为所有三种格式的数据提供类似 SQL 的访问。

Parquet:简洁的表格数据存储

在 图 11.16 中,你会注意到三个文件格式选项:CSV、JSON 和 Parquet。前两种我们已经在本书中使用过。CSV 是一个简单的表格数据存储,而 JSON 是一个可读性强的文档存储。两者在数据交换中都很常见,并且经常用于分布式大型数据集的存储。Parquet 是一个 Hadoop 原生的表格数据格式。

Parquet 使用巧妙的元数据来提高 map 和 reduce 操作的性能。在 Parquet 上运行一个作业可能只需要与在 CSV 或 JSON 文件上运行类似作业所需时间的 1/100。此外,Parquet 支持高效的压缩。因此,它可以以 CSV 或 JSON 成本的一小部分存储。

这些优点使 Parquet 成为需要主要由机器读取的数据的绝佳选择,例如用于批量分析操作。JSON 和 CSV 仍然是小型数据或可能需要一些人工检查的数据的好选择。有关 Parquet 的更多信息,请参阅 Jean-Georges Perrin 所著的 Spark in Action 第二版中的第七章:mng.bz/eD7P

手动上传对象是有用的,因为它可以是一个很好的介绍或提醒我们所有可用的选项。然而,它确实需要大量的点击。在下一节中,我们将探讨如何以编程方式上传对象。

11.2.2. 使用 Python 和 boto 对 S3 进行编程访问

虽然基于浏览器的 S3 界面很棒,但有时我们希望在尽可能少的人工参与下将对象上传到 S3。在这种情况下,我们可以使用 AWS SDK 之一。对于 Python,那就是 boto 库。

Boto 是一个库,它提供了对许多 AWS API 的 Pythonic 访问,包括 S3 API。我们可以使用 boto 来编写 Python 代码——包括我们在本书中迄今为止使用过的所有 map 和 reduce 优点——以将对象上传到 S3。boto 的当前版本是 boto3,我们可以使用 pip 来安装它:

pip install boto3

我们将能够使用 boto 代表我们与 AWS 进行交互。为此,我们需要授予它授权。这种授权以访问密钥和访问密钥秘密的形式出现。要创建这些密钥,我们将在浏览器中返回 AWS。具体来说,我们希望进入我们的身份访问管理(IAM)控制台。

带有 IAM 的安全云

通过亚马逊网络服务 IAM 接口,我们可以创建具有不同访问权限的账户。例如,我们可能希望让我们的开发人员访问我们的计算资源,但限制财务团队只能访问计费。这是一个强大的工具——类似于操作系统上的用户账户。

在 AWS 中默认情况下,我们以 root 身份操作。正如您可能从 Unix 系统的工作中知道的那样,root 访问权限给我们提供了很多权力,但它也可能允许恶意或无知的行动者造成大量损害。因此,我们希望限制我们在 root 身份下花费的时间。为此,我们将创建单独的 IAM 账户来工作。

通过以下方式导航到 IAM 控制台的“用户”选项卡:

从这里,您将看到一个空的用户列表。它是空的,因为我们还没有创建任何 IAM 用户。顶部将有一个大蓝色“添加用户”按钮。点击该按钮,您将被带到另一个 AWS 向导(图 11.17)。此向导将引导我们设置 IAM 用户。

图 11.17. AWS 创建用户向导将帮助我们创建一个只能以编程方式访问我们的 AWS 资源的用户。

在第一页,为用户输入用户名并勾选程序访问权限的复选框。这将提供用户凭证以使用 AWS 的 Python SDK:boto。不要勾选 AWS 管理控制台访问的复选框。不勾选它将防止该用户通过 Web 访问 AWS。

点击到第二页,您将被要求设置用户权限(图 11.18)。这就是我们决定用户可以做什么和不能做什么的地方。我们希望我们的用户能够访问和修改处理大型数据集所需的 AWS 资源。AWS 将此类用户称为数据科学家。为了给创建的用户赋予数据科学家的权限,请执行以下操作:

  • 点击“附加现有”,以便我们可以查看 AWS 建议的权限策略。

  • 在搜索栏中输入 DataScientist 并选择出现的搜索结果。

图 11.18. 向我们的新 IAM 用户添加数据科学家策略将允许用户访问在云中处理大型数据集所需的资源,但除此之外没有其他权限。

注意,AWS 为其他角色提供了各种其他权限集,例如系统管理员、仅限计费、仅限读取和仅限数据库管理,我们可以使用这些权限集来确保人们只能访问他们需要的资源。更多信息请参阅表 11.2。

表 11.2. 有用的 AWS 安全策略和您可能会分配它们的常见情况
AWS 安全策略 用例
管理员访问 需要能够管理其他用户;启动和关闭所有服务
数据科学家 执行通用数据分析任务的用户,需要使用 S3、EC2 和 Elastic MapReduce 服务的组合
AmazonElasticMapReduceRole 需要使用 AWS 的 Elastic MapReduce 集群计算能力的用户
AmazonS3FullAccess 需要读取和写入 AWS S3 数据的程序/脚本
AmazonS3ReadOnlyAccess 仅需要从 AWS S3 读取数据的程序/脚本
PowerUserAccess 需要访问所有服务的所有功能但不需要管理其他用户的高级用户

一旦准备好——点击下一两个屏幕,直到看到一条成功消息,表明用户已创建。在这个页面上,您将看到一个下载 .csv 文件的选项。此文件包含您刚刚创建的用户凭证,包括我们需要通过 boto 程序化访问 S3 所需的访问密钥和秘密密钥。下载此文件并准备好——我们即将编写一些代码。

使用 Python SDK boto3 进行 AWS 脚本编写

在本章的存储库中,有关于车祸的数据。我们将在下一章在云中分析这些数据——但首先,我们需要将其加载到 S3 存储桶中。为此,我们将使用我们在第二章中首次介绍的熟悉的映射模式。对于这个映射操作,我们需要两样东西:

  • 一系列文件路径,指示我们想要上传的所有文件

  • 一个执行上传这些文件工作的辅助函数

让我们从辅助函数开始,以便开始使用 boto3。我们的映射辅助函数通常只接受一个参数,但为了这个映射辅助函数,让我们设计它以接受两个参数。第一个将是我们要上传的文件的路径,第二个是我们想要上传的存储桶。这将使我们能够为其他存储桶重用该函数。

我们首先关注函数的第一个参数:文件路径。让我们使用这个文件路径,并通过 os.path.split 函数从路径中提取文件名。当我们将其上传到 S3 时,我们将赋予文件这个名称——与我们在本地系统上相同的名称。

从这里,我们准备好创建一个 AWS 客户端实例。客户端实例是一个具有代表我们可以在 AWS 上执行的操作的方法的类,例如将文件上传到存储桶。这在 boto 中作为 .client 提供,我们将使用三个参数对其进行初始化:

  • 我们想要使用的服务名称——在这种情况下是 "s3"

  • 我们创建的数据科学家账户的访问密钥 ID

  • 我们创建的数据科学家账户的秘密访问密钥

重要的是,我们不想以纯文本形式传递这些密钥。如果我们将代码上传到代码仓库,这样做可能会暴露我们的账户凭证。相反,我们想要从环境变量中读取它们。您可以在 Unix 机器上使用 export 命令或从 PC 上的环境变量向导中分配访问密钥和访问秘密。

export AWS_ACCESS_KEY=YOUR-ACCESS-KEY-HERE
export AWS_SECRET_KEY=YOUR-ACCESS-SECRET-HERE

凭证、AWS 和 boto3

当使用 boto3 时,您有几种方式可以建立您的身份。我在这里选择的方法在易用性和安全性之间取得了平衡。另外两种流行的选择是将您的访问密钥和秘密密钥指定在位于 ~/.aws/credentials~/.aws/config 的凭证或配置文件中。亚马逊在其 AWS 命令行界面文档中提供了如何设置这些文件的说明:mng.bz/O9oo

凭证文件的优点是您可以指定多个配置文件——例如,用于开发和环境——并在设置 boto3 会话时轻松地在它们之间切换。这超出了本章的范围,但我鼓励您查看 boto3 配置文档以获取更多信息:mng.bz/G4ER

提供这三个参数将返回一个可以代表我们执行 S3 操作的客户端。此客户端有一个 .upload_file 方法,我们可以使用它来上传我们的文件。我们还将向 .upload_file 方法传递三个参数:

  1. 我们想要上传的文件的文件路径

  2. 我们想要上传到的存储桶的名称

  3. 我们希望文件在 S3 上显示的名称

此方法执行 AWS 上传并可能返回一个 HTTP 响应。让我们将此响应返回,并连同文件名一起,作为我们函数完成时的值。我们可以在下面的列表中看到完整的辅助函数。

列表 11.1. 用于上传文件到 S3 的辅助函数
import boto3 as aws #A
import os

def upload_file(file_path, bucket):
    _, file_name = os.path.split(file_path)
    s3 = aws.client("s3",
        aws_access_key_id = os.environ["AWS_ACCESS_KEY"],
        aws_secret_access_key = os.environ["AWS_SECRET"]
    )
    response = s3.upload_file(file_path, bucket, file_name)
    return file_name, response

要使用此函数,我们需要将其映射到一系列文件上。使用我们在第四章中介绍的 iglob 函数,将我们感兴趣的文件序列分配给一个变量。一旦我们有了这个序列,我们需要将我们的 upload_file 函数应用到每个文件上,如下面的列表所示。

列表 11.2. 从文件系统上传文件到 S3
from glob import iglob

if __name__ == "__main__":
    files = iglob("/path/to/data/files/*")
    [upload_file(f, bucket="your-bucket-name") for f in files]

运行此代码将花费一些时间,但它不应在终端中提供任何完成线索。相反,在浏览器中导航到您创建的 S3 存储桶。一旦到达那里,您应该会看到一个装满数据文件的存储桶,准备进行分析(图 11.19)。

图 11.19. 您的浏览器显示了已上传到 AWS 简单存储服务存储桶中的流量数据文件。

11fig19_alt.jpg

在下一章中,我们将使用这些文件和这个存储桶来学*如何在云中分析大型数据集。

11.3. 练*

11.3.1. S3 存储类

以下三种情况下哪种 S3 存储类最适合?

  1. 我们知道我们很少需要并且有足够时间准备的数据

  2. 我们知道我们每个月只需要使用几次的数据

  3. 我们需要定期访问的数据

11.3.2. S3 存储区域

AWS 资源存在于可用区或区域内部。高度耐用的 S3 存储是否存在于可用区或跨区域?

11.3.3. 对象存储

对象存储的三个基本组成部分是什么?

  • 对象、对象名称、对象位置

  • 对象、对象路径、对象颜色

  • 对象、对象大小、元数据

  • 对象、对象 ID、元数据

摘要

  • 亚马逊网络服务简单存储解决方案——通常称为 S3——是我们希望在云中操作的大型数据集的绝佳选择,因为它在大小上实际上是无限的。

  • S3 也是一种托管服务——AWS 充当我们数据的保管人,我们可以专注于从中获取价值。

  • 在 S3 中,对象可以是任何我们上传的数据文件,它们存储在桶中。我们可以为桶和对象分配元数据标签以进行组织。

  • 如果我们经常访问它们,我们可以将 S3 对象存储在标准存储类别中;如果不太经常访问,则存储在频繁访问存储类别中;对于存档,则使用冰川存储类别。

  • 我们可以通过 AWS 的图形界面通过浏览器创建桶和上传对象。界面显示了我们执行每个操作时的大量选项。

  • 我们还可以通过 AWS 的 Python 软件开发工具包 boto3 上传对象。

第十二章. 使用 Amazon 的弹性 MapReduce 在云中实现 MapReduce

本章涵盖

  • 使用弹性 MapReduce 启动和配置云计算集群

  • 使用 mrjob 在云中运行 Hadoop 作业

  • 使用 Spark 进行分布式云机器学*

在整本书中,我们一直在谈论扩展代码的能力。我们首先探讨了如何在本地并行化代码;然后转向分布式计算框架;最后,在第十一章(kindle_split_022.html#ch11)中,我们介绍了云计算技术。在本章中,我们将探讨我们可以用来处理任何规模数据的技巧。我们将看到如何将我们在书中中间部分介绍过的 Hadoop 和 Spark 框架(Hadoop 为第七章和第八章;Spark 为第七章、第九章和第十章)带入云中,使用 Amazon Elastic MapReduce。我们将首先探讨如何使用 mrjob 将 Hadoop 带入云中——这是我们在第八章中介绍的一个用于 Hadoop 和 Python 的框架。然后,我们将探讨将 Spark 及其机器学*功能带入云中。

12.1. 使用 mrjob 在 EMR 上运行 Hadoop

在第八章中,我们回顾了两种与 Hadoop 一起工作的方法:

  1. Hadoop Streaming— 它使用 Python 脚本来进行映射和归约

  2. mrjob— 我们可以使用它仅使用 Python 代码来执行 Hadoop 作业

当我们使用这两种方法时,我们专注于在 Hadoop 中实现映射和减少风格。通过第八章中的技术,如果你已经有一个可用的 Hadoop 集群,你可以利用它,但大多数人没有。在本节中,我们将回顾在亚马逊网络服务(AWS)的弹性映射和减少(EMR)上运行 Hadoop 作业,这是一个我们可以随时创建计算集群的服务。

12.1.1. 使用 EMR 方便的云集群

以前,Hadoop 集群只为那些经常需要它们或能够负担得起大部分时间闲置的大量计算资源的人所保留。这意味着 10 年前,大部分情况下,只有企业和学术机构才有集群计算。现在,随着云服务的普及,每个人都可以访问。获取计算集群的一种便捷方式是亚马逊的弹性映射和减少服务(Elastic MapReduce)。

其他云计算服务

亚马逊并不是云基础集群计算服务的唯一提供商。他们的两个主要竞争对手,微软 Azure 和谷歌云,都提供与亚马逊网络服务类似的服务。微软的 Azure HDInsight 服务和谷歌的云数据处理服务(Cloud Dataproc)都支持 Hadoop 和 Spark。这意味着你可以使用第七章到第十章的知识来使用这两项服务。在第八章中介绍并在本节中深入探讨的 mrjob 也支持谷歌云数据处理服务。mrjob 不支持 Azure HDInsight。

在本章中,我们将使用 AWS,因为我们希望使用上一章中使用的资源。

亚马逊网络服务的 EMR 是一个托管数据集群服务。我们指定集群的一般属性,AWS 运行软件为我们创建集群。当我们完成使用集群后,亚马逊将计算资源吸收回其网络。你可以将其想象成 S3 云存储。由于亚马逊拥有如此多的计算能力,我们可以随时请求一些资源,他们将会租给我们。然后,当我们不再需要时,亚马逊很乐意将其收回。

使用这种设置,如果我们每月或每年只需要运行一次大型数据处理任务,我们就不需要在整个月或整个年内支付维护集群的费用。我们可以在需要处理时请求 AWS 提供计算资源,处理完毕后,我们可以将计算资源归还给亚马逊。如果我们需要更频繁地做这项工作,或者在不规则的时间间隔内进行,我们可以维护一个小型集群,该集群可以根据使用情况扩展。

例如,假设我们每六小时运行一次对 Facebook、Twitter 和 Instagram 上关于我们产品评论的文本分析。我们可能始终维护一个小集群以减少启动时间,然后根据要分析的新数据量来扩展集群。如果我们产品只有几千条评论,我们可能不需要扩展我们的小集群。如果发生意外情况——比如说一位 A 级名人被发现使用我们的产品——并且我们有数十万条评论需要解析,我们的集群可以自动扩展以适应增加的流量。

重要的是,亚马逊 EMR 服务的定价模式是按计算单元每秒计费。如果我们运行 100 台机器,并且我们的作业在 2 分钟内完成,我们将支付与我们在一台机器上处理并花费 200 分钟相同的费用。这意味着缓慢做事没有成本节约。亚马逊鼓励我们并行化我们的问题。所有三家云服务提供商——微软、谷歌和亚马逊——都以这种方式定价他们的托管计算服务,尽管价格因提供商而异。

12.1.2. 使用 mrjob 启动 EMR 集群

启动 EMR 集群最简单的方法是使用我们已熟悉的 Python 库:mrjob。尽管我们可以——并且在第八章中这样做——在本地使用 mrjob,但 mrjob 旨在自动化 EMR 集群的采购。通过编写带有 EMR 的 Hadoop 作业并指定正确的设置,我们可以在云中快速设置一个 Hadoop 集群。

因为我们在本章后面将使用 Spark 进行机器学*(第 12.2 节),所以让我们做一些数据分析,这将帮助我们理解我们在第十一章上传到 S3 的文件。在第十一章中,我们上传了关于交通事故的数据,包括诸如白天时间和涉及车辆数量等特征。对于我们在 EMR 上的第一个 Hadoop 作业,让我们编写一个 MapReduce 作业,计算不同数量车辆发生事故的次数(图 12.1)。

图 12.1. 我们可以使用 EMR 将 MapReduce 作业扩展到任何规模。在这种情况下,我们将使用 EMR 来分析涉及不同数量车辆的交通事故数量。

图片

要做到这一点,我们将创建一个继承自主要 mrjob 类的自定义类。然后我们将为该类编写两个方法:

  1. 一个.mapper,接收行并返回涉及的车辆数量

  2. 一个.reducer,用于将车辆分组并计算总数

因为我们的数据存储在 JSON 行格式中,所以当我们使用.mapper处理每一行时,我们将使用json.loads将其读取到一个 Python 对象中,如列表 12.1 所示。从那里,我们可以使用字典符号来检索事故中涉及的车辆数量。在tuple中产生这个值和 1 将使我们能够在我们.reducer中向上计数值。

列表 12.1. 使用 mrjob 按车辆数量计数碰撞
from mrjob.job import MRJob
import json

class CrashCounts(MRJob):

    def mapper(self, _, line):
        crash_report = json.loads(line)
        vehicles = crash_report['Number of Vehicles Involved']
        yield vehicles, 1

    def reducer(self, key, values):
        yield key, sum(values)

if __name__ == '__main__':
    CrashCounts.run()

在我们的.reducer中,我们使用标准的计数归约。键保持不变,但值变为所有值的总和。如果我们本地运行,结果将是一系列键和值打印到终端。第一个值表示涉及的车辆数量,第二个值表示涉及该数量车辆的碰撞数量。如果你有本地数据文件,你可以运行 mrjob 脚本并自行验证这一点。

要在云上 EMR 上运行此操作,我们需要在命令行中向我们的 mrjob 脚本传递三个额外的参数,如列表 12.2 所示:

  1. 我们需要指定的第一个参数称为 runner。这告诉 mrjob 如何处理我们的命令。默认情况下,它会在本地处理。要在 EMR 上处理,我们需要指定-r emr

  2. 接下来,我们需要提供一个指向我们的输入文件的路径。到目前为止,我们一直在使用 blob 语法并指向这些文件在本地存储的位置。然而,我们的数据存储在 S3 中。这个路径将需要是我们的存储桶。

  3. 最后,让我们指定一个文件夹,我们将把输出写入该文件夹。我们可以将其放在同一个存储桶中或单独的存储桶中。

列表 12.2. 使用 mrjob 在 EMR 上运行 Hadoop
 python mrjob_crash_counts.py \
       -r emr \
       s3://your-bucket-name-here/ \
       --output-dir=s3://your-other-bucket-name/crash-counts

除了这些定义脚本将去往何处的变量之外,我们还需要提供我们的凭证。mrjob 使用它们代表我们创建计算集群。为了保持这些凭证的秘密性,mrjob 坚持要求你将这些变量导出到你的本地环境中。这防止你在源代码中以明文形式暴露你的凭证。如果你在第十一章中使用 boto3 上传数据到 S3 时没有提供凭证,以下列表显示了如何在 Mac 和 Linux 上执行此操作。对于 Windows,搜索“环境变量”并按照向导操作。

列表 12.3. 为 mrjob 设置 AWS 凭证
export AWS_ACCESS_KEY_ID=<your AWS access key>
export AWS_SECRET_ACCESS_KEY =<your secret AWS access key>

一旦你设置了环境变量,你将能够使用列表 12.2 中的命令在 EMR 上运行 mrjob。默认情况下,此命令将为你启动一个小型测试集群。对于学*工具,这个小集群已经足够了。对于更大的作业,你将需要使用更多资源。使用更多资源的一种常见方法是使用 mrjob 配置文件。此文件允许我们使用 YAML 符号来指定我们想要的集群类型。

例如,如果我们想

  • 使用 20 个实例运行我们的 Hadoop 作业

  • 让所有这些实例都是 m1.large

  • 让这些资源位于美国西部(北部加利福尼亚)地区

  • 使用具有值为“Mastering Large Datasets”的“项目”标签标记这些资源。

我们可以在配置文件中指定所有这些。以下列表中提供了一个配置文件示例。

列表 12.4. mrjob 的示例配置文件
runners:
  emr:
    num_core_instances: 20
    image_version: 5.24.0
    instance_type: m1.large
    region: us-west-1
    tags:
      project: Mastering Large Datasets

在配置文件中指定设置使我们能够使用和重用多个设置。例如,运行夜间提取-转换-加载过程可能只需要使用 20 个实例。然而,对于月度管理报告,我们可能需要使用 100 个实例。我们可以使用两个配置文件来允许我们保存我们的参数。

我们可以在使用 Python 调用 mrjob 时,通过命令行传递这些参数,使用conf-path参数。下面的列表显示了这一操作的示例。

列表 12.5. 向 mrjob 调用添加配置文件
python mrjob_crash_counts.py \
       -r emr \
       s3://your-bucket-name-here/ \
       --output-dir=s3://your-other-bucket-name/crash-counts
       --conf-path=</path/to/your/config/file.conf>

一旦你成功地在 EMR 上使用 mrjob 运行了 Hadoop 作业,我们就可以打开 AWS 控制台来查看发生了什么。

12.1.3. AWS EMR 浏览器界面

在第 12.1.2 节中,我们探讨了如何使用 AWS EMR 与 mrjob 工具。在本节中,我们将探讨如何使用浏览器界面运行 Spark 作业。正如 AWS 为我们之前在第十一章中查看的对象存储系统 S3 提供了基于浏览器的界面一样,AWS 也为我们提供了基于浏览器的 EMR 界面。你可以通过访问console.aws.amazon.com/elasticmapreduce/home来访问该界面。

如果你从第 12.1.2 节运行了作业并且作业成功完成,你应该会看到一个状态为“已终止——所有步骤完成”的任务(图 12.2)。如果你看到其他消息,作业可能仍在运行,或者可能发生了错误。

图 12.2. 亚马逊基于浏览器的控制台提供了我们集群状态的便捷概览,包括它们的名称、ID、状态、启动时间和总运行时间。

12fig02_alt.jpg

从 AWS 集群控制台查看集群状态

无论你在运行作业后看到什么,点击集群名称,你将到达特定于该集群的控制台页面。在这个页面上,你会看到实例的名称和状态,以及其他关于你的集群的信息(图 12.3)。

图 12.3. 集群控制台显示了特定于该 AWS 集群的信息,例如集群 ID 和其中的机器数量。你还可以使用此控制台来修改运行集群的设置。

12fig03_alt.jpg

点击步骤标签,你将找到提交给你的集群的所有步骤列表。在 EMR 中,步骤是我们通过 EMR API 发送到集群的任务——无论是通过控制台、使用 boto3 等 SDK,还是通过命令行 AWS 工具。在我们的情况下,应该只有一个步骤(图 12.4)。当我们使用 EMR 运行器运行 mrjob 时,AWS 创建了这一步骤。

图 12.4。EMR 控制台的步骤特定详细信息屏幕显示了我们的集群需要工作的任务信息。

可以将多个步骤提交到同一个集群。如果我们这样做,步骤将一个接一个地运行。每个步骤将在开始之前等待它前面的所有步骤完成。如果我们想要同时运行多个步骤,我们可以从 EMR 同时请求多个集群。

这个标签页很有用,因为它提供了方便地访问每个步骤的日志。如果你的 EMR 步骤失败——如果你足够频繁地使用该服务,这是不可避免的——这个页面可以在你的调试过程中提供帮助。此外,当作业失败时,mrjob 会解析由 Hadoop 作业创建的日志,并尝试为你提供一个用户友好的错误诊断。Hadoop 的日志是 Java 日志,Java 错误信息可能需要一些适应。如果你比 Java 更熟悉 Python,mrjob 的诊断可以非常有帮助。

运行大量的小型 EMR 作业

在这个标签页上,你还会注意到每个特定步骤花费的时间量。例如,在图 12.4 中,你可以看到我的崩溃计数脚本只花费了 1 分钟运行。与图 12.3 相比,整个集群运行了 13 分钟。剩余的时间用于设置和拆除。在设置阶段,机器被采购并连接,并在其上安装必要的软件(如 Python、Java 和 Hadoop)。在拆除阶段,AWS 返回资源并生成日志。

如果你将运行大量的小任务,这是一个你可能想要将多个步骤提交到单个集群的场景。mrjob 使我们能够通过create-cluster命令轻松设置集群。我们可以将此命令传递到配置文件中,以便集群的行为就像我们用单个作业创建它一样。此外,集群将在我们的作业完成后继续运行。当我们运行这种持久集群时,我们通常会想要指定在完全关闭之前它可以空闲的最大小时数:

mrjob create-cluster --max-hours-idle 1 --conf-path=path/to/conf/file.conf

这可以防止我们为不需要的资源付费。

要将作业提交到现有的集群,我们需要指定我们想要提交代码的集群 ID。在命令行中,该参数是--cluster-id,并且应该跟在我们想要在其上运行的集群 ID 后面。

另一个需要注意的参数是emr_action_on_failure(在配置文件中)或--emr-action-on-failure(在命令行中)。这些参数指定了如果我们的作业失败,集群应该发生什么。当我们运行单个步骤时,默认为TERMINATE_CLUSTER。将失败终止作为默认设置意味着如果我们的作业有任何错误,我们的集群将关闭。emr_action_on_failure的另外两个选项是CANCEL_AND_WAITCONTINUE

CANCEL_AND_WAIT 告诉集群取消已排队的其他步骤,并暂停执行任何操作。如果您的步骤相关,这很有用。例如,如果您有一个提取-转换-加载工作流程中的三个步骤——每个步骤分别用于提取、转换和加载——您不希望加载步骤在转换步骤未正确完成时运行。

CONTINUE 告诉集群继续执行其他步骤。当步骤不相关时,这很有用;例如,如果您正在运行批量分析。一个分析步骤的结果不一定会影响下一个步骤,因此,如果我们其中一个步骤有错误,我们仍然可以继续我们的分析作业。我们在以下列表中使用 CONTINUE

列表 12.6. 在 mrjob 配置文件中指定集群 ID 和故障行为
runners:
  emr:
    num_core_instances: 6
    image_version: 5.24.0
    instance_type: m1.large
    region: us-west-1
    cluster_id: j-000000000            *1*
    emr_action_on_failure: CONTINUE    *1*
    tags:
      project: Mastering Large Datasets
  • 1 从命令行指定集群 ID 和故障时的操作可以让我们节省时间,反复设置集群以快速运行作业。
在 S3 中查看我们的输出

让我们看看我们的 Hadoop 作业的输出。当我们调用我们的 mrjob 脚本时,我们指定了一个输出目录。这是一个 S3 桶中的文件夹。我们的输出被写入到该桶中的对象。如果您在浏览器中导航到该桶,您应该会看到一个对象列表(图 12.5)。

图 12.5. 在您的浏览器中,该桶列出了我们的 Hadoop 处理结果创建的对象。

这些对象都是我们的 Hadoop 处理崩溃计数的结果,并包含结果的一部分。这些文件中的每一行都将与我们的 mrjob 类的 .reducer 输出具有相同的模式。每一行的第一个元素将是参与事故的车辆数量,第二个元素将是看到涉及该数量车辆的碰撞次数。

如果我们不希望将结果存储到 S3 桶中,省略 --output-dir 参数将改为将那些值打印到我们的屏幕上。在知道我们不会在未来通过 EMR 使用那些结果的情况下,将值输出到屏幕上可能很有用。一个例子可能是当我们测试我们的作业时。我们可以用几个小实例运行它,并将结果本地打印出来以进行测试,然后在验证作业正常工作后使用许多实例并保存结果。

在本节中,我们回顾了如何使用 mrjob 和亚马逊网络服务的 EMR 将 Hadoop 作业提交到云计算集群。EMR 上的 Hadoop 对于大型数据处理工作负载,如批量分析或提取-转换-加载,非常出色。在下一节中,我们将回顾在 EMR 上使用 Spark。

12.2. 在 EMR 上使用 Spark 进行云中的机器学*

当我在第七章中介绍 Hadoop 和 Spark 时,我将它们都介绍为分布式计算的框架。Hadoop 非常适合低内存工作负载和大量数据。Spark 非常适合难以分解为 map 和 reduce 步骤的工作,以及我们可以负担更高内存机器的情况。在本节中,我们将关注如何使用 Spark 在 EMR 上对云中的大量数据进行机器学*模型的训练。

12.2.1. 编写我们的机器学*模型

在我们可以在云端运行我们的机器学*器之前,让我们首先在测试数据上本地构建一个模型。这将模拟如果我们在一个真正的大型数据集上运行机器学*算法时可能会执行的过程:

  1. 获取整个数据集的样本。

  2. 在该数据集上训练和评估几个模型。

  3. 从中选择一些模型在完整数据集上进行评估。

  4. 在云端对整个数据集训练几个模型。

这个过程的好处是,它使你能够在本地机器上快速且低成本地测试大量模型。而且,由于我们使用的是可扩展的框架和可扩展的计算风格,我们可以将我们喜欢的模型带到云端,并在完整数据集上测试它们(图 12.6)。

图 12.6. 对于大型数据集,一个常见的机器学*过程是在本地采样许多模型,然后在云端评估最佳模型。

对于这个场景,我们将继续使用我们在第十一章中上传并在本章第一部分探索的车祸数据。

场景:车祸分析

车祸的根本原因分析是政府和安全组织使驾驶更安全的关键方式。我们被这样一个组织要求开发一个机器学*模型,可以预测哪些条件会导致涉及多辆车(三辆或更多)的碰撞,以及哪些条件会导致只涉及一辆车的碰撞。

如果你已经完成了第十章,其中我们学*了 Spark 中的机器学*,你可能想自己尝试这一部分作为挑战。我们将使用朴素贝叶斯分类器作为此场景的机器学*模型。朴素贝叶斯算法是一种简单、概率分类器,常用于对机器学*问题难度的基线评估,尤其是在文本分析中。朴素贝叶斯算法表现不佳的问题可以认为是困难的,而朴素贝叶斯算法表现良好的问题则相对容易。

运行朴素贝叶斯算法的第一步与第十章中决策树的第一步相同:我们需要读取数据。我们的数据是 JSON 行格式,因此读取数据的最佳方式是使用 SparkContext.textFile 方法,然后链式调用 .map 方法将数据转换成适合转换为 Spark DataFrame 的版本。Spark DataFrames 是 Spark 内置机器学*库所需的数据格式。

为了将数据从 JSON 行转换为 Python 对象的序列,我们首先需要将数据拆分为行。我们可以通过在所有换行符上使用 .flatMap.split 来完成此操作。我们在这里使用 .flatMap 而不是正常的 .map,因为我们的原始序列是一系列文件。如果我们使用标准的 .map,那么每个文件被转换成一系列行,我们会得到一系列序列。我们想要的只是一个行序列。.flatMap 方法将我们的序列序列扁平化为一个单一的序列。从这里,我们可以将 JSON 模块中的 loads 函数映射到所有行上。这种方法我们已经使用过几次,它将 JSON 格式的字符串转换为 Python 对象。

此外,我们还想改进记录时间的方式。在数据中,时间以原始时间记录。这没有用,因为我们的机器学*模型可能没有足够的数据来学*上午 11:45 和下午 1:03 是密切相关的,但下午 3:45 和下午 5:03 可能具有非常不同的驾驶条件(因为傍晚通勤交通的开始)。列表 12.7 包含一个使时间有意义的函数。

一旦我们将数据放入 Python 对象的序列中,我们还想进行另外两个清理转换。首先,我们将想要将碰撞分为三类:

  1. 单辆车碰撞

  2. 双辆车碰撞

  3. 三辆车或更多车辆的碰撞

碰撞数量将是我们的分析的目标变量。为了进行此分组,我们需要编写一个辅助函数来转换 '涉及车辆数量' 字段,如下所示。

列表 12.7. 从 JSON 行读取和清理碰撞数据
def group_crashes(x):
    if int(x['Number of Vehicles Involved']) > 3:
        x['Number of Vehicles Involved'] = "3"
    return x

def improve_times(x):
    time = x['Time']
    if time < "5:00":
        x['Time'] = "Early morning"
    elif time < "7:00":
        x['Time'] = "Morning"
    elif time < "9:00":
        x['Time'] = "Morning commute"
    elif time < "12:00":
        x['Time'] = "Late morning"
    elif time < "16:00":
        x['Time'] = "Afternoon"
    elif time < "18:30":
        x['Time'] = "Evening commute"
    elif time < "22:00":
        x['Time'] = "Evening"
    else:
        x['Time'] = "Late night"
    return x

  sc = SparkContext(appName="Crash model")
  spark = SparkSession.builder \
                      .master("local") \
                      .getOrCreate()

  texts = sc.textFile("/path/to/your/files/")
  xs = texts.flatMap(lambda x:x.split("\n")) \
            .map(json.loads) \
            .map(group_crashes) \
            .map(improve_times)

从这一点开始,我们准备将我们的 RDD 转换为 DataFrame 并为 Spark 的机器学*器准备我们的 DataFrame。要将 RDD 转换为 DataFrame,我们使用 SparkSession.createDataFrame 方法(图 12.7)。SparkSession 对象是 Spark 的 SQL、DataFrame 和机器学*能力的核心,并作为 SparkContext 对象的 RDDs 的镜像。

图 12.7. 我们可以使用 SparkContextSparkSession 来利用 RDDs 和 DataFrames。当我们想要使用 DataFrame 的机器学*方法时,我们需要显式地将我们的 RDD 转换为 DataFrame

一旦我们的数据在DataFrame中,我们需要使用StringIndexer将我们的变量转换为 Spark 期望的索引格式(列表 12.8)。我们不会在这里详细介绍索引器代码,因为本节的重点是 Spark 和 EMR。如果你需要复*,我们最初在第十章中讨论了这些概念,特别是第 10.2.2 节以及列表 10.2、10.3 和 10.4。

列表 12.8. 准备机器学*用的事故RDD
  df = spark.createDataFrame(xs)

  feature_labels = df.columns
  feature_labels.pop(feature_labels.index('Number of Vehicles Involved'))
  df = reduce(string_to_index, feature_labels, df)
  indexes = ["i-"+f for f in feature_labels]

  df = VectorAssembler(inputCols=indexes,
                       outputCol="features").transform(df)

  df = StringIndexer(inputCol='Number of Vehicles Involved',
                     outputCol='label').fit(df).transform(df)

我们的DataFrame已经准备好用于机器学*,最后一步是设置实际的机器学*算法。正如之前提到的,对于这个例子,我们想使用朴素贝叶斯算法。就像在第十章的最终示例中一样,我们将使用交叉验证来评估模型性能。从算法的名称(朴素)你可能已经猜到,与更复杂的模型相比,朴素贝叶斯模型具有相对较少的参数,所以我们只优化模型的单个参数:平滑度。平滑度参数指的是模型中使用的附加平滑度量。附加平滑过程防止零值主导模型,而是将零值视为非常小的数字。典型值是 1/1000,1/100,1/10 和 1。你可以在下面的列表中看到机器学*代码。你会发现这段代码和我们在第 10.3.2 节中为随机森林分类器编写的代码有很多相似之处。

列表 12.9. 事故中车辆的朴素贝叶斯分类器
mce = MulticlassClassificationEvaluator()
nb = NaiveBayes()

grid = ParamGridBuilder().addGrid(nb.smoothing, [.0001, .001, .01, 1]) \
                         .build()
cv = CrossValidator(estimator=nb, estimatorParamMaps=grid,
                    evaluator=mce,numFolds=5,
                    parallelism=4)
cv_model = cv.fit(df)
transformed = cv_model.transform(df)
f1 = mce.evaluate(transformed)
print("NB F1: {:0.4f}".format(f1))
cv_model.bestModel.save("/path/to/your/model")

你可能会注意到,这段代码与第十章中的代码不同的一点是我们将评估指标称为 F1 而不是 AUC。F1,就像 AUC 一样,是一个评估假阳性与假阴性之间权衡的指标。它在信息检索和文档分类中应用最为广泛。对于我们来说,知道 F1 分数可以在 0 到 1 之间变化,数值越高越好就足够了。

你可以在本地运行此代码,将.textFiles方法和.bestModel.save方法指向本地机器上的位置。对于.textFiles的输入,我建议使用完整事故数据集的一个子集。这将加快测试过程——整个数据集在单台机器上处理需要几分钟。对于输出,你指定了一个 Spark 将尝试创建目录并存储模型描述的位置。这应该是一个尚未存在的目录。

提醒:Spark-submit

记住,要用spark-submit实用程序而不是 Python 运行你的 Spark 代码。spark-submit实用程序将 Spark 作业排队,它将在本地并行运行并模拟如果你在活动集群上运行程序会发生什么。

12.2.2. 为 Spark 设置 EMR 集群

要在云中运行这个机器学*作业,我们需要一个运行 Spark 作业的集群。在本节的前面部分,我们看到了两种使用 mrjob 以编程方式设置 EMR 集群的方法:

  1. 我们可以通过提交带有 -r emr 标志的 Hadoop 作业来设置单步集群。

  2. 我们可以通过运行 mrjob create-cluster 实用工具来设置持久集群。

在本小节中,我将向您展示如何使用 mrjob 设置 Spark 集群,并介绍 EMR 集群向导。

使用 mrjob 设置 Spark 集群

在 第 12.1 节 中,我们使用 mrjob 设置了 EMR 集群,以便我们可以在云中运行我们的 Hadoop 作业。作为这部分内容的一部分,我们编写了一个 mrjob 配置文件(列表 12.4)。mrjob 配置文件是我们希望我们的集群看起来像什么的声明,如 列表 12.10 所示。我们可以使用相同的方法来设置一个 Spark 集群。我们只需要指定一些额外的选项。

列表 12.10. 复*:mrjob 配置用于 EMR
runners:
  emr:
    num_core_instances: 20
    image_version: 5.24.0
    instance_type: m1.large
    region: us-west-1
    tags:
      project: Mastering Large Datasets

注意,此配置定义了一个由 21 台机器组成的集群——20 个工作节点和 1 个主节点。这些机器的类型是 m1.large,并使用 AMI 版本 5.24.0。此外,我们将在 us-west-1 区域设置集群,并标记为“project: Mastering Large Datasets”。

对于我们的 Spark 集群,我们首先需要做的是更改我们使用的实例,以一个具有更多内存的实例。如我之前提到的,Hadoop 是为了利用低计算能力环境而设计的。Spark 有更高的资源需求。对于 Spark,我们可以使用的最小实例类型是 m1.xlarge。当在生产环境中运行 Spark 作业时,我们可以通过使用 AWS C 系列实例来获得更好的性能,这些实例是针对计算优化的。

EC2 实例类型和集群

我们需要了解三种类型的 EC2 实例用于集群计算:M 系列、C 系列和 R 系列。M 系列实例是集群计算的默认选项。这些实例是坚固的通用实例。我建议将它们用于 Hadoop 作业和 Spark 作业的测试。AWS 为计算密集型工作负载提供 C 系列实例,包括 Spark 分析。批处理 Spark 作业最好在生产环境中在 C 系列实例上运行。最后,R 系列实例是一个高内存系列。如果我们处理流分析,我们将想要使用这个系列的实例。

接下来,我们需要告诉 mrjob 我们想要使用 Spark。为此,mrjob 提供了一个名为 bootstrap_spark 的选项。这个选项接受一个布尔变量,因此我们将它设置为 true

最后,我们希望能够通过 SSH 命令行访问我们的实例。SSH 是一个我们可以用来登录远程服务器并运行命令的实用工具。为了设置集群以便我们可以通过 SSH 登录,我们需要指定一个 AWS .pem 密钥对。

如果您还没有设置 AWS EC2 密钥对,您可以使用与 boto3 一起安装的 AWS 命令行工具创建一个。该命令是aws ec2 create-key-pair。您还想要设置强制性的--key-name选项,这样您就可以引用您的密钥。

aws ec2 create-key-pair --key-name my-emr-key > /path/to/my/key.pem

AWS EC2 密钥是区域特定的,此命令将在您的默认区域中创建密钥。如果您不确定您的默认区域是什么,您可以访问console.aws.amazon.com。区域将显示为 URL 中的参数;例如,mng.bz/ZeA5

这样,我们就有了设置 Spark 集群的配置文件。我们的 Spark mrjob 配置文件看起来如下所示。

列表 12.11. Spark EMR 集群的 mrjob 配置文件
runners:
  emr:
    num_core_instances: 4
    image_version: 5.24.0
    max_hours_idle: 1
    instance_type: m1.xlarge
    region: us-west-1
    bootstrap_spark: true       *1*
    ec2_key_pair: my-emr-key    *2*
    tags:
      software: Spark
      project: Mastering Large Datasets
  • 1 告诉 EMR 在集群上安装 Spark

  • 2 为 EMR 提供我们将用于 SSH 到集群的密钥名称

您可以使用create-cluster命令运行此集群。当您这样做时,您应该会收到一个 JSON 字符串作为响应。您还可以访问 AWS EMR 控制台并查看您的集群设置。一旦您确认集群正在运行,您可以随时将其关闭。要这样做,只需选中集群旁边的复选框,然后点击屏幕顶部的终止按钮。

AWS EMR 集群向导

除了使用 mrjob 设置 EMR 集群外,我们还可以使用 AWS 控制台来设置。就像我们在第十一章中看到的那样,与 S3 一起使用,AWS 控制台是查看我们使用 AWS 时所有选项的好方法。要开始,导航到 EMR 控制台主页面:console.aws.amazon.com/elasticmapreduce/

在这个页面上,您应该会看到一系列集群列表,包括您在运行 Hadoop 作业时可能创建的集群以及您在 Spark 和 mrjob 的前一小节中创建的集群。在这个页面的顶部,您应该会看到一个按钮邀请您创建一个集群。该按钮启动 AWS EMR 集群向导。

当您点击它时,您将立即被带到快速设置页面。在这个页面上,有四组选项:

  1. 描述我们的集群的常规选项

  2. 软件选项告诉 AWS 我们在集群上要做什么

  3. 硬件选项告诉 AWS 为我们保留哪些实例

  4. 安全选项告诉 AWS 我们将如何访问集群

在常规选项(常规配置)部分(图 12.8),您会给您的集群起一个您能认出的名字。那里还有两个其他选项定义了日志行为和启动模式——这两个都是默认的。接下来,在软件选项(软件配置)(也图 12.8),您会想要使用最新的 EMR 版本并选择包含 Spark 的软件配置。这告诉 AWS 在设置我们的集群时安装 Spark。

图 12.8. AWS EMR 向导的“常规配置”部分允许您指定集群名称和其他配置选项。

12fig08_alt.jpg

滚动页面,你会看到硬件和安全配置选项(图 12.9)。对于硬件选项(硬件配置),将实例类型设置为 m1.xlarge,并将实例数量设置为 3。如果你更改实例数量,你会注意到核心节点数——将在你的集群上运行工作的节点——总是比所选的总实例数少一个。这是因为总有一个实例需要作为主实例。最后,从下拉菜单中选择你的 EC2 密钥对。如果你在这里看不到你的密钥对,请尝试使用屏幕右上角的下拉菜单更改可用区域。

图 12.9. 在 EMR 设置向导中,硬件和安全配置选项提供了一个简单的 GUI 来启动一个合适大小的集群。

12fig09_alt.jpg

如果你从这里继续启动你的集群,你将启动一个与使用 mrjob 启动的集群大致相同的集群。然而,相反,请转到页面顶部并选择“转到高级选项”。这将带您进入一个四步向导,显示 EMR 集群的所有选项。

首先,你会看到一个可供你使用的软件长列表,包括

  1. Hadoop

  2. Spark

  3. JupyterHub

  4. Hive

  5. Pig

  6. TensorFlow

在本书中,我们已经介绍了前两种软件——Hadoop 和 Spark:Hadoop 在第七章和 8 章,Spark 在第七章、9 章和 10 章。此外,本章我们还讨论了 Hadoop 和 Spark。根据你的背景,你可能熟悉剩下的四个工具。

JupyterHub 是流行的 Jupyter Notebook 软件的集群版。安装该软件意味着您可以从笔记本环境中运行交互式的 Spark 和 Hadoop 作业。这对于数据分析师和数据科学家来说是一个非常好的工具。

Hive 和 Pig 是类似工具,它们为大数据集提供了 SQL 或类似 SQL 的接口。分析师可以使用 Hive 将 SQL 代码编译成 Hadoop MapReduce 作业。同样,我们也可以使用 Pig 将Pig-latin命令编译成运行 Hadoop MapReduce 作业。这两款软件都旨在使大数据集对传统商业分析师可访问。

最后一个是 TensorFlow,这是一个流行的深度学*库。该库被用于许多最先进的深度学*实现。在 AWS 上运行 TensorFlow 的能力可以显著减少训练时间,因为它使我们能够在 GPU(图形处理单元——为快速算术设计的处理器)集群或 TPU(张量处理单元——为深度学*设计的处理器)上运行作业,如果没有云,这些作业将是成本高昂的。

如果你点击向导中的下一页,你将看到定义集群可用硬件的详细选项(图 12.10)。特别是在这一页上,请注意底部的实例组。你会注意到,在这里你不仅能够定义你想要的实例类型,还可以查看和设置这些实例的资源定价信息。例如,我们可以看到 AWS 建议我们使用的 m3.xlarge 实例具有 8 个虚拟核心和 15 GB 的内存。

图 12.10. 高级硬件配置选项使我们能够竞标临时实例并设置集群的自动扩展。

12fig10_alt.jpg

此外,我们还可以为我们的实例选择按需或临时定价。临时定价是一种短期市场价格,我们可以用它来在计算作业上节省资金。当我们启用临时定价时,亚马逊以低廉的价格为我们提供未使用的实例。然而,这种低廉的价格伴随着一些风险。如果临时价格超过我们的出价——例如,当 AWS 资源需求高时——亚马逊可能会关闭我们的实例并将它们租给另一个买家。话虽如此,如果我们正在夜间运行批量分析作业,这通常是一种节省资金的好方法。

最后,在创建集群——高级选项视图中,我们可以看到自动扩展选项。当我们打开自动扩展时,AWS 将监视我们的资源使用情况,并根据需要扩展或缩小我们的集群。例如,如果我们正在运行一个大的 Spark 作业,集群可能会扩展到我们设置的实例最大数量。当那个作业完成时,集群最终会缩小到最小,直到我们准备好运行另一个作业。

点击下一页,你将能够

  • 定义日志设置

  • 为你的集群添加自由格式的键值标签

  • 添加 EC2 密钥对以进行 SSH 访问

一旦你有机会查看这些页面上的所有选项,你可以使用快速或高级设置创建一个集群。如果你更喜欢使用 mrjob,你也可以从上一个子节使用 mrjob create-cluster 命令重新启动你的集群。在接下来的子节中,当我们通过 SSH 连接到我们的集群时,你需要一个运行中的集群,其中包含 Spark,并附加了 EC2 密钥对。

注意

你可以在写作时以不到 5 美元的价格运行本章中的示例。

12.2.3. 从我们的集群运行 PySpark 作业

一旦我们的集群启动并运行,我们几乎准备好运行我们的机器学*作业了。只剩下五个步骤:

  1. 修改我们的云脚本

  2. 将我们的脚本添加到 S3

  3. 通过 SSH 连接到主节点

  4. 安装所需的软件

  5. 配置我们的 Spark 集群以运行 Python3

  6. 运行我们的 Spark 作业

这些步骤将使我们从只有本地机器学*脚本的机器学*脚本,到在云上运行我们的机器学*作业。但首先,我们需要对我们的机器学*脚本进行两项小的修改。

在本节前面提到的朴素贝叶斯脚本中,我们使用了本地文件路径作为输入和输出。这很重要,因为我们想在本地上测试脚本。当我们云中运行脚本时,我们将无法访问本地资源——只有云中的资源。我们需要更改这些路径以指向云位置。具体来说,我们将它们都指向 S3 位置。

要引用一个 S3 存储桶,在你的存储桶名称前加上 S3://。例如,如果你有一个名为 my-favorite-S3-bucket 的存储桶,该存储桶的路径将是 S3://my-favorite-S3-bucket/。对于 S3 文件夹,你可以在存储桶名称后添加任何单词。将输入路径指向包含你的车祸数据文件的存储桶,将输出路径指向另一个存储桶中的文件夹,如下所示。完成这些后,将其保存为一个新的文件。

列表 12.12。输入和输出的云准备路径
texts = sc.textFile("S3://your-crash-data-bucket/")
. . .
cv_model.bestModel.save("S3://your-output-bucket/nb-model")

定义了这些路径后,我们就拥有了一个适用于云的脚本。不幸的是,如果这个脚本在我们的本地机器上,我们就无法从云中访问它。我们还需要将脚本移动到云中。同样,我们将使用 S3。如果你愿意接受挑战,你可以尝试自己创建一个新的存储桶并将脚本上传到那里,无论是使用 AWS 控制台还是 boto3。否则,你可以按照第 11.2.1 节中的说明创建一个新的存储桶并上传你的新脚本。

一旦脚本上传完毕,我们就准备好登录到我们的集群。如果你使用的是 Mac 或 Linux 机器,你将能够使用内置的 SSH 工具。在 Windows 上,你需要下载一个支持 SSH 的终端模拟器:PuTTY 是这一领域的传统选择。你可以从这里下载它:mng.bz/RP4D

一旦你知道你可以使用 SSH,请转到浏览器中的 EMR 控制台并找到你的运行中的 EMR 集群。在页面顶部,你会看到一个集群的路径(图 12.11)。这就是你将 SSH 进入的路径。

图 12.11。你可以在你集群的控制台页面顶部找到你集群的主节点地址。

12fig11_alt.jpg

要进入集群,打开你的终端或 PuTTY,并输入集群的地址。(AWS 提供了使用 PuTTY 连接的文档,mng.bz/2Jn9。)此外,你还需要通过将 SSH 指向你的密钥来识别自己:

ssh -i /path/to/your/key.pem ec2-00-000-000-00.compute-1.amazonaws.com

如果一切顺利,你应该能在你的终端屏幕上看到 ASCII 艺术的 EMR(图 12.12)。这将告诉你你已经登录到了主节点。从这个屏幕开始,整个集群都在你的控制之下。你可以像在本地机器的命令行一样安装软件和运行脚本。对于我们的机器学*脚本,我们需要 NumPy——这是一个用于数值处理的 Python 库。让我们确保它已安装,并且为了保险起见,我们还将安装 toolz 包。

图 12.12. 当你登录到 EMR 集群时,会看到一个漂亮的 EMR ASCII 艺术。

我们安装 Python 库的方式不会因为我们在本地机器上还是在远程服务器上而改变;我们使用 pip。然而,因为我们想使用 Python3,所以我们需要安装 pip3,这通常不是默认安装的。你可以使用命令 sudo yum install -y pip3 来安装 pip3。然后你可以使用 pip3 来使用命令 sudo pip3 install -y numpy toolz 安装 NumPy 和 toolz。此外,你可以安装你可能希望使用的任何其他软件。在主节点上安装软件将在所有其他节点上复制此安装。

在这一点上,如果你想测试 NumPy、toolz 或其他任何库,你可以

  • 使用 python3 命令调用 Python

  • 导入你想要测试的库

  • 从控制台运行任何你想要的 Python 代码

现在我们已经设置了 Python3 环境来运行我们的机器学*脚本。不幸的是,Spark 仍然配置为使用遗留的 Python。让我们配置我们的 Spark 环境,使其运行 Python3。为此,我们需要修改服务器上的一个 shell 文件。在服务器上,我们只能使用基于终端的文本编辑器。这些编辑器中最容易使用的是 nano

要在 nano 中打开文件,我们输入 nano 然后文件名。要打开存储 Spark 环境变量的文件,我们将输入

nano $SPARK_HOME/conf/spark-env.sh

当你打开它时,你会看到一个 shell 脚本。在脚本的底部,修改显示 PYSPARK_PYTHON 的行,使其读取 PYSPARK_PYTHON=python3。当你完成时,你可以通过按以下键来保存并退出文件(这些键在 Mac、PC 和 Linux 上都是相同的):

  1. Control-O— 开始保存文件

  2. Enter— 将文件写入磁盘

  3. Control-X— 开始退出 nano

  4. Y— 告诉 nano 你想要退出

最后,为了使这些更改生效,激活你刚刚修改的 Spark 环境:

source $SPARK_HOME/conf/spark-env.sh

现在我们已经准备好运行我们的 Spark 机器学*脚本了!你可以像在本地机器上一样运行你的 PySpark 脚本。记住,你的脚本位于 S3 存储桶中,你需要将 PySpark 指向那里的文件。

spark-submit S3://bucket-holding-my-script/my-script.py

当你运行脚本时,你会看到 Spark 的标准输出。它会告诉你它在做什么,并给出任务的进度。根据你分配给这个集群的实例数量,这个作业可能需要一点时间或更长的时间。完成时,你将能够进入你为输出指定的 S3 存储桶,并看到一个名为 nb-model 的文件夹。这个文件夹包含你训练的朴素贝叶斯模型的压缩描述。

12.3. 练*

12.3.1. R 系列集群

编写一个 mrjob 配置文件,你可以用它来启动五个 R 系列实例的集群。

12.3.2. 连续运行 Hadoop 作业

配置一个持久的 EMR 集群,然后在那个集群上执行两个 Hadoop MapReduce 任务。例如,第一个任务中仅选择 JSON 行数据的几个字段,然后使用另一个任务将那些数据转换成 CSV 格式。

12.3.3. 实例类型

我们在集群计算工作流中使用三种主要的实例类型:M、C 和 R。以下哪种类型适合以下每个情况?

  • 流式工作流

  • Hadoop 工作流

  • 测试工作流

  • Spark 工作流

摘要

  • 弹性映射减少(Elastic MapReduce),简称 EMR,是我们可以使用的一种 AWS 管理服务,可以快速方便地获得集群计算能力。

  • 我们可以使用 mrjob 库在 EMR 上运行 Hadoop 任务,这允许我们用 Python 编写分布式 MapReduce 并进行集群计算。

  • 我们可以使用 mrjob 的配置文件来描述我们希望我们的集群看起来像什么,包括我们希望使用哪些实例,我们希望这些实例位于何处,以及我们可能希望添加的任何标签。

  • 当在 EMR 上运行 Hadoop 时,我们可以直接在 S3 中的数据上操作,这促进了 PB 级分析以及提取-转换-加载操作。

  • 当我们需要在大数据集上运行高级分析和机器学*时,AWS EMR 也支持 Spark。

  • 在 EMR 上运行 Spark 任务需要比 Hadoop 任务更强大的实例,这可能会增加成本。但对于某些工作流,Spark 任务的运行速度将比 Hadoop 任务快。

posted @ 2025-11-21 09:10  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报