PySpark-高级数据分析-全-

PySpark 高级数据分析(全)

原文:zh.annas-archive.org/md5/1465ef285ca983de186a0de91cf82eab

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Apache Spark 的漫长前辈系列,从 MPI(消息传递接口)到 MapReduce,使得编写能够利用大规模资源的程序成为可能,同时抽象了分布式系统的繁琐细节。数据处理需求推动了这些框架的发展,事实上,大数据领域与这些框架的相关性如此之深,以至于其范围由这些框架能处理的内容来定义。Spark 最初的承诺是将这一点推向更远——使编写分布式程序感觉像编写常规程序一样。

Spark 的流行崛起与 Python 数据(PyData)生态系统的兴起相吻合。因此,Spark 的 Python API——PySpark,在过去几年中显著增长了其流行度。尽管 PyData 生态系统最近出现了一些分布式编程选项,但 Apache Spark 仍然是跨行业和领域处理大数据集的最受欢迎选择之一。由于最近努力将 PySpark 与其他 PyData 工具集成,学习这个框架可以帮助您显著提高作为数据科学从业者的生产力。

我们认为教授数据科学的最佳方式是通过示例。为此,我们编写了一本应用程序书籍,试图涉及大规模分析中最常见的算法、数据集和设计模式之间的互动。这本书不打算从头到尾阅读:选择一个看起来像您正在尝试完成的任务的页面或者简单激发您兴趣的章节,从那里开始。

为什么我们现在写这本书?

Apache Spark 在 2020 年经历了一个重大版本升级——版本 3.0。最大的改进之一是引入了 Spark 自适应执行。这一特性消除了调整和优化的大部分复杂性。我们在书中没有提及它,因为在 Spark 3.2 及更高版本中默认开启,因此您自动获得了这些好处。

生态系统的变化,再加上 Spark 的最新主要版本发布,使得这一版本显得及时。与之前选择 Scala 的《高级分析与 Spark》不同,我们将使用 Python。在适当时,我们将涵盖最佳实践并与更广泛的 Python 数据科学生态系统集成。所有章节都已更新为使用最新的 PySpark API。新增了两个章节,并对多个章节进行了重大改写。我们不会涵盖 Spark 的流处理和图形库。随着 Spark 进入新的成熟和稳定时代,我们希望这些改变能使这本书成为未来几年内有用的分析资源。

本书的组织方式

第一章 将 Spark 和 PySpark 放在数据科学和大数据分析的更广泛背景下进行了介绍。之后,每一章都包括了使用 PySpark 进行的自包含分析。第二章 通过数据清洗的应用案例介绍了 PySpark 和 Python 中的数据处理基础。接下来的几章深入探讨了使用 Spark 进行机器学习的核心内容,应用了一些最常见的算法在经典应用中。剩下的章节则更多地涉及一些稍微特别的应用场景,例如通过文本中的潜在语义关系查询维基百科,分析基因组数据,以及识别相似图像。

这本书并非关于 PySpark 的优缺点。它也不是关于其他几件事情。它介绍了 Spark 编程模型以及 Spark 的 Python API,PySpark 的基础知识。然而,它并不试图成为 Spark 的参考资料或者提供所有 Spark 的细节和技巧的全面指南。它也不是机器学习、统计学或线性代数的参考书,尽管很多章节在使用这些内容之前会提供一些背景知识。

相反,这本书将帮助读者了解如何使用 PySpark 处理大规模数据进行复杂分析的实际感受,覆盖整个流程:不仅仅是构建和评估模型,还包括数据清洗、预处理和探索,同时关注将结果转化为生产应用。我们认为通过示例来教学是最好的方式。

这里有一些本书将处理的任务示例:

预测森林覆盖

我们通过使用决策树来预测森林覆盖类型,使用相关特征如位置和土壤类型(见第四章)。

查询维基百科的类似条目

我们通过使用 NLP(自然语言处理)技术识别条目之间的关系,并通过查询维基百科语料库来进行处理(见第六章)。

理解纽约出租车的利用率

我们通过执行时间和地理空间分析来计算出租车等待时间的平均值作为位置的函数(见第七章)。

降低投资组合的风险

我们使用蒙特卡洛模拟来评估投资组合的财务风险(见第九章)。

在可能的情况下,我们尽量不仅仅提供“解决方案”,而是演示完整的数据科学工作流程,包括所有的迭代、死胡同和重新启动。本书将有助于更熟悉 Python、Spark 和机器学习及数据分析。然而,这些都是为了更大的目标服务,我们希望本书最重要的是教会您如何处理类似前述任务。每章,大约 20 页,将尝试尽可能接近演示如何构建这些数据应用的一部分。

本书使用的约定

本书使用以下印刷约定:

斜体

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

常量宽度

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

常量宽度粗体

显示用户应按字面输入的命令或其他文本。

常量宽度斜体

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

此元素表示提示或建议。

此元素表示一般注释。

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

使用代码示例

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

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

本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您要复制代码的大部分内容,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发来自 O’Reilly 书籍的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到您产品的文档中需要许可。

我们感谢您的支持,但不要求署名。署名通常包括书名、作者、出版商和 ISBN 号。例如:“使用 PySpark 进行高级分析,作者 Akash Tandon、Sandy Ryza、Uri Laserson、Sean Owen 和 Josh Wills(O’Reilly)。版权 2022 年 Akash Tandon,978-1-098-10365-1。”

如果您认为您使用的代码示例超出了公平使用范围或上述授权,请随时与我们联系,邮箱为permissions@oreilly.com

O’Reilly 在线学习

在超过 40 年的时间里,O’Reilly Media已经为公司提供了技术和商业培训、知识和洞察力,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章以及我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问https://oreilly.com

如何联系我们

有关本书的评论和问题,请联系出版商:

  • O’Reilly Media, Inc.

  • Gravenstein Highway North 1005 号

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

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

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

  • 707-829-0104(传真)

我们为本书设有一个网页,列出勘误、示例以及任何额外信息。您可以访问该页面 https://oreil.ly/adv-analytics-pyspark

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

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

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

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

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

致谢

毫无疑问,如果没有 Apache Spark 和 MLlib 的存在,你不会读到这本书。我们要感谢建造和开源它的团队以及为其增添内容的数百名贡献者。

我们要感谢所有花费大量时间审阅上一版书籍内容的专家们:Michael Bernico、Adam Breindel、Ian Buss、Parviz Deyhim、Jeremy Freeman、Chris Fregly、Debashish Ghosh、Juliet Hougland、Jonathan Keebler、Nisha Muktewar、Frank Nothaft、Nick Pentreath、Kostas Sakellis、Tom White、Marcelo Vanzin,还有再次感谢 Juliet Hougland。谢谢大家!我们真心感激。这大大提高了结果的结构和质量。

Sandy 还要感谢 Jordan Pinkus 和 Richard Wang 对风险章节背后理论的帮助。

感谢 Jeff Bleiel 和 O’Reilly 在出版这本书并把它送到你手中的经验和大力支持。

第一章:分析大数据

当人们说我们生活在大数据时代时,他们意味着我们有工具可以在以前听都没听说过的规模上收集、存储和处理信息。在 10 或 15 年前,以下任务根本无法完成:

  • 使用数千个特征和数十亿笔交易构建检测信用卡欺诈的模型

  • 智能向数百万用户推荐数百万种产品

  • 通过包含数百万个工具的投资组合模拟估算财务风险

  • 轻松操作来自数千人的基因组数据,以检测与疾病相关的遗传关联

  • 通过定期处理数百万张卫星图像评估农业用地利用和作物产量,以改进政策制定

在这些能力背后是一整套开源软件生态系统,可以利用服务器集群处理大量数据。2006 年引入/发布的 Apache Hadoop 推动了分布式计算的广泛采用。从那时起,大数据生态系统和工具在快速发展。过去五年还见证了许多开源机器学习(ML)和深度学习库的引入和采用。这些工具旨在利用我们现在收集和存储的大量数据。

但就像凿子和一块石头不能创造雕像一样,拥有这些工具和所有这些数据之间存在一定的差距。通常,“做些有用的事情”意味着在表格数据上放置架构,并使用 SQL 来回答像“在我们的注册流程中成功到达第三页的无数用户中,超过 25 岁的有多少?”这样的问题。关于如何设计数据存储和组织信息(数据仓库、数据湖等)以便轻松回答此类问题的领域非常丰富,但在本书中我们大多数情况下将避免其复杂性。

有时,“做些有用的事情”需要额外的工作。虽然 SQL 仍然可能是方法的核心,但为了解决数据的特殊性或进行复杂的分析,我们需要一种更灵活、功能更丰富的编程范式,特别是在机器学习和统计等领域。这就是数据科学的应用场景,也是我们在本书中将要讨论的内容。

在本章中,我们将首先介绍大数据的概念,并讨论处理大型数据集时出现的一些挑战。然后,我们将介绍 Apache Spark,一个用于分布式计算的开源框架,以及其关键组件。我们的重点将放在 PySpark 上,这是 Spark 的 Python API,并探讨它在更广泛生态系统中的应用。接着,我们将讨论 Spark 3.0 带来的变化,这是该框架四年来的首个重要版本。最后,我们将简要说明 PySpark 如何解决数据科学的挑战,并解释为什么它是你技能组合的重要补充。

本书的早期版本使用了 Spark 的 Scala API 来展示代码示例。我们决定改用 PySpark,因为 Python 在数据科学社区中很受欢迎,而且核心 Spark 团队也在更好地支持这种语言。通过本章的学习,您理想地将会欣赏到这个决定。

处理大数据

在处理大数据时,我们喜爱的许多小数据工具在某些时候会遇到瓶颈。像 pandas 这样的库无法处理不能放入内存的数据。那么,等效的过程应该是什么样的,可以利用计算机集群在大数据集上实现相同的结果?分布式计算的挑战要求我们重新思考在单节点系统中依赖的许多基本假设。例如,因为数据必须分布在集群的许多节点上,具有广泛数据依赖关系的算法将受到网络传输速率远远慢于内存访问的影响。随着解决问题的机器数量增加,失败的概率也会增加。这些事实要求一种编程范式,对底层系统的特性敏感:它阻止糟糕的选择,并且使编写能够高度并行执行的代码变得容易。

最近几年在软件社区中备受关注的单机工具并不是数据分析的唯一工具。像基因组学这样处理大数据集的科学领域几十年来一直在利用并行计算框架。今天在这些领域处理数据的大多数人熟悉一个名为 HPC(高性能计算)的集群计算环境。Python 和 R 的困难在于它们无法很好地扩展,而 HPC 的困难在于其相对低的抽象水平和使用难度。例如,要并行处理一个充满 DNA 测序读数的大文件,我们必须手动将其分割成更小的文件,并为每个文件提交一个作业给集群调度器。如果其中一些失败,用户必须检测到故障并手动重新提交。如果分析需要全局排序等全对全操作,大数据集必须通过单个节点进行流式处理,或者科学家必须借助更低级别的分布式框架如 MPI,而这些框架在没有对 C 语言和分布/网络系统的广泛知识的情况下很难编程。

面向高性能计算环境的工具通常未能将内存中的数据模型与较低级别的存储模型解耦。例如,许多工具只知道如何从 POSIX 文件系统中以单一流的方式读取数据,这使得工具在自然并行化或使用其他存储后端(如数据库)方面变得困难。现代分布式计算框架提供了抽象层,允许用户将计算机集群视为单个计算机——自动分割文件并将存储分布到多台机器上,将工作分解为较小的任务并以分布式方式执行,以及从故障中恢复。它们可以自动化处理大型数据集的许多麻烦,并且比高性能计算成本更低。

关于分布式系统的一个简单理解是,它们是一组独立的计算机,对最终用户表现为单个计算机。它们支持横向扩展,即添加更多计算机,而不是升级单个系统(纵向扩展)。后者成本相对较高,通常无法满足大工作负载。分布式系统在扩展性和可靠性方面表现出色,但在设计、构建和调试时也引入了复杂性。在选择此类工具之前,应理解这种权衡。

介绍 Apache Spark 和 PySpark

进入 Apache Spark,这是一个开源框架,结合了跨多台机器分发程序的引擎和一个优雅的编程模型。Spark 起源于加州大学伯克利分校的 AMPLab,并已贡献给 Apache 软件基金会。在发布时,它可以说是第一个使分布式编程真正对数据科学家可访问的开源软件。

组件

除了核心计算引擎(Spark Core)外,Spark 还由四个主要组件组成。用户使用其任一 API 编写的 Spark 代码在集群中的工作节点的 JVM(Java 虚拟机)中执行(参见 第二章)。这些组件作为独立的库提供,如图 1-1 所示:

Spark SQL 和 DataFrames + Datasets

用于处理结构化数据的模块。

MLlib

一个可扩展的机器学习库。

结构化流处理

这使得构建可扩展的容错流应用程序变得容易。

GraphX(已过时)

GraphX 是 Apache Spark 的图形和图形并行计算库。然而,对于图形分析,推荐使用 GraphFrames 而不是 GraphX,因为 GraphX 的开发不如以前活跃,并且缺乏 Python 绑定。GraphFrames 是一个开源的通用图形处理库,类似于 Apache Spark 的 GraphX,但使用基于 DataFrame 的 API。

aaps 0101

图 1-1. Apache Spark 组件

PySpark

PySpark 是 Spark 的 Python API。简单来说,PySpark 是基于核心 Spark 框架的 Python 封装,而 Spark 框架主要是用 Scala 编写的。PySpark 为数据科学从业者提供直观的编程环境,结合了 Python 的灵活性和 Spark 的分布式处理能力。

PySpark 允许我们跨编程模型工作。例如,一个常见模式是使用 Spark 执行大规模的提取、转换和加载(ETL)工作负载,然后将结果收集到本地机器上,并使用 pandas 进行操作。在接下来的章节中,我们将探讨这些编程模型。这里是官方文档中的一个代码示例,让你一窥即将到来的内容:

from pyspark.ml.classification import LogisticRegression

# Load training data
training = spark.read.format("libsvm").load("data/mllib/sample_libsvm_data.txt")

lr = LogisticRegression(maxIter=10, regParam=0.3, elasticNetParam=0.8)

# Fit the model
lrModel = lr.fit(training)

# Print the coefficients and intercept for logistic regression
print("Coefficients: " + str(lrModel.coefficients))
print("Intercept: " + str(lrModel.intercept))

# We can also use the multinomial family for binary classification
mlr = LogisticRegression(maxIter=10, regParam=0.3, elasticNetParam=0.8,
                         family="multinomial")

# Fit the model
mlrModel = mlr.fit(training)

# Print the coefficients and intercepts for logistic regression
# with multinomial family
print("Multinomial coefficients: " + str(mlrModel.coefficientMatrix))
print("Multinomial intercepts: " + str(mlrModel.interceptVector))

生态系统

Spark 是大数据生态系统中最接近瑞士军刀的工具。此外,它与生态系统的其余部分集成良好,并具有可扩展性。与之前描述的 Apache Hadoop 和 HPC 系统不同,Spark 分离了存储和计算。这意味着我们可以使用 Spark 读取存储在多个来源中的数据 — 如 Apache Hadoop、Apache Cassandra、Apache HBase、MongoDB、Apache Hive、关系型数据库等 — 并在内存中处理它。Spark 的 DataFrameReader 和 DataFrameWriter API 还可以扩展到从其他来源读取数据,例如 Apache Kafka、Amazon Kinesis、Azure Storage 和 Amazon S3 等。它还支持多种部署模式,从本地环境到 Apache YARN 和 Kubernetes 集群。

它还有一个庞大的社区。这导致了许多第三方包的创建。一个由社区创建的这类包的列表可以在这里找到。主要的云提供商(AWS EMRAzure DatabricksGCP Dataproc)还提供了用于运行托管 Spark 工作负载的第三方供应商选项。此外,还有专门的会议和本地聚会组,可用于了解有趣的应用和最佳实践。

Spark 3.0

在 2020 年,Apache Spark 发布了自 2016 年发布 Spark 2.0 以来的第一个重要版本 — Spark 3.0。这个系列的上一个版本,发布于 2017 年,涵盖了由 Spark 2.0 带来的变化。与上一个主要版本发布相比,Spark 3.0 并没有引入太多重大的 API 变更。该版本主要侧重于性能和可用性的改进,而不引入显著的向后不兼容性。

Spark SQL 模块在自适应查询执行和动态分区修剪方面获得了主要的性能增强。简单来说,它们允许 Spark 在运行时调整物理执行计划,并在查询结果中跳过不需要的数据。这些优化解决了用户以前需要进行手动调优和优化的重大工作量。在 TPC-DS 上,Spark 3.0 比 Spark 2.4 快近两倍,这是一个行业标准的分析处理基准。由于大多数 Spark 应用程序都由 SQL 引擎支持,所有高级库,包括 MLlib 和结构化流处理,以及高级 API,包括 SQL 和 DataFrames,都受益于此。符合 ANSI SQL 标准使得 SQL API 更加可用。

Python 在数据科学生态系统中的采用率领先。因此,Python 现在是 Spark 上最广泛使用的语言。PySpark 在 Python 包索引(PyPI)上每月的下载量超过 500 万次。Spark 3.0 改进了其功能和可用性。重设计了 pandas 用户定义函数(UDFs),以支持 Python 类型提示和迭代器作为参数。新增了新的 pandas UDF 类型,并且错误处理现在更符合 Python 的风格。Python 版本低于 3.6 已被弃用。从 Spark 3.2 开始,也已弃用了 Python 3.6 支持。

在过去的四年里,数据科学生态系统也发生了快速变化。现在更加关注将机器学习模型投入生产。深度学习已经取得了显著的成果,Spark 团队目前正在实验,以便项目的调度程序可以利用像 GPU 这样的加速器。

PySpark 解决了数据科学的挑战

对于一个旨在实现对海量数据进行复杂分析的系统来说,要取得成功,它需要考虑到或者至少不与数据科学家面临的一些基本挑战冲突。

  • 首先,成功进行分析所需的绝大部分工作都涉及数据预处理。数据是杂乱的,清洗、整理、融合、拼接以及其他许多动作都是能够对其进行实用处理的先决条件。

  • 其次,迭代 是数据科学的一个基本部分。建模和分析通常需要多次对相同数据进行遍历。流行的优化过程如随机梯度下降涉及对其输入进行重复扫描以达到收敛。迭代也在数据科学家自身的工作流中起着重要作用。选择正确的特征,选择正确的算法,运行正确的显著性测试以及找到正确的超参数都需要进行实验。

  • 第三,当构建出一个表现良好的模型时,任务并未结束。数据科学的目的是使数据对非数据科学家有用。数据推荐引擎和实时欺诈检测系统的用途最终体现在数据应用程序中。在这样的系统中,模型成为生产服务的一部分,并可能需要定期甚至实时重建。

PySpark 很好地处理了数据科学中提到的挑战,承认构建数据应用程序中最大的瓶颈不是 CPU、磁盘或网络,而是分析师的生产力。将从预处理到模型评估的整个流程折叠到单一编程环境中可以加快开发速度。通过在 REPL(读取-评估-打印循环)环境中打包表达性编程模型和一组分析库,PySpark 避免了与集成开发环境的来回往返。分析师能够快速地实验他们的数据,这样他们就更有可能从中获得实用的东西。

读取-评估-打印循环,或称 REPL,是一种计算机环境,其中用户输入被读取和评估,然后结果被返回给用户。

PySpark 的核心 API 为数据转换提供了坚实的基础,独立于统计、机器学习或矩阵代数的任何功能。在探索和感知数据集的同时,数据科学家可以将数据保留在内存中运行查询,并且可以轻松地缓存转换后的数据版本,而不需要经历磁盘的来回。作为一个既使建模变得简单又非常适合生产系统的框架,对于数据科学生态系统来说,这是一个巨大的胜利。

接下来的去向

Spark 跨越了为探索性分析设计的系统和为运营分析设计的系统之间的鸿沟。据说,数据科学家在工程方面比大多数统计学家更擅长,在统计方面比大多数工程师更擅长。至少,Spark 在作为运营系统方面比大多数探索系统更出色,在数据探索方面也比运营系统中常用的技术更好。希望本章对您有所帮助,并且您现在对开始使用 PySpark 感到兴奋。从下一章开始我们将进入实际操作!

第二章:使用 PySpark 进行数据分析介绍

Python 是数据科学任务中使用最广泛的语言。能够使用同一语言进行统计计算和 web 编程的前景,促成了它在 2010 年代初期的流行。这导致了一个繁荣的工具生态系统和一个对数据分析非常有帮助的社区,通常被称为 PyData 生态系统。这是 PySpark 受欢迎的一个重要原因。能够通过 Python 中的 Spark 利用分布式计算帮助数据科学从业者提高生产力,因为他们熟悉这种编程语言并且有一个广泛的社区。出于同样的原因,我们选择在 PySpark 中编写我们的示例。

在同一个环境中完成所有数据清洗和分析工作,不论数据本身存储和处理的位置如何,都会产生多么深远的变革,这是很难用言语表达的。这种感受需要亲身经历才能理解,我们希望我们的示例能够捕捉到我们首次开始使用 PySpark 时所体验到的那种神奇感觉。例如,PySpark 支持与 pandas 的互操作性,后者是最流行的 PyData 工具之一。我们将在本章进一步探讨这一功能。

在本章中,我们将通过数据清洗练习探索 PySpark 强大的 DataFrame API。在 PySpark 中,DataFrame 是一种数据集的抽象,具有正则结构,其中每条记录是由一组列组成的行,每列具有明确定义的数据类型。你可以将 DataFrame 视为 Spark 中关系数据库表的类比。尽管命名惯例可能让你以为是 pandas.DataFrame 对象,但 Spark 的 DataFrames 是完全不同的东西。这是因为它们代表了集群上的分布式数据集,而不是本地数据,其中每一行数据都存储在同一台机器上。尽管在使用 DataFrames 和它们在 Spark 生态系统中扮演角色的方式上有些相似之处,但在使用 pandas 或 R 中处理数据框时习惯的一些方法在 Spark 中并不适用,因此最好将它们视为独立的实体,并以开放的心态去接触它们。

至于数据清洗,这是任何数据科学项目的第一步,通常也是最重要的一步。许多聪明的分析由于分析的数据存在根本性质量问题或潜在的伪造信息而未能实现。因此,介绍如何使用 PySpark 和 DataFrames 处理数据的最好方式,不是进行数据清洗练习吗?

首先,我们将介绍 PySpark 的基础知识,并使用来自加州大学尔湾分校机器学习库的样本数据集进行实践。我们将重申为什么 PySpark 是进行数据科学的良好选择,并介绍其编程模型。然后,我们将在我们的系统或集群上设置 PySpark,并使用 PySpark 的 DataFrame API 分析我们的数据集。在使用 PySpark 进行数据分析时,你将大部分时间集中在 DataFrame API 上,因此准备好对其进行深入了解。这将为我们进入后续章节并深入探讨各种机器学习算法做好准备。

对于执行数据科学任务,你不需要深入理解 Spark 在底层是如何工作的。然而,理解关于 Spark 架构的基本概念将使得在使用 PySpark 时更容易工作,并在编写代码时做出更好的决策。这就是我们将在下一节中讨论的内容。

当使用 DataFrame API 时,你的 PySpark 代码应该提供与 Scala 相当的性能。如果使用 UDF 或 RDD,将会影响性能。

Spark 架构

aaps 0201

图 2-1. Spark 架构图

图 2-1 通过高级组件展示了 Spark 架构。Spark 应用程序作为独立的进程集合在集群或本地上运行。在高层次上,一个 Spark 应用程序由驱动程序进程、集群管理器和一组执行器进程组成。驱动程序是中心组件,负责在执行器进程间分发任务。始终只有一个驱动程序进程。当我们谈论扩展性时,我们指的是增加执行器的数量。集群管理器简单地管理资源。

Spark 是一个分布式、数据并行的计算引擎。在数据并行模型中,更多的数据分区意味着更多的并行性。分区允许高效的并行处理。将数据分解成块或分区的分布式方案允许 Spark 执行器仅处理靠近它们的数据,从而最小化网络带宽。换句话说,每个执行器的核心被分配到自己的数据分区上进行处理。在涉及分区选择时,请记住这一点。

Spark 编程始于数据集,通常驻留在分布式持久存储中,如 Hadoop 分布式文件系统(HDFS)或云解决方案(例如 AWS S3),并以 Parquet 格式存储。编写 Spark 程序通常包括以下几个步骤:

  1. 定义一组对输入数据集的转换。

  2. 调用将转换后的数据集输出到持久存储或将结果返回到驱动程序本地内存的操作。这些操作理想情况下应由工作节点执行,如 图 2-1 右侧所示。

  3. 运行在分布式方式下计算的本地计算。这些计算可以帮助你决定接下来应采取哪些转换和操作。

重要的是要记住,PySpark 的所有高级抽象仍然依赖于自 Spark 诞生以来存在的相同哲学:存储和执行之间的相互作用。理解这些原则将帮助您更好地利用 Spark 进行数据分析。

接下来,我们将在我们的机器上安装和设置 PySpark,以便我们可以开始进行数据分析。这是一个一次性的操作,将帮助我们运行本章及后续章节的代码示例。

安装 PySpark

本书中的示例和代码假设您已经安装了 Spark 3.1.1。为了跟随代码示例,从 PyPI repository 安装 PySpark。

$ pip3 install pyspark

注意,PySpark 需要安装 Java 8 或更高版本。如果需要 SQL、ML 和/或 MLlib 作为额外的依赖项,这也是一个选择。我们稍后会需要这些。

$ pip3 install pyspark[sql,ml,mllib]

从 PyPI 安装会跳过运行 Scala、Java 或 R 所需的库。完整的发行版本可以从 Spark project site 获取。请参考 Spark documentation 来了解在集群或本地机器上设置 Spark 环境的说明。

现在我们准备启动 pyspark-shell,这是 Python 语言的 REPL,同时具有一些特定于 Spark 的扩展。这类似于您可能使用过的 Python 或 IPython shell。如果您只是在个人计算机上运行这些示例,可以通过指定 local[N] 来启动本地 Spark 集群,其中 N 是要运行的线程数,或者 * 来匹配计算机上可用的核心数。例如,要在八核机器上启动一个使用八个线程的本地集群:

$ pyspark --master local[*]

Spark 应用本身通常被称为 Spark 集群。这是一个逻辑抽象,不同于物理集群(多台机器)。

如果您有一个运行支持 YARN 的 Hadoop 集群,可以使用 yarn 作为 Spark 主节点的值在集群上启动 Spark 作业:

$ pyspark --master yarn --deploy-mode client

本书其余示例将不显示 --master 参数到 spark-shell,但您通常需要根据环境设置适当的参数。

您可能需要指定额外的参数以使 Spark shell 充分利用您的资源。通过执行 pyspark --help 可以找到参数列表。例如,在本地主节点运行 Spark 时,您可以使用 --driver-memory 2g 来让单个本地进程使用 2 GB 内存。YARN 内存配置更为复杂,相关选项如 --executor-memorySpark on YARN documentation 中有解释。

Spark 框架正式支持四种集群部署模式:独立模式、YARN、Kubernetes 和 Mesos。更多详细信息可以在 Deploying Spark documentation 中找到。

运行这些命令之一后,您将看到大量来自 Spark 的日志消息,因为它初始化自身,但您还应该看到一些 ASCII 艺术,随后是一些额外的日志消息和提示:

Python 3.6.12 |Anaconda, Inc.| (default, Sep  8 2020, 23:10:56)
[GCC 7.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /__ / .__/\_,_/_/ /_/\_\   version 3.0.1
      /_/

Using Python version 3.6.12 (default, Sep  8 2020 23:10:56)
SparkSession available as 'spark'.

在 shell 中可以运行:help命令。这会提示您启动交互式帮助模式或请求有关特定 Python 对象的帮助。除了关于:help的说明外,Spark 日志消息还指出“SparkSession 可用作 spark。”这是对SparkSession的引用,它作为所有 Spark 操作和数据的入口点。请继续在命令行输入spark

spark
...
<pyspark.sql.session.SparkSession object at DEADBEEF>

REPL 将打印对象的字符串形式。对于SparkSession对象,这只是它在内存中的名称加上十六进制地址。(DEADBEEF是一个占位符;您在这里看到的确切值会因运行而异。)在交互式 Spark shell 中,Spark 驱动程序会为您实例化一个 SparkSession,而在 Spark 应用程序中,您需要自己创建一个 SparkSession 对象。

在 Spark 2.0 中,SparkSession 成为了所有 Spark 操作和数据的统一入口点。以前使用的入口点如 SparkContext、SQLContext、HiveContext、SparkConf 和 StreamingContext 也可以通过它访问。

我们究竟如何处理spark变量?SparkSession是一个对象,因此它有与之关联的方法。我们可以在 PySpark shell 中通过输入变量名称,然后加上一个句点,然后加上 tab 键来查看这些方法是什么:

 spark.[\t]
...
spark.Builder(           spark.conf
spark.newSession(        spark.readStream
spark.stop(              spark.udf
spark.builder            spark.createDataFrame(
spark.range(             spark.sparkContext
spark.streams            spark.version
spark.catalog            spark.getActiveSession(
spark.read               spark.sql(
spark.table(

在 SparkSession 提供的所有方法中,我们将经常使用的是允许我们创建 DataFrames 的方法。现在我们已经设置好了 PySpark,我们可以设置我们感兴趣的数据集,并开始使用 PySpark 的 DataFrame API 与之交互。这是我们将在下一节中做的事情。

设置我们的数据

UC Irvine 机器学习存储库是一个非常好的资源,提供用于研究和教育的有趣(且免费)的数据集。我们将分析的数据集是从 2010 年在德国医院进行的一项记录链接研究中策划的,包含数百万对根据多种不同标准(如患者姓名(名和姓)、地址和生日)匹配的患者记录。每个匹配字段根据字符串相似性被分配了从 0.0 到 1.0 的数值分数,然后手动标记数据以识别哪些对表示同一人,哪些不是。用于创建数据集的字段的基础值已被删除,以保护患者的隐私。数值标识符、字段的匹配分数以及每对的标签(匹配与非匹配)已发布,供记录链接研究使用。

从 shell 中,让我们从存储库中拉取数据:

$ mkdir linkage
$ cd linkage/
$ curl -L -o donation.zip https://bit.ly/1Aoywaq
$ unzip donation.zip
$ unzip 'block_*.zip'

如果您有一个 Hadoop 集群方便的话,可以在 HDFS 中为块数据创建一个目录,并将数据集的文件复制到那里:

$ hadoop dfs -mkdir linkage
$ hadoop dfs -put block_*.csv linkage

要为我们的记录链接数据集创建一个 DataFrame,我们将使用S⁠p⁠a⁠r⁠k​S⁠e⁠s⁠s⁠i⁠o⁠n对象。具体来说,我们将在其 Reader API 上使用csv方法:

prev = spark.read.csv("linkage/block*.csv")
...
prev
...
DataFrame[_c0: string, _c1: string, _c2: string, _c3: string,...

默认情况下,CSV 文件中的每一列都被视为string类型,列名默认为_c0_c1_c2等。我们可以通过调用其show方法在 shell 中查看 DataFrame 的头部:

prev.show(2)
...
+-----+-----+------------+------------+------------+------------+-------+------+
|  _c0|  _c1|         _c2|         _c3|         _c4|         _c5|    _c6|   _c7|
+-----+-----+------------+------------+------------+------------+-------+------+
| id_1| id_2|cmp_fname_c1|cmp_fname_c2|cmp_lname_c1|cmp_lname_c2|cmp_sex|cmp_bd|
| 3148| 8326|           1|           ?|           1|           ?|      1|     1|
|14055|94934|           1|           ?|           1|           ?|      1|     1|
|33948|34740|           1|           ?|           1|           ?|      1|     1|
|  946|71870|           1|           ?|           1|           ?|      1|     1|

我们可以看到 DataFrame 的第一行是标头列的名称,正如我们所预期的那样,并且 CSV 文件已被干净地分割为其各个列。我们还可以看到一些列中存在?字符串;我们需要将这些处理为缺失值。除了正确命名每列之外,如果 Spark 能够正确推断每列的数据类型将是理想的。

幸运的是,Spark 的 CSV 读取器通过我们可以在 Reader API 上设置的选项为我们提供了所有这些功能。您可以在pyspark文档中看到 API 接受的完整选项列表。现在,我们将像这样读取和解析链接数据:

parsed = spark.read.option("header", "true").option("nullValue", "?").\
          option("inferSchema", "true").csv("linkage/block*.csv")

当我们在parsed数据上调用show时,我们看到列名已正确设置,并且?字符串已被null值替换。要查看每列的推断类型,我们可以像这样打印parsed DataFrame 的架构:

parsed.printSchema()
...
root
 |-- id_1: integer (nullable = true)
 |-- id_2: integer (nullable = true)
 |-- cmp_fname_c1: double (nullable = true)
 |-- cmp_fname_c2: double (nullable = true)
...

每个Column实例包含列的名称,能处理每条记录中数据类型的最具体数据类型,以及一个布尔字段,指示该列是否可以包含空值,默认为 true。为了执行架构推断,Spark 必须对数据集进行两次遍历:第一次遍历以确定每列的类型,第二次遍历执行实际的解析。如果需要,第一次遍历可以对样本进行处理。

如果您提前知道要为文件使用的架构,可以创建pyspark.sql.types.StructType类的实例,并通过schema函数将其传递给 Reader API。当数据集非常大时,这可以显著提高性能,因为 Spark 不需要再次遍历数据以确定每列的数据类型。

下面是使用StructTypeStructField定义架构的示例:

from pyspark.sql.types import *
schema = StructType([StructField("id_1", IntegerType(), False),
  StructField("id_2", StringType(), False),
  StructField("cmp_fname_c1", DoubleType(), False)])

spark.read.schema(schema).csv("...")

另一种定义架构的方法是使用 DDL(数据定义语言)语句:

schema = "id_1 INT, id_2 INT, cmp_fname_c1 DOUBLE"

DataFrames 具有多种方法,使我们能够将数据从集群读取到我们客户端机器上的 PySpark REPL 中。其中最简单的方法可能是first,它将 DataFrame 的第一个元素返回到客户端:

parsed.first()
...
Row(id_1=3148, id_2=8326, cmp_fname_c1=1.0, cmp_fname_c2=None,...

first方法对于对数据集进行健全性检查很有用,但我们通常对将 DataFrame 的较大样本带回客户端进行分析感兴趣。当我们知道一个 DataFrame 只包含少量记录时,我们可以使用toPandascollect方法将 DataFrame 的所有内容作为数组返回到客户端。对于非常大的 DataFrames,使用这些方法可能是危险的,并且可能导致内存不足的异常。因为我们还不知道链接数据集有多大,所以暂时不会这样做。

在接下来的几节中,我们将使用本地开发和测试以及集群计算的混合方式执行更多的数据处理和数据分析工作,但如果你需要花一点时间来沉浸在你刚刚进入的新的精彩世界中,我们当然会理解。

使用 DataFrame API 分析数据

DataFrame API 配备了一组强大的工具,这些工具对于习惯于 Python 和 SQL 的数据科学家可能会很熟悉。在本节中,我们将开始探索这些工具以及如何将其应用于记录链接数据。

如果我们查看parsed DataFrame 的模式和前几行数据,我们会看到:

parsed.printSchema()
...
root
 |-- id_1: integer (nullable = true)
 |-- id_2: integer (nullable = true)
 |-- cmp_fname_c1: double (nullable = true)
 |-- cmp_fname_c2: double (nullable = true)
 |-- cmp_lname_c1: double (nullable = true)
 |-- cmp_lname_c2: double (nullable = true)
 |-- cmp_sex: integer (nullable = true)
 |-- cmp_bd: integer (nullable = true)
 |-- cmp_bm: integer (nullable = true)
 |-- cmp_by: integer (nullable = true)
 |-- cmp_plz: integer (nullable = true)
 |-- is_match: boolean (nullable = true)

...

parsed.show(5)
...
+-----+-----+------------+------------+------------+------------+.....
| id_1| id_2|cmp_fname_c1|cmp_fname_c2|cmp_lname_c1|cmp_lname_c2|.....
+-----+-----+------------+------------+------------+------------+.....
| 3148| 8326|         1.0|        null|         1.0|        null|.....
|14055|94934|         1.0|        null|         1.0|        null|.....
|33948|34740|         1.0|        null|         1.0|        null|.....
|  946|71870|         1.0|        null|         1.0|        null|.....
|64880|71676|         1.0|        null|         1.0|        null|.....
  • 前两个字段是表示记录中匹配的患者的整数 ID。

  • 下面的九个字段是(可能缺失的)数字值(可以是 double 或 int),表示患者记录不同字段的匹配评分,比如他们的姓名、生日和位置。当字段的唯一可能值是匹配(1)或不匹配(0)时,字段存储为整数,而在可能存在部分匹配时存储为双精度数。

  • 最后一个字段是一个布尔值(truefalse),表示该行代表的患者记录对是否匹配。

我们的目标是制定一个简单的分类器,让我们能够根据患者记录的匹配评分的值来预测记录是否匹配。让我们通过count方法来了解我们要处理的记录数量的概念:

parsed.count()
...
5749132

这是一个相对较小的数据集—肯定足够小,可以在集群中的一个节点或者甚至在你的本地机器的内存中存储,如果你没有可用的集群。到目前为止,每当我们处理数据时,Spark 都会重新打开文件,重新解析行,然后执行请求的操作,比如显示数据的前几行或计算记录的数量。当我们提出另一个问题时,Spark 将再次执行这些操作,即使我们已经将数据筛选到少量记录中,或者正在使用原始数据集的聚合版本。

这并不是我们计算资源的最佳使用方式。数据解析完成后,我们希望将数据保存在集群上的解析形式,这样每次需要提出新问题时就不必重新解析。Spark 通过允许我们在实例上调用cache方法来信号化指定 DataFrame 应在生成后缓存在内存中来支持这种用例。现在让我们为parsed DataFrame 进行这样的操作:

parsed.cache()

数据缓存后,我们想知道的下一件事是记录的匹配与非匹配的相对比例:

from pyspark.sql.functions import col

parsed.groupBy("is_match").count().orderBy(col("count").desc()).show()
...
+--------+-------+
|is_match|  count|
+--------+-------+
|   false|5728201|
|    true|  20931|
+--------+-------+

我们不需要编写函数来提取is_match列,只需将其名称传递给 DataFrame 的groupBy方法,调用count方法来计算每个分组内的记录数量,根据count列按降序排序,然后使用show在 REPL 中清晰地呈现计算结果。在幕后,Spark 引擎确定执行聚合并返回结果的最有效方法。这展示了 Spark 提供的进行数据分析的干净、快速和表达方式。

请注意,我们可以有两种方式引用 DataFrame 中列的名称:一种是作为字面字符串,例如groupBy("is_match")中的用法,另一种是通过使用我们在count列上使用的col函数获取的Column对象。在大多数情况下,这两种方法都是有效的,但我们需要使用col函数调用结果count列对象上的desc方法。

你可能已经注意到,DataFrame API 中的函数与 SQL 查询的组件相似。这不是巧合,事实上,我们可以选择将我们创建的任何 DataFrame 视为数据库表,并使用熟悉和强大的 SQL 语法来表达我们的问题。首先,我们需要告诉 Spark SQL 执行引擎应将parsed DataFrame 关联的名称,因为变量名称本身("parsed")对于 Spark 是不可用的:

parsed.createOrReplaceTempView("linkage")

因为parsed DataFrame 仅在此 PySpark REPL 会话期间可用,它是一个临时表。如果我们配置 Spark 连接到跟踪结构化数据集架构和位置的 Apache Hive 元存储,Spark SQL 也可以用于查询 HDFS 中的持久表。

一旦我们的临时表在 Spark SQL 引擎中注册,我们可以像这样查询它:

spark.sql("""
 SELECT is_match, COUNT(*) cnt
 FROM linkage
 GROUP BY is_match
 ORDER BY cnt DESC
""").show()
...
+--------+-------+
|is_match|    cnt|
+--------+-------+
|   false|5728201|
|    true|  20931|
+--------+-------+

您可以选择通过调用enableHiveSupport方法在创建SparkSession实例时使用 ANSI 2003 兼容版本的 Spark SQL(默认方式)或在 HiveQL 模式下运行 Spark。

在 PySpark 中,您应该使用 Spark SQL 还是 DataFrame API 来进行分析呢?每种方法都有其利弊:SQL 的优点在于它广泛被认知,并且对于简单查询而言表达能力强。它还允许您使用 JDBC/ODBC 连接器从诸如 PostgreSQL 或像 Tableau 这样的工具中查询数据。然而,SQL 的缺点在于,在动态、可读且可测试的方式下表达复杂的多阶段分析可能会很困难——而 DataFrame API 在这些方面表现出色。在本书的其余部分中,我们既使用 Spark SQL 又使用 DataFrame API,并留给读者作为一个练习来审视我们所做的选择,并将我们的计算从一种接口转换到另一种接口。

我们可以逐个将函数应用于我们的 DataFrame,以获取诸如计数和平均值之类的统计数据。然而,PySpark 提供了一种更好的方法来获取 DataFrames 的汇总统计数据,这就是我们将在下一节中讨论的内容。

DataFrames 的快速汇总统计

尽管有许多种类的分析可以在 SQL 或 DataFrame API 中同样有效地表达,但有些常见的数据框架操作在 SQL 中表达起来可能很乏味。其中一种特别有帮助的分析是计算数据框架数值列中所有非空值的最小值、最大值、平均值和标准差。在 PySpark 中,这个函数与 pandas 中的函数同名,即 describe

summary = parsed.describe()
...
summary.show()

summary DataFrame 拥有 parsed DataFrame 中每个变量的一列,以及另一列(也称为 summary),指示其余列中的哪个指标——countmeanstddevminmax——存在。我们可以使用 select 方法选择列的子集,以便更容易阅读和比较汇总统计信息:

summary.select("summary", "cmp_fname_c1", "cmp_fname_c2").show()
+-------+------------------+------------------+
|summary|      cmp_fname_c1|      cmp_fname_c2|
+-------+------------------+------------------+
|  count|           5748125|            103698|
|   mean|0.7129024704436274|0.9000176718903216|
| stddev|0.3887583596162788|0.2713176105782331|
|    min|               0.0|               0.0|
|    max|               1.0|               1.0|
+-------+------------------+------------------+

注意 count 变量在 cmp_fname_c1cmp_fname_c2 之间的值的差异。几乎每条记录的 cmp_fname_c1 都有非空值,而仅不到 2% 的记录有 cmp_fname_c2 的非空值。要创建一个有用的分类器,我们需要依赖几乎总是出现在数据中的变量——除非它们的缺失反映出记录是否匹配的有意义信息。

一旦我们对数据中变量的分布有了整体的了解,我们希望了解这些变量的值如何与 is_match 列的值相关联。因此,我们的下一步是仅针对与匹配和非匹配对应的 parsed DataFrame 子集计算相同的汇总统计数据。我们可以使用类似 SQL 的 where 语法或使用 DataFrame API 中的 Column 对象来过滤 DataFrames,然后在结果 DataFrames 上使用 describe

matches = parsed.where("is_match = true")
match_summary = matches.describe()

misses = parsed.filter(col("is_match") == False)
miss_summary = misses.describe()

我们传递给 where 函数的字符串内部逻辑可以包含在 Spark SQL 中的 WHERE 子句中有效的语句。对于使用 DataFrame API 的过滤条件,我们使用 == 运算符来检查 is_match 列对象与布尔对象 False 是否相等,因为这只是 Python,而不是 SQL。请注意,where 函数是 filter 函数的别名;我们可以在上述片段中颠倒 wherefilter 的调用顺序,一切仍将正常工作。

现在我们可以开始比较 match_summarymiss_summary DataFrame,以查看变量分布如何随记录是匹配还是未匹配而变化。尽管这是一个相对较小的数据集,进行这种比较仍然有些繁琐——我们真正想要的是转置 match_summarymiss_summary DataFrame,以便行和列被交换,这将允许我们通过变量连接转置的 DataFrame 并分析汇总统计信息,这是大多数数据科学家所知的“透视”或“重塑”数据集的实践。在下一节中,我们将展示如何执行这些转换。

数据透视和重塑 DataFrame

我们可以使用 PySpark 提供的函数完全转置 DataFrames。但是,还有一种执行此任务的方法。 PySpark 允许在 Spark 和 pandas DataFrames 之间进行转换。我们将问题中的 DataFrames 转换为 pandas DataFrames,重塑它们,然后将它们转换回 Spark DataFrames。由于 summarymatch_summarymiss_summary DataFrames 的大小较小,因此我们可以安全地执行此操作,因为 pandas DataFrames 位于内存中。在接下来的章节中,我们将依靠 Spark 操作来处理较大数据集上的这些转换。

由于 Apache Arrow 项目的存在,允许在 JVM 和 Python 进程之间高效传输数据,所以可以进行 Spark 和 pandas DataFrames 之间的转换。当我们使用 pip 安装 pyspark[sql] 时,PyArrow 库作为 Spark SQL 模块的依赖项被安装。

让我们将 summary 转换为 pandas DataFrame:

summary_p = summary.toPandas()

现在我们可以在 summary_p DataFrame 上使用 pandas 函数:

summary_p.head()
...
summary_p.shape
...
(5,12)

现在,我们可以使用 DataFrame 上熟悉的 pandas 方法执行转置操作,以交换行和列:

summary_p = summary_p.set_index('summary').transpose().reset_index()
...
summary_p = summary_p.rename(columns={'index':'field'})
...
summary_p = summary_p.rename_axis(None, axis=1)
...
summary_p.shape
...
(11,6)

我们已成功转置了 summary_p pandas DataFrame。使用 SparkSession 的 createDataFrame 方法将其转换为 Spark DataFrame:

summaryT = spark.createDataFrame(summary_p)
...
summaryT.show()
...
+------------+-------+-------------------+-------------------+---+------+
|       field|  count|               mean|             stddev|min|   max|
+------------+-------+-------------------+-------------------+---+------+
|        id_1|5749132|  33324.48559643438| 23659.859374488064|  1| 99980|
|        id_2|5749132|  66587.43558331935| 23620.487613269695|  6|100000|
|cmp_fname_c1|5748125| 0.7129024704437266|0.38875835961628014|0.0|   1.0|
|cmp_fname_c2| 103698| 0.9000176718903189| 0.2713176105782334|0.0|   1.0|
|cmp_lname_c1|5749132| 0.3156278193080383| 0.3342336339615828|0.0|   1.0|
|cmp_lname_c2|   2464| 0.3184128315317443|0.36856706620066537|0.0|   1.0|
|     cmp_sex|5749132|  0.955001381078048|0.20730111116897781|  0|     1|
|      cmp_bd|5748337|0.22446526708507172|0.41722972238462636|  0|     1|
|      cmp_bm|5748337|0.48885529849763504| 0.4998758236779031|  0|     1|
|      cmp_by|5748337| 0.2227485966810923| 0.4160909629831756|  0|     1|
|     cmp_plz|5736289|0.00552866147434343|0.07414914925420046|  0|     1|
+------------+-------+-------------------+-------------------+---+------+

我们还没有完成。打印 summaryT DataFrame 的模式:

summaryT.printSchema()
...
root
 |-- field: string (nullable = true)
 |-- count: string (nullable = true)
 |-- mean: string (nullable = true)
 |-- stddev: string (nullable = true)
 |-- min: string (nullable = true)
 |-- max: string (nullable = true)

在从 describe 方法获取的汇总模式中,每个字段都被视为字符串。由于我们希望将汇总统计信息作为数字进行分析,因此需要将值从字符串转换为双精度数:

from pyspark.sql.types import DoubleType
for c in summaryT.columns:
  if c == 'field':
    continue
  summaryT = summaryT.withColumn(c, summaryT[c].cast(DoubleType()))
...
summaryT.printSchema()
...
root
 |-- field: string (nullable = true)
 |-- count: double (nullable = true)
 |-- mean: double (nullable = true)
 |-- stddev: double (nullable = true)
 |-- min: double (nullable = true)
 |-- max: double (nullable = true)

现在我们已经找到了如何转置汇总 DataFrame 的方法,让我们将我们的逻辑实现为一个函数,我们可以在 match_summarymiss_summary DataFrame 上重复使用:

from pyspark.sql import DataFrame
from pyspark.sql.types import DoubleType

def pivot_summary(desc):
  # convert to pandas dataframe
  desc_p = desc.toPandas()
  # transpose
  desc_p = desc_p.set_index('summary').transpose().reset_index()
  desc_p = desc_p.rename(columns={'index':'field'})
  desc_p = desc_p.rename_axis(None, axis=1)
  # convert to Spark dataframe
  descT = spark.createDataFrame(desc_p)
  # convert metric columns to double from string
  for c in descT.columns:
    if c == 'field':
      continue
    else:
      descT = descT.withColumn(c, descT[c].cast(DoubleType()))
  return descT

现在在你的 Spark shell 中,对 match_summarymiss_summary DataFrames 使用 pivot_summary 函数:

match_summaryT = pivot_summary(match_summary)
miss_summaryT = pivot_summary(miss_summary)

现在我们已经成功地转置了汇总的 DataFrames,我们可以连接并比较它们。这就是我们将在下一节中做的事情。此外,我们还将选择适合建立模型的理想特征。

连接 DataFrames 和选择特征

到目前为止,我们仅使用 Spark SQL 和 DataFrame API 进行数据集的过滤和聚合,但我们也可以使用这些工具在 DataFrames 上执行连接操作(内连接、左连接、右连接或全连接)。虽然 DataFrame API 包括一个 join 函数,但通常使用 Spark SQL 更容易表达这些连接操作,特别是当我们要连接的表有很多列名相同时,并且我们希望能够清楚地指示在选择表达式中正在引用哪个列时。让我们为 match_summaryTmiss_summaryT DataFrames 创建临时视图,在 field 列上进行连接,并对结果行计算一些简单的汇总统计信息:

match_summaryT.createOrReplaceTempView("match_desc")
miss_summaryT.createOrReplaceTempView("miss_desc")
spark.sql("""
 SELECT a.field, a.count + b.count total, a.mean - b.mean delta
 FROM match_desc a INNER JOIN miss_desc b ON a.field = b.field
 WHERE a.field NOT IN ("id_1", "id_2")
 ORDER BY delta DESC, total DESC
""").show()
...
+------------+---------+--------------------+
|       field|    total|               delta|
+------------+---------+--------------------+
|     cmp_plz|5736289.0|  0.9563812499852176|
|cmp_lname_c2|   2464.0|  0.8064147192926264|
|      cmp_by|5748337.0|  0.7762059675300512|
|      cmp_bd|5748337.0|   0.775442311783404|
|cmp_lname_c1|5749132.0|  0.6838772482590526|
|      cmp_bm|5748337.0|  0.5109496938298685|
|cmp_fname_c1|5748125.0|  0.2854529057460786|
|cmp_fname_c2| 103698.0| 0.09104268062280008|
|     cmp_sex|5749132.0|0.032408185250332844|
+------------+---------+--------------------+

一个好的特征具有两个特性:它倾向于在匹配和非匹配情况下具有显著不同的值(因此平均值之间的差异将很大),并且在数据中经常出现,我们可以依赖它定期出现在任何一对记录中。按此标准,cmp_fname_c2 并不是非常有用,因为它大部分时间都缺失,并且匹配和非匹配的平均值之间的差异相对较小——0.09,对于一个从 0 到 1 的分数来说。cmp_sex 特征也不是特别有帮助,因为即使它对于任何一对记录都是可用的,平均值之间的差异仅为 0.03。

另一方面,cmp_plzcmp_by 特征则非常优秀。它们几乎总是出现在任何一对记录中,并且平均值之间的差异非常大(这两个特征都超过了 0.77)。cmp_bdcmp_lname_c1cmp_bm 特征似乎也是有益的:它们通常在数据集中可用,并且匹配和非匹配的平均值之间的差异很大。

cmp_fname_c1cmp_lname_c2 特征则有些复杂:cmp_fname_c1 并不能很好地区分(平均值之间的差异仅为 0.28),即使它通常对于一对记录来说是可用的,而 cmp_lname_c2 在平均值之间有很大的差异,但几乎总是缺失。根据这些数据,不太明显在什么情况下应该在我们的模型中包含这些特征。

目前,我们将使用一个简单的评分模型,根据显然良好特征的值之和对记录对的相似性进行排名:cmp_plzcmp_bycmp_bdcmp_lname_c1cmp_bm。对于这些特征值缺失的少数记录,我们将在求和中使用 0 替代null值。通过创建计算得分和is_match列的 DataFrame,我们可以大致了解我们简单模型的性能,并评估得分在各种阈值下如何区分匹配和非匹配。

评分和模型评估

对于我们的评分函数,我们将对五个字段(cmp_lname_c1cmp_plzcmp_bycmp_bdcmp_bm)的值进行求和。我们将使用pyspark.sql.functions中的expr来实现这一点。expr函数将输入的表达式字符串解析成对应的列。这个字符串甚至可以涉及多个列。

让我们创建所需的表达式字符串:

good_features = ["cmp_lname_c1", "cmp_plz", "cmp_by", "cmp_bd", "cmp_bm"]
...
sum_expression = " + ".join(good_features)
...
sum_expression
...
'cmp_lname_c1 + cmp_plz + cmp_by + cmp_bd + cmp_bm'

现在,我们可以使用sum_expression字符串来计算分数。在对值进行求和时,我们将使用 DataFrame 的fillna方法考虑并替换为 0 的空值:

from pyspark.sql.functions import expr
scored = parsed.fillna(0, subset=good_features).\
                withColumn('score', expr(sum_expression)).\
                select('score', 'is_match')
...
scored.show()
...
+-----+--------+
|score|is_match|
+-----+--------+
|  5.0|    true|
|  5.0|    true|
|  5.0|    true|
|  5.0|    true|
|  5.0|    true|
|  5.0|    true|
|  4.0|    true|
...

创建评分函数的最后一步是决定分数必须超过什么阈值,以便我们预测两个记录表示匹配。如果我们设置的阈值过高,那么我们将错误地将匹配记录标记为错过(称为假阴性率),而如果我们将阈值设置得太低,我们将错误地将错过标记为匹配(假阳性率)。对于任何非平凡的问题,我们总是需要在两种错误类型之间进行某种权衡,阈值值应该是多少的问题通常取决于模型应用的情况中两种错误类型的相对成本。

为了帮助我们选择一个阈值,创建一个列联表(有时称为交叉表crosstab),计算分数高于/低于阈值的记录数,并将这些类别中记录数与每个类别中的匹配/非匹配数量交叉。因为我们还不知道将使用什么阈值,让我们编写一个函数,该函数接受scored DataFrame 和阈值选择作为参数,并使用 DataFrame API 计算交叉表:

def crossTabs(scored: DataFrame, t: DoubleType) -> DataFrame:
  return  scored.selectExpr(f"score >= {t} as above", "is_match").\
          groupBy("above").pivot("is_match", ("true", "false")).\
          count()

注意,我们在 DataFrame API 中包括了selectExpr方法,根据t参数的值使用 Python 的 f-string 格式化语法动态确定名为above的字段的值,这使我们能够按名称替换变量,如果我们在字符串文字前加上字母f(这是另一个 Scala 隐式魔法的方便部分)。一旦定义了above字段,我们就使用之前使用的groupBypivotcount方法创建交叉表。

通过应用高阈值值为 4.0,意味着五个特征的平均值为 0.8,我们可以筛选掉几乎所有非匹配项,同时保留超过 90%的匹配项:

crossTabs(scored, 4.0).show()
...
+-----+-----+-------+
|above| true|  false|
+-----+-----+-------+
| true|20871|    637|
|false|   60|5727564|
+-----+-----+-------+

通过应用较低的阈值 2.0,我们可以确保捕捉到所有已知的匹配记录,但在假阳性方面会付出很大的代价(右上角的单元格):

crossTabs(scored, 2.0).show()
...
+-----+-----+-------+
|above| true|  false|
+-----+-----+-------+
| true|20931| 596414|
|false| null|5131787|
+-----+-----+-------+

尽管假阳性的数量高于我们的期望,这种更宽松的筛选仍然从我们的考虑中移除了 90%的非匹配记录,同时包含每一个正匹配。虽然这已经相当不错了,但还有可能做得更好;看看是否能找到一种方法来利用MatchData的其他值(包括缺失和非缺失的值)来设计一个评分函数,以成功识别每一个true匹配,而成本低于 100 个假阳性。

如何继续前进

如果本章是您首次使用 PySpark 进行数据准备和分析,我们希望您能感受到这些工具提供的强大基础。如果您已经使用 Python 和 Spark 一段时间了,我们希望您能把本章推荐给您的朋友和同事,作为向他们介绍这种强大力量的一种方式。

本章的目标是为您提供足够的知识,以便能够理解并完成本书中其余示例。如果您是通过实际示例学习最佳的人,那么您的下一步是继续学习下一组章节,我们将在那里向您介绍 MLlib,这是专为 Spark 设计的机器学习库。

第三章:推荐音乐与 Audioscrobbler 数据集

推荐引擎是大规模机器学习的最受欢迎的示例之一;例如,大多数人都熟悉亚马逊的推荐系统。推荐引擎是一个共同点,因为它们无处不在,从社交网络到视频站点再到在线零售商。我们也可以直接观察它们的运行。我们知道 Spotify 在选择播放曲目时是由计算机决定的,就像我们并不一定注意到 Gmail 在判断传入邮件是否是垃圾邮件时的操作一样。

推荐系统的输出比其他机器学习算法更直观易懂,甚至有些令人兴奋。尽管我们认为音乐品味是个人的、难以解释的,推荐系统却出奇地能够准确识别我们可能会喜欢的音乐曲目。在像音乐或电影这样的领域,推荐系统通常能够相对容易地解释为什么推荐某首音乐与某人的收听历史相符。并非所有的聚类或分类算法都能满足这一描述。例如,支持向量机分类器是一组系数,即使对于从业者来说,在进行预测时这些数字代表的含义也很难表达清楚。

在接下来的三章中,我们将探讨 PySpark 上的关键机器学习算法,其中第一章围绕推荐引擎展开,特别是音乐推荐。这是一个介绍 PySpark 和 MLlib 实际应用的方式,同时也会涉及到随后章节中发展的一些基础机器学习概念。

在本章中,我们将在 PySpark 中实现一个推荐系统。具体来说,我们将使用交替最小二乘(ALS)算法处理由音乐流媒体服务提供的开放数据集。我们将首先了解数据集并在 PySpark 中导入它。然后,我们将讨论选择 ALS 算法的动机以及在 PySpark 中的实现。接着是数据准备和使用 PySpark 构建模型。最后,我们将提供一些用户推荐,并讨论通过超参数选择改进我们的模型的方法。

数据设置

我们将使用由 Audioscrobbler 发布的数据集。Audioscrobbler 是 Last.fm 的第一个音乐推荐系统,也是最早的互联网流媒体广播站之一,成立于 2002 年。Audioscrobbler 提供了一个开放的 API 用于“scrobbling”,即记录听众的歌曲播放情况。Last.fm 利用这些信息构建了一个强大的音乐推荐引擎。由于第三方应用和网站可以将收听数据返回给推荐引擎,该系统达到了数百万用户。

当时,关于推荐引擎的研究大多限于从类似评分的数据中学习。也就是说,推荐系统通常被视为在输入数据上运行的工具,如“Bob 对 Prince 的歌曲评级为 3.5 星”。Audioscrobbler 数据集很有趣,因为它仅记录了播放事件:“Bob 播放了一首 Prince 的歌曲”。播放事件比评级事件包含的信息更少。仅因为 Bob 播放了这首歌,并不意味着他实际上喜欢它。你我可能偶尔会播放一首不喜欢的歌曲,甚至播放整张专辑然后离开房间。

然而,听众评价音乐的频率远低于播放音乐的频率。因此,这样的数据集要大得多,涵盖的用户和艺术家更多,包含的总信息也更多,即使每个个体数据点的信息量较少。这种类型的数据通常被称为隐式反馈数据,因为用户与艺术家的连接是作为其他行为的副作用而暗示的,而不是作为显式评分或赞赏给出的。

可以在在线压缩存档中找到 Last.fm 在 2005 年发布的数据集快照。下载该存档,并在其中找到几个文件。首先,需要将数据集的文件可用化。如果您使用远程集群,请将所有三个数据文件复制到存储中。本章将假定这些文件在data/目录下可用。

启动pyspark-shell。请注意,本章中的计算将比简单的应用程序占用更多的内存。例如,如果您在本地而不是在集群上运行,可能需要指定类似--driver-memory 4g的参数来确保有足够的内存完成这些计算。主要数据集位于user_artist_data.txt文件中。该文件包含约 141,000 个唯一用户和 1.6 百万个唯一艺术家。记录了约 2420 万个用户对艺术家的播放次数及其计数。让我们将这个数据集读入 DataFrame 并查看一下:

raw_user_artist_path = "data/audioscrobbler_data/user_artist_data.txt"
raw_user_artist_data = spark.read.text(raw_user_artist_path)

raw_user_artist_data.show(5)

...
+-------------------+
|              value|
+-------------------+
|       1000002 1 55|
| 1000002 1000006 33|
|  1000002 1000007 8|
|1000002 1000009 144|
|1000002 1000010 314|
+-------------------+

像 ALS 这样的机器学习任务可能比简单的文本处理更需要计算资源。最好将数据分成更小的片段,即更多的分区进行处理。您可以在读取文本文件后链式调用.repartition(n)来指定不同且更大的分区数。例如,可以将此值设置为与集群中的核心数相匹配。

数据集还在artist_data.txt文件中按 ID 提供了每位艺术家的名称。请注意,在提交播放时,客户端应用程序会提交正在播放的艺术家的名称。这个名称可能拼写错误或非标准,这可能在稍后才会被发现。例如,“The Smiths”,“Smiths, The”和“the smiths”可能会作为数据集中不同的艺术家 ID 出现,尽管它们实际上是同一位艺术家。因此,数据集还包括artist_alias.txt,它将已知的拼写错误或变体的艺术家 ID 映射到该艺术家的规范 ID 上。让我们也将这两个数据集读入 PySpark:

raw_artist_data = spark.read.text("data/audioscrobbler_data/artist_data.txt")

raw_artist_data.show(5)

...
+--------------------+
|               value|
+--------------------+
|1134999\t06Crazy ...|
|6821360\tPang Nak...|
|10113088\tTerfel,...|
|10151459\tThe Fla...|
|6826647\tBodensta...|
+--------------------+
only showing top 5 rows
...

raw_artist_alias = spark.read.text("data/audioscrobbler_data/artist_alias.txt")

raw_artist_alias.show(5)

...
+-----------------+
|            value|
+-----------------+
| 1092764\t1000311|
| 1095122\t1000557|
| 6708070\t1007267|
|10088054\t1042317|
| 1195917\t1042317|
+-----------------+
only showing top 5 rows

现在我们对数据集有了基本的理解,我们可以讨论我们对推荐算法的需求,并且随后理解为什么交替最小二乘算法是一个好选择。

我们对推荐系统的需求

我们需要选择一个适合我们数据的推荐算法。以下是我们的考虑:

隐式反馈

这些数据完全由用户和艺术家歌曲之间的互动组成。除了它们的名称,它没有任何关于用户或艺术家的信息。我们需要一种能够在没有用户或艺术家属性访问权限的情况下学习的算法。这些通常被称为协同过滤算法。例如,决定两个用户可能有相似品味是因为他们同岁不是协同过滤的例子。决定两个用户可能喜欢同一首歌是因为他们播放了许多其他相同的歌协同过滤的例子。

稀疏性

我们的数据集看起来很大,因为它包含数千万次播放。但从另一个角度来看,它是稀疏的和不足的,因为它是稀疏的。平均而言,每个用户只听了大约 171 位艺术家的歌曲 —— 而这些艺术家共有 160 万。有些用户只听过一个艺术家的歌曲。我们需要一种算法,即使对这些用户也能提供良好的推荐。毕竟,每个单独的听众最初肯定是从一个播放开始的!

可扩展性和实时预测

最后,我们需要一个能够扩展的算法,无论是建立大型模型还是快速生成推荐。推荐通常需要在几乎实时内提供 —— 在一秒钟内,而不是明天。

一类可能适合的算法是潜在因子模型。它们试图通过相对较少数量的未观察到的潜在原因来解释大量用户和项目之间的观察到的互动。例如,考虑一个客户购买了金属乐队 Megadeth 和 Pantera 的专辑,但也购买了古典作曲家莫扎特的专辑。可能很难解释为什么只有这些专辑被购买而没有其他的。然而,这可能只是更大音乐品味集合中的一个小窗口。也许这个客户喜欢从金属到前卫摇滚再到古典的连贯音乐光谱。这种解释更简单,并且额外地建议了许多其他可能感兴趣的专辑。在这个例子中,“喜欢金属、前卫摇滚和古典音乐”是三个潜在因子,可以解释成千上万个个别专辑的偏好。

在我们的案例中,我们将专门使用一种类型的矩阵因子分解模型。从数学上讲,这些算法将用户和产品数据视为一个大矩阵 A,如果用户 i 玩了艺术家 j,则在第 i 行和第 j 列的条目存在。A 是稀疏的:大多数 A 的条目为 0,因为实际数据中只有少数可能的用户-艺术家组合出现。它们将 A 分解为两个较小矩阵 XY 的矩阵乘积。它们非常瘦长—都有很多行,因为 A 有很多行和列,但是两者都只有少数列(k)。k 列对应用于解释交互数据的潜在因子。

由于k较小,因此因子分解只能是近似的,如图 3-1 所示。

aaps 0301

图 3-1. 矩阵因子分解

这些算法有时被称为矩阵补全算法,因为原始矩阵 A 可能非常稀疏,但乘积 XY^(T) 是密集的。很少或没有条目为 0,因此模型只是 A 的近似。从模型的角度来看,它产生(“补全”)了即使在原始 A 中缺少的(即 0 的)条目的值。

这是一个例子,令人高兴的是,线性代数直接而优雅地映射到直觉中。这两个矩阵包含每个用户和每个艺术家的一行。这些行具有很少的值—k。每个值对应模型中的一个潜在特征。因此,这些行表达了用户和艺术家与这些k个潜在特征的关联程度,这些特征可能对应于品味或流派。简单地说,用户特征和特征-艺术家矩阵的乘积产生了整个稠密的用户-艺术家互动矩阵的完整估计。可以将这个乘积视为将项目映射到它们的属性,然后按用户属性加权的过程。

坏消息是,通常情况下 A = XY^(T) 没有确切的解,因为 XY 不够大(严格来说是太低的rank)以完美地表示 A。这实际上是件好事。A 只是所有可能互动的一个微小样本。在某种程度上,我们认为 A 是一个极其零星且因此难以解释的简单潜在现实的视角,只需要几个因子 k 就能很好地解释。想象一下描绘猫的拼图。最终的拼图描述很简单:一只猫。然而,当你手里只有几片时,你看到的图片却很难描述。

XY^(T) 应尽可能接近 A。毕竟,这是我们唯一的线索。它不会也不应该完全复制它。再次的坏消息是,不能直接求解得到最佳的 XY。好消息是,如果 Y 已知,那么求解最佳的 X 是微不足道的,反之亦然。但是事先都不知道!

幸运的是,有些算法可以摆脱这种困境并找到一个合理的解决方案。PySpark 中提供的一种算法就是 ALS 算法。

交替最小二乘算法

我们将使用交替最小二乘算法从我们的数据集中计算潜在因子。这种方法在 Netflix Prize 竞赛时期被如《“面向隐性反馈数据集的协同过滤”》和《“Netflix Prize 的大规模并行协同过滤”》等论文广泛应用。PySpark MLlib 的 ALS 实现借鉴了这些论文的思想,并且是目前 Spark MLlib 中唯一实现的推荐算法。

这里有一个代码片段(非功能性),让你一窥后面章节的内容:

from pyspark.ml.recommendation import ALS

als = ALS(maxIter=5, regParam=0.01, userCol="user",
          itemCol="artist", ratingCol="count")
model = als.fit(train)

predictions = model.transform(test)

使用 ALS,我们将把输入数据视为一个大型稀疏矩阵A,并找出XY,如前面讨论的那样。起初,Y是未知的,但可以初始化为一个填满随机选定行向量的矩阵。然后,简单的线性代数给出了在给定AY的情况下最佳解决方案的方法。事实上,可以分别计算X的每一行i作为YA的一行的函数。因为可以分别完成,所以可以并行进行,这对大规模计算是一个极好的特性:

AiY(YTY)–1 = Xi

无法完全实现等式,因此实际上目标是最小化|A[i]Y(Y(*T*)*Y*)(–1) – X[i]|,或两个矩阵条目之间平方差的总和。这就是名称中“最小二乘”的含义。实际上,这永远不会通过计算逆解决,而是更快、更直接地通过 QR 分解等方法。该方程简单地阐述了计算行向量的理论。

可以用相同的方法计算每个Y[j]从X中得到。再次,从Y计算X,依此类推。这就是“交替”的来源。这里只有一个小问题:Y是虚构的——随机的!X计算得最优,没错,但对Y给出了一个虚假的解。幸运的是,如果这个过程重复进行,XY最终会收敛到合理的解决方案。

当用于分解表示隐式数据的矩阵时,ALS 分解会更加复杂。它不是直接分解输入矩阵A,而是一个包含 0 和 1 的矩阵P,其中A包含正值的地方为 1,其他地方为 0。后续会将A中的值作为权重加入。这些细节超出了本书的范围,但并不影响理解如何使用该算法。

最后,ALS 算法还可以利用输入数据的稀疏性。这一点,以及它依赖简单优化的线性代数和数据并行性质,使得它在大规模上非常快速。

接下来,我们将预处理我们的数据集,使其适合与 ALS 算法一起使用。

数据准备

构建模型的第一步是了解可用的数据,并将其解析或转换为在 Spark 中进行分析时有用的形式。

Spark MLlib 的 ALS 实现在用户和项目的 ID 不需要严格为数字时也可以使用,但当 ID 确实可表示为 32 位整数时更有效。这是因为在底层数据使用 JVM 的数据类型表示。这个数据集已经符合这个要求了吗?

raw_user_artist_data.show(10)

...
+-------------------+
|              value|
+-------------------+
|       1000002 1 55|
| 1000002 1000006 33|
|  1000002 1000007 8|
|1000002 1000009 144|
|1000002 1000010 314|
|  1000002 1000013 8|
| 1000002 1000014 42|
| 1000002 1000017 69|
|1000002 1000024 329|
|  1000002 1000025 1|
+-------------------+

文件的每一行包含一个用户 ID、一个艺术家 ID 和一个播放计数,由空格分隔。为了对用户 ID 进行统计,我们通过空格字符拆分行并将值解析为整数。结果在概念上有三个“列”:一个用户 ID,一个艺术家 ID 和一个整数计数。将其转换为具有“user”、“artist”和“count”列的数据框是有意义的,因为这样可以简单地计算最大值和最小值:

from pyspark.sql.functions import split, min, max
from pyspark.sql.types import IntegerType, StringType

user_artist_df = raw_user_artist_data.withColumn('user',
                                    split(raw_user_artist_data['value'], ' ').\
                                    getItem(0).\
                                    cast(IntegerType()))
user_artist_df = user_artist_df.withColumn('artist',
                                    split(raw_user_artist_data['value'], ' ').\
                                    getItem(1).\
                                    cast(IntegerType()))
user_artist_df = user_artist_df.withColumn('count',
                                    split(raw_user_artist_data['value'], ' ').\
                                    getItem(2).\
                                    cast(IntegerType())).drop('value')

user_artist_df.select([min("user"), max("user"), min("artist"),\
                                    max("artist")]).show()
...
+---------+---------+-----------+-----------+
|min(user)|max(user)|min(artist)|max(artist)|
+---------+---------+-----------+-----------+
|       90|  2443548|          1|   10794401|
+---------+---------+-----------+-----------+

最大的用户和艺术家 ID 分别为 2443548 和 10794401(它们的最小值分别为 90 和 1;没有负值)。这些值远远小于 2147483647。在使用这些 ID 时不需要进行额外的转换。

在本示例的后续过程中了解与不透明数字 ID 对应的艺术家名称将会很有用。raw_artist_data 包含以制表符分隔的艺术家 ID 和名称。PySpark 的 split 函数接受 pattern 参数的正则表达式值。我们可以使用空白字符 \s 进行拆分:

from pyspark.sql.functions import col

artist_by_id = raw_artist_data.withColumn('id', split(col('value'), '\s+', 2).\
                                                getItem(0).\
                                                cast(IntegerType()))
artist_by_id = artist_by_id.withColumn('name', split(col('value'), '\s+', 2).\
                                               getItem(1).\
                                               cast(StringType())).drop('value')

artist_by_id.show(5)
...
+--------+--------------------+
|      id|                name|
+--------+--------------------+
| 1134999|        06Crazy Life|
| 6821360|        Pang Nakarin|
|10113088|Terfel, Bartoli- ...|
|10151459| The Flaming Sidebur|
| 6826647|   Bodenstandig 3000|
+--------+--------------------+

这导致一个数据框,其艺术家 ID 和名称作为列 idname

raw_artist_alias将可能拼写错误或非标准的艺术家 ID 映射到艺术家规范名称的 ID。这个数据集相对较小,大约有 20 万条目。每行包含两个 ID,由制表符分隔。我们将以类似的方式解析这个数据集,就像我们对raw_artist_data所做的那样:

artist_alias = raw_artist_alias.withColumn('artist',
                                          split(col('value'), '\s+').\
                                                getItem(0).\
                                                cast(IntegerType())).\
                                withColumn('alias',
                                            split(col('value'), '\s+').\
                                            getItem(1).\
                                            cast(StringType())).\
                                drop('value')

artist_alias.show(5)
...
+--------+-------+
|  artist|  alias|
+--------+-------+
| 1092764|1000311|
| 1095122|1000557|
| 6708070|1007267|
|10088054|1042317|
| 1195917|1042317|
+--------+-------+

第一个条目将 ID 1092764 映射到 1000311。我们可以从 artist_by_id DataFrame 中查找这些 ID:

artist_by_id.filter(artist_by_id.id.isin(1092764, 1000311)).show()
...

+-------+--------------+
|     id|          name|
+-------+--------------+
|1000311| Steve Winwood|
|1092764|Winwood, Steve|
+-------+--------------+

此条目显然将“Winwood, Steve”映射到“Steve Winwood”,这实际上是艺术家的正确名称。

构建第一个模型

虽然数据集几乎符合与 Spark MLlib 的 ALS 实现一起使用的形式,但需要进行小的额外转换。如果存在不同的规范化 ID,则应用别名数据集以将所有艺术家 ID 转换为规范化 ID:

from pyspark.sql.functions import broadcast, when

train_data = train_data = user_artist_df.join(broadcast(artist_alias),
                                              'artist', how='left').\ train_data = train_data.withColumn('artist',
                                    when(col('alias').isNull(), col('artist')).\
                                    otherwise(col('alias'))) ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/1.png)
train_data = train_data.withColumn('artist', col('artist').\
                                             cast(IntegerType())).\
                                             drop('alias')

train_data.cache()

train_data.count()
...
24296858

1

获取艺术家的别名(如果存在);否则,获取原始艺术家。

我们 broadcast 之前创建的 artist_alias DataFrame。这使得 Spark 在集群中每个执行器上发送并保存一个副本。当有成千上万个任务并且许多任务在每个执行器上并行执行时,这可以节省大量的网络流量和内存。作为经验法则,在与一个非常大的数据集进行连接时,广播一个明显较小的数据集是有帮助的。

调用cache表明向 Spark 建议,在计算后,应该临时存储这个 DataFrame,并且在集群中保留在内存中。这很有帮助,因为 ALS 算法是迭代的,通常需要多次访问这些数据。如果没有这个,每次访问时 DataFrame 都可能会从原始数据中重新计算!Spark UI 中的 Storage 标签将显示 DataFrame 的缓存量和内存使用情况,如 Figure 3-2 所示。这个 DataFrame 在整个集群中消耗约 120 MB。

aaps 0302

图 3-2. Spark UI 中的 Storage 标签,显示了缓存的 DataFrame 内存使用情况

当您使用cachepersist时,DataFrame 在触发遍历每条记录的操作(例如count)之前并不完全缓存。如果您使用show(1)这样的操作,只有一个分区会被缓存。这是因为 PySpark 的优化器会发现您并不需要计算所有分区才能检索一条记录。

请注意,UI 中的“Deserialized”标签在 Figure 3-2 中仅与 RDD 相关,其中“Serialized”意味着数据存储在内存中,而不是作为对象,而是作为序列化字节。然而,像这样的 DataFrame 实例会单独在内存中“编码”常见数据类型。

实际上,120 MB 大小令人惊讶地小。考虑到这里存储了约 2400 万次播放,一个快速的估算计算表明,每个用户-艺术家-计数条目平均消耗仅 5 个字节。然而,仅三个 32 位整数就应该消耗 12 个字节。这是 DataFrame 的一个优点之一。因为存储的数据类型是基本的 32 位整数,它们的内存表示可以在内部进行优化。

最后,我们可以构建一个模型:

from pyspark.ml.recommendation import ALS

model = ALS(rank=10, seed=0, maxIter=5, regParam=0.1,
            implicitPrefs=True, alpha=1.0, userCol='user',
            itemCol='artist', ratingCol='count'). \
        fit(train_data)

使用默认配置将model构建为一个ALSModel。操作可能需要几分钟或更长时间,具体取决于您的集群。与某些机器学习模型相比,这种类型的模型非常庞大。模型中为每个用户和产品包含一个包含 10 个值的特征向量,在这种情况下,超过 170 万个特征向量。模型包含这些大型用户特征和产品特征矩阵,它们作为自己的 DataFrame 存在。

您的结果中的值可能略有不同。最终模型取决于随机选择的初始特征向量集。然而,MLlib 中这些组件的默认行为是通过默认的固定种子使用相同的随机选择集。这与其他库不同,其他库中随机元素的行为通常默认不是固定的。因此,在这里和其他地方,随机种子设置为(… seed=0, …)

要查看一些特征向量,请尝试以下操作,显示仅一行,不截断特征向量的宽显示:

model.userFactors.show(1, truncate = False)

...
+---+----------------------------------------------- ...
|id |features                                        ...
+---+----------------------------------------------- ...
|90 |0.16020626, 0.20717518, -0.1719469, 0.06038466 ...
+---+----------------------------------------------- ...

ALS上调用的其他方法,如setAlpha,设置了超参数,其值可能会影响模型做出的推荐的质量。稍后将对此进行解释。更重要的第一个问题是:模型好不好?它是否能产生好的推荐?这是我们将在下一节中尝试回答的问题。

检查点建议

我们首先应该看看艺术家的推荐是否有直观的意义,方法是检查一个用户、播放和该用户的推荐。以用户 2093760 为例,首先让我们看看他或她的播放记录,以了解其品味。提取该用户听过的艺术家 ID,并打印他们的名称。这意味着搜索由该用户播放的艺术家 ID 的输入,然后通过这些 ID 筛选艺术家集以按顺序打印名称:

user_id = 2093760

existing_artist_ids = train_data.filter(train_data.user == user_id) \ ![1  .select("artist").collect() ![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/2.png)

existing_artist_ids = [i[0] for i in existing_artist_ids]

artist_by_id.filter(col('id').isin(existing_artist_ids)).show() ![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/3.png)
...
+-------+---------------+
|     id|           name|
+-------+---------------+
|   1180|     David Gray|
|    378|  Blackalicious|
|    813|     Jurassic 5|
|1255340|The Saw Doctors|
|    942|         Xzibit|
+-------+---------------+

1

查找其用户为 2093760 的行。

2

收集艺术家 ID 的数据集。

3

过滤这些艺术家。

这些艺术家看起来是主流流行音乐和嘻哈的混合体。一位 Jurassic 5 的粉丝?请记住,现在是 2005 年。如果你在想,Saw Doctors 是一支非常受爱尔兰人欢迎的爱尔兰摇滚乐队。

现在,对于用户进行推荐很简单,尽管通过这种方式计算需要一些时间。适用于批量评分,但不适用于实时用例:

user_subset = train_data.select('user').where(col('user') == user_id).distinct()
top_predictions = model.recommendForUserSubset(user_subset, 5)

top_predictions.show()
...
+-------+--------------------+
|   user|     recommendations|
+-------+--------------------+
|2093760|[{2814, 0.0294106...|
+-------+--------------------+

结果推荐包含由艺术家 ID 组成的列表和当然的“预测”。对于这种 ALS 算法,预测是一个通常在 0 到 1 之间的不透明值,较高的值意味着更好的推荐。它不是概率,但可以被视为估计一个 0/1 值,指示用户是否会与艺术家互动。

提取推荐的艺术家 ID 后,我们可以类似地查找艺术家的名称:

top_predictions_pandas = top_predictions.toPandas()
print(top_prediction_pandas)
...
      user                                    recommendations
0  2093760  [(2814, 0.029410675168037415), (1300642, 0.028...
...

recommended_artist_ids = [i[0] for i in top_predictions_pandas.\
                                        recommendations[0]]

artist_by_id.filter(col('id').isin(recommended_artist_ids)).show()
...
+-------+----------+
|     id|      name|
+-------+----------+
|   2814|   50 Cent|
|   4605|Snoop Dogg|
|1007614|     Jay-Z|
|1001819|      2Pac|
|1300642|  The Game|
+-------+----------+

结果全是嘻哈。乍一看,这并不像是一个很好的推荐集。虽然这些通常是受欢迎的艺术家,但它们似乎并不符合这位用户的听歌习惯。

评估推荐质量

当然,这只是对一个用户结果的主观判断。对于除该用户外的任何人来说,很难量化推荐的好坏。此外,即使对少量输出进行人工评分也是不可行的。

合理地假设用户倾向于播放吸引人的艺术家的歌曲,而不播放不吸引人的艺术家的歌曲是合理的。因此,用户的播放行为为用户推荐的“好”和“坏”艺术家提供了部分信息。这是一个问题性的假设,但是在没有其他数据的情况下,这是最好的选择。例如,据推测用户 2093760 喜欢的艺术家远不止 5 位,而 170 万其他未播放的艺术家中,有一些是有趣的,而不是所有的推荐都是“坏”的。

如果一个推荐系统被评估其在推荐列表中将好的艺术家排名高的能力,会怎样?这是可应用于像推荐系统这样的排名系统的几个通用度量标准之一。问题在于,“好”被定义为“用户曾经听过的艺术家”,而推荐系统已经将所有这些信息作为输入接收。它可以简单地返回用户之前听过的艺术家作为顶级推荐并获得完美分数。但这并不实用,特别是因为推荐系统的角色是推荐用户以前未曾听过的艺术家。

为了使其具有意义,可以将部分艺术家播放数据保留并隐藏在 ALS 模型构建过程之外。然后,可以将这些保留的数据解释为每个用户的一组好的推荐,但是推荐系统尚未收到这些推荐。要求推荐系统对模型中的所有项目进行排名,并检查保留艺术家的排名。理想情况下,推荐系统应将它们全部或几乎全部排在列表的顶部。

然后,我们可以通过比较所有保留艺术家的排名与其余排名来计算推荐系统的得分。(在实践中,我们只检查所有这些对中的一个样本,因为可能存在大量这样的对。)在保留艺术家排名较高的对中的比例就是其得分。得分为 1.0 表示完美,0.0 是最差的分数,0.5 是从随机排名艺术家中获得的期望值。

这个度量指标直接与信息检索概念“接收者操作特征曲线(ROC 曲线)”(oreil.ly/Pt2bn)相关联。上一段中的度量值等于该 ROC 曲线下的面积,通常称为 AUC,即曲线下面积。AUC 可以看作是随机选择一个好推荐高于随机选择一个坏推荐的概率。

AUC 度量标准还用于分类器的评估。它与相关方法一起实现在 MLlib 类BinaryClassificationMetrics中。对于推荐系统,我们将计算每个用户的 AUC 并对结果进行平均。得到的度量标准略有不同,可能称为“平均 AUC”。我们将实现这一点,因为它在 PySpark 中尚未(完全)实现。

与排名系统相关的其他评估指标在RankingMetrics中实现。这些包括精确度、召回率和平均精度(MAP)。MAP 也经常被使用,更专注于顶部推荐的质量。然而,在这里,AUC 将作为衡量整个模型输出质量的常见和广泛指标。

在机器学习中,事实上,将一些数据保留用于选择模型并评估其准确性的过程是常见做法。通常,数据被分成三个子集:训练集、交叉验证(CV)集和测试集。在这个初始示例中,为简单起见,只使用两个集合:训练集和 CV 集。这足以选择一个模型。在第四章中,这个想法将被扩展到包括测试集。

计算 AUC

伴随本书的源代码提供了平均 AUC 的实现。这里不会重复,但在源代码的注释中有详细说明。它接受 CV 集作为每个用户的“正面”或“好”的艺术家,并且一个预测函数。这个函数将包含每个用户-艺术家对的数据帧转换为一个数据帧,也包含其预估的互动强度作为“预测”,其中更高的值意味着在推荐中更高的排名。

要使用输入数据,我们必须将其分成训练集和 CV 集。ALS 模型将仅在训练数据集上进行训练,CV 集将用于评估模型。这里,90%的数据用于训练,剩余的 10%用于交叉验证:

def area_under_curve(
    positive_data,
    b_all_artist_IDs,
    predict_function):
...

all_data = user_artist_df.join(broadcast(artist_alias), 'artist', how='left') \
    .withColumn('artist', when(col('alias').isNull(), col('artist'))\
    .otherwise(col('alias'))) \
    .withColumn('artist', col('artist').cast(IntegerType())).drop('alias')

train_data, cv_data = all_data.randomSplit([0.9, 0.1], seed=54321)
train_data.cache()
cv_data.cache()

all_artist_ids = all_data.select("artist").distinct().count()
b_all_artist_ids = broadcast(all_artist_ids)

model = ALS(rank=10, seed=0, maxIter=5, regParam=0.1,
            implicitPrefs=True, alpha=1.0, userCol='user',
            itemCol='artist', ratingCol='count') \
        .fit(train_data)
area_under_curve(cv_data, b_all_artist_ids, model.transform)

注意,areaUnderCurve接受一个函数作为其第三个参数。在这里,从ALSModel中传入的transform方法被传递进去,但很快会被另一种方法替代。

结果约为 0.879。这好吗?它显然高于从随机推荐中预期的 0.5,接近 1.0,这是可能得分的最大值。通常,AUC 超过 0.9 被认为是高的。

但这是一个准确的评估吗?可以将这个评估用不同的 90%作为训练集重复。得到的 AUC 值的平均可能会更好地估计算法在数据集上的性能。事实上,一个常见的做法是将数据分成k个大小相似的子集,使用k-1 个子集进行训练,并在剩余的子集上进行评估。我们可以重复这个过程k次,每次使用不同的子集。这被称为k-折交叉验证。为了简单起见,在这些示例中不会实施这种技术,但 MLlib 中的CrossValidator API 支持这种技术。验证 API 将在“随机森林”中再次讨论。

将其与更简单的方法进行基准测试是有帮助的。例如,考虑向每个用户推荐全球播放次数最多的艺术家。这不是个性化的,但简单且可能有效。定义这个简单的预测函数,并评估其 AUC 分数:

from pyspark.sql.functions import sum as _sum

def predict_most_listened(train):
    listen_counts = train.groupBy("artist")\
                    .agg(_sum("count").alias("prediction"))\
                    .select("artist", "prediction")

    return all_data.join(listen_counts, "artist", "left_outer").\
                    select("user", "artist", "prediction")

area_under_curve(cv_data, b_all_artist_ids, predict_most_listened(train_data))

结果也约为 0.880. 这表明根据这个度量标准,非个性化推荐已经相当有效。然而,我们预期“个性化”的推荐在比较中得分更高。显然,模型需要一些调整。它能做得更好吗?

超参数选择

到目前为止,用于构建ALSModel的超参数值仅仅是给出的,没有评论。这些参数不会被算法学习,必须由调用者选择。配置的超参数包括:

setRank(10)

模型中潜在因子的数量,或者等价地说,用户特征矩阵和产品特征矩阵中的列数k。在非平凡情况下,这也是它们的秩。

setMaxIter(5)

因子分解运行的迭代次数。更多的迭代需要更多的时间,但可能会产生更好的因子分解。

setRegParam(0.01)

一个常见的过拟合参数,通常也称为lambda。更高的值抵抗过拟合,但值太高会损害因子分解的准确性。

setAlpha(1.0)

控制因子分解中观察到的与未观察到的用户-产品交互的相对权重。

rankregParamalpha可以被视为模型的超参数。(maxIter更多地是对因子分解中使用的资源的限制。)这些不是最终出现在ALSModel内部矩阵中的值,那些只是其参数,由算法选择。这些超参数实际上是构建过程的参数。

前述列表中使用的值不一定是最优的。选择好的超参数值是机器学习中常见的问题。选择值的最基本方法是简单地尝试不同的组合,并评估每个组合的度量标准,并选择产生最佳度量值的组合。

在下面的例子中,尝试了八种可能的组合:rank = 5 或 30,regParam = 4.0 或 0.0001,alpha = 1.0 或 40.0. 这些值仍然有些随意选择,但被选来涵盖广泛的参数值范围。结果按照顶部 AUC 分数的顺序打印:

from pprint import pprint
from itertools import product

ranks = [5, 30]
reg_params = [4.0, 0.0001]
alphas = [1.0, 40.0]
hyperparam_combinations = list(product(*[ranks, reg_params, alphas]))

evaluations = []

for c in hyperparam_combinations:
    rank = c[0]
    reg_param = c[1]
    alpha = c[2]
    model = ALS().setSeed(0).setImplicitPrefs(true).setRank(rank).\
                  setRegParam(reg_param).setAlpha(alpha).setMaxIter(20).\
                  setUserCol("user").setItemCol("artist").\
                  setRatingCol("count").setPredictionCol("prediction").\
        fit(trainData)

    auc = area_under_curve(cv_aata, b_all_artist_ids, model.transform)

    model.userFactors.unpersist() ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/1.png)
    model.itemFactors.unpersist()

    evaluations.append((auc, (rank, regParam, alpha)))

evaluations.sort(key=lambda x:x[0], reverse=True) ![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/2.png)
pprint(evaluations)

...
(0.8928367485129145,(30,4.0,40.0))
(0.891835487024326,(30,1.0E-4,40.0))
(0.8912376926662007,(30,4.0,1.0))
(0.889240668173946,(5,4.0,40.0))
(0.8886268430389741,(5,4.0,1.0))
(0.8883278461068959,(5,1.0E-4,40.0))
(0.8825350012228627,(5,1.0E-4,1.0))
(0.8770527940660278,(30,1.0E-4,1.0))

1

立即释放模型资源。

2

按第一个值(AUC)降序排列,并打印。

绝对值上的差异很小,但对于 AUC 值来说仍然有些显著。有趣的是,参数alpha在 40 时似乎一直比 1 更好。(对于好奇的人来说,40 是早期提到的原始 ALS 论文中建议的默认值之一。) 这可以解释为表明模型更专注于用户听过的内容,而不是未听过的内容。

更高的regParam看起来也更好。这表明模型在某种程度上容易过拟合,因此需要更高的regParam来抵抗试图过度拟合每个用户给出的稀疏输入。过拟合将在“随机森林”中进一步详细讨论。

如预期的那样,5 个特征对于这样大小的模型来说相当低,它的表现不如使用 30 个特征来解释口味的模型。也许最佳特征数实际上比 30 更高,而这些值之间因为都太小而相似。

当然,这个过程可以针对不同的值范围或更多的值重复进行。这是一种选择超参数的蛮力方法。然而,在一个拥有数百个核心和大量内存的集群不少见的世界里,以及可以利用并行性和内存加速的 Spark 等框架中,这变得非常可行。

不严格要求理解超参数的含义,但知道值的正常范围有助于开始搜索一个既不太大也不太小的参数空间。

这是一个相当手动的方式来循环超参数、构建模型并评估它们。在第四章中,通过学习更多关于 Spark ML API 的知识,我们将发现有一种更自动化的方式,可以使用PipelineTrainValidationSplit来计算这个过程。

进行推荐

暂时使用最佳超参数集,新模型为用户 2093760 推荐什么?

+-----------+
|       name|
+-----------+
|  [unknown]|
|The Beatles|
|     Eminem|
|         U2|
|  Green Day|
+-----------+

传闻,这对于此用户来说更有意义,因为他主要听的是流行摇滚而不是所有的嘻哈。[unknown]明显不是一个艺术家。查询原始数据集发现它出现了 429,447 次,几乎进入了前 100 名!这是一些没有艺术家信息的播放的默认值,可能由某个特定的 scrobbling 客户端提供。这不是有用的信息,我们在重新开始之前应该从输入中丢弃它。这是数据科学实践通常是迭代的一个例子,在每个阶段都会有关于数据的发现。

这个模型可以用来为所有用户做推荐。这在批处理过程中可能很有用,该过程每小时或甚至更短时间重新计算一次模型和用户的推荐,具体取决于数据的大小和集群的速度。

然而,目前 Spark MLlib 的 ALS 实现并不支持一次向所有用户推荐的方法。可以像上面展示的那样一次向一个用户推荐,尽管每次会启动一个持续几秒钟的短期分布式作业。这对于快速重新计算小组用户的推荐可能是合适的。这里,推荐给了从数据中选取的 100 名用户并打印出来:

some_users = all_data.select("user").distinct().limit(100) ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/1.png)
val someRecommendations =
  someUsers.map(userID => (userID, makeRecommendations(model, userID, 5)))
someRecommendations.foreach { case (userID, recsDF) =>
  val recommendedArtists = recsDF.select("artist").as[Int].collect()
  println(s"$userID -> ${recommendedArtists.mkString(", ")}")
}

...
1000190 -> 6694932, 435, 1005820, 58, 1244362
1001043 -> 1854, 4267, 1006016, 4468, 1274
1001129 -> 234, 1411, 1307, 189, 121
...

1

100 名不同用户的子集

这里,推荐只是被打印出来了。它们也可以被写入像HBase这样的外部存储,它提供了运行时快速查找。

从这里开始

自然而然,可以花更多时间调整模型参数,查找并修复输入中的异常,如[unknown]艺术家。例如,快速分析播放次数显示用户 2064012 播放了艺术家 4468 惊人的 439,771 次!艺术家 4468 是一个不可思议成功的另类金属乐队——System of a Down,早在推荐中就出现过。假设平均歌曲长度为 4 分钟,这相当于连续 33 年播放像“Chop Suey!”和“B.Y.O.B.”这样的热门曲目。因为该乐队从 1998 年开始制作唱片,这需要每次同时播放四到五首歌曲七年。这必然是垃圾信息或数据错误的例子,也是生产系统可能需要解决的真实数据问题之一。

ALS 并不是唯一可能的推荐算法,但目前它是 Spark MLlib 唯一支持的算法。不过,MLlib 也支持 ALS 的一种变体用于非隐式数据。它的使用方式相同,只是ALS配置为setImplicitPrefs(false)。当数据更像是评分而不是计数时,这是合适的。例如,当数据集是用户对艺术家评分在 1-5 分之间时。从ALSModel.transform推荐方法返回的prediction列确实是一个估计的评分。在这种情况下,简单的 RMSE(均方根误差)指标适合评估推荐系统。

以后,Spark MLlib 或其他库可能会提供其他推荐算法。

在生产环境中,推荐引擎经常需要实时推荐,因为它们用于像电子商务网站这样的环境中,顾客在浏览产品页面时频繁请求推荐。预先计算并存储推荐是一种在规模上可行的方法。这种方法的一个缺点是需要为所有可能需要推荐的用户预先计算推荐结果,而实际上只有其中的一小部分用户每天访问网站。例如,如果一百万用户中只有 10,000 名用户在一天内访问网站,每天为所有一百万用户预先计算推荐是 99%的浪费努力。

我们最好根据需要动态计算推荐。虽然我们可以使用ALSModel为一个用户计算推荐,但这是一个必须分布式执行的操作,需要几秒钟的时间,因为ALSModel非常庞大,实际上是一个分布式数据集。而对于其他模型来说,并不是这样,它们可以提供更快的评分。

第四章:使用决策树和决策森林进行预测

分类和回归是最古老和最深入研究的预测分析类型。您在分析软件包和库中可能遇到的大多数算法都是分类或回归技术,比如支持向量机、逻辑回归、神经网络和深度学习。将回归和分类联系在一起的共同线索是,它们都涉及根据一个或多个其他值来预测一个(或多个)值。为了做到这一点,它们需要一组输入和输出来学习。它们需要同时提供问题和已知答案。因此,它们被称为监督学习的类型。

PySpark MLlib 提供了多种分类和回归算法的实现。其中包括决策树、朴素贝叶斯、逻辑回归和线性回归。这些算法的令人兴奋之处在于,它们可以帮助预测未来——或者至少预测我们尚不确定的事物,比如基于你的在线行为预测你购买汽车的可能性,根据电子邮件中的词汇预测邮件是否为垃圾,或者哪些土地可能根据其位置和土壤化学成分种植出最多的庄稼。

在本章中,我们将专注于一种流行且灵活的分类和回归算法(决策树),以及该算法的扩展(随机决策森林)。首先,我们将理解决策树和森林的基础知识,并介绍前者的 PySpark 实现。决策树的 PySpark 实现支持二元和多类分类,以及回归。该实现通过行进行数据分区,允许使用数百万甚至数十亿个实例进行分布式训练。接下来我们将准备我们的数据集并创建我们的第一棵决策树。然后我们将调整我们的决策树模型。最后,我们将在处理过的数据集上训练一个随机森林模型并进行预测。

尽管 PySpark 的决策树实现易于入门,理解决策树和随机森林算法的基础知识是有帮助的。这是我们将在下一节中讨论的内容。

决策树和森林

决策树(Decision trees)是一类算法家族,可以自然处理类别和数值特征。使用并行计算可以构建单棵树,并且可以同时并行构建多棵树。它们对数据中的异常值具有鲁棒性,这意味着少数极端甚至可能错误的数据点可能完全不会影响预测结果。它们可以处理不同类型和不同规模的数据,无需预处理或标准化。

基于决策树的算法具有相对直观和理解的优势。事实上,我们在日常生活中可能都在隐含地使用决策树体现的同样推理方式。例如,我早晨坐下来喝带牛奶的咖啡。在我决定使用牛奶加入我的咖啡之前,我想预测一下:牛奶是不是变质了?我不确定。我可能会检查一下使用日期是否已过期。如果没有,我预测它没变质。如果日期已经过期,但是距离过期日不到三天,我会冒险预测它没变质。否则,我会闻一下牛奶。如果闻起来有点怪,我预测是变质的;否则,预测不是。

这一系列的是/否决策导致了一个预测结果,这就是决策树所体现的。每个决策都导致两种结果中的一种,即预测结果或另一个决策,如图 4-1 所示。从这个意义上说,把这个过程想象成一个决策树是很自然的,其中树的每个内部节点都是一个决策,每个叶节点都是最终答案。

那是一个简单的决策树,没有经过严谨的构建。为了阐述,再考虑另一个例子。一个机器人在一家异国情调的宠物商店找了一份工作。它想在商店开门前学习,哪些动物适合孩子作为宠物。店主匆匆忙忙列出了九种适合和不适合的宠物。机器人从检查动物中收集到的信息编制了表 4-1。

aaps 0401

图 4-1. 决策树:牛奶是否变质?

表 4-1. 异国情调宠物商店“特征向量”

Name Weight (kg) # Legs Color Good pet?
Fido 20.5 4 Brown Yes
Mr. Slither 3.1 0 Green No
Nemo 0.2 0 Tan Yes
Dumbo 1390.8 4 Gray No
Kitty 12.1 4 Gray Yes
Jim 150.9 2 Tan No
Millie 0.1 100 Brown No
McPigeon 1.0 2 Gray No
Spot 10.0 4 Brown Yes

机器人可以为九种列出的宠物做出决策。商店里还有更多的宠物可供选择。它仍需要一种方法来决定其余动物中哪些适合孩子作为宠物。我们可以假设所有动物的特征都是已知的。利用商店主提供的决策数据和决策树,我们可以帮助机器人学习什么样的动物是适合孩子作为宠物的。

虽然有名字,但名字不会包含在我们的决策树模型的特征中。没有理由相信仅仅是名字就具有预测性;对于机器人来说,“费利克斯”可能是猫,也可能是有毒的大蜘蛛。因此,我们有两个数值特征(体重、腿的数量)和一个分类特征(颜色),预测一个分类目标(是否是孩子的好宠物)。

决策树的工作原理是基于提供的特征进行一个或多个顺序决策。起初,机器人可能会尝试将一个简单的决策树适配到这些训练数据上,该决策树仅基于体重做出一个决策,如图 4-2 所示。

aaps 0402

图 4-2. 机器人的第一棵决策树

决策树的逻辑易于阅读和理解:500 公斤的动物显然不适合作为宠物。这条规则在九个案例中预测了五个正确的值。快速浏览表明,我们可以通过将体重阈值降低到 100 公斤来改进规则。这样做可以在九个例子中正确预测六个。重的动物现在被正确预测了;轻的动物只部分正确。

因此,可以构建第二个决策来进一步调整对于体重小于 100 公斤的例子的预测。最好选择一个能够将某些不正确的“是”预测变成“否”的特征。例如,有一种听起来像蛇的小型绿色动物,我们当前的模型将其分类为合适的宠物候选者。通过基于颜色的决策,如图 4-3 所示,机器人可以进行正确预测。

aaps 0403

图 4-3. 机器人的下一棵决策树

现在,九个例子中有七个是正确的。当然,可以添加决策规则,直到所有九个都正确预测。但是,当翻译成通俗语言时,这样的决策树所包含的逻辑可能会显得不太可信:“如果动物的体重小于 100 公斤,它的颜色是棕色而不是绿色,并且它的腿少于 10 条,那么是,它适合作为宠物。”虽然完全符合给定的例子,但这样的决策树无法预测到小型、棕色、四条腿的狼獾不适合作为宠物。需要一些平衡来避免这种现象,称为过拟合

决策树推广为一种更强大的算法,称为随机森林。随机森林结合了许多决策树,以减少过拟合的风险,并单独训练决策树。该算法在训练过程中注入随机性,使得每棵决策树都有所不同。组合预测减少了预测的方差,使得生成的模型更具普适性,并提高了在测试数据上的性能。

这就足够我们开始在 PySpark 中使用决策树和随机森林了。在接下来的部分,我们将介绍我们将使用的数据集,并为在 PySpark 中使用做好准备。

数据准备

本章使用的数据集是著名的 Covtype 数据集,可以在线上获取,格式为压缩的 CSV 文件covtype.data.gz及其附带的信息文件covtype.info

数据集记录了美国科罗拉多州森林覆盖地块的类型。这个数据集和现实世界的森林有些巧合!每条数据记录包含描述每块地的多个特征,如海拔、坡度、距离水源的距离、阴影和土壤类型,以及覆盖该地块的已知森林类型。要从其他特征中预测森林覆盖类型,总共有 54 个特征。

这个数据集已经在研究中使用过,甚至在Kaggle 比赛中也有应用。这是本章中探索的一个有趣的数据集,因为它既包含分类特征又包含数值特征。数据集中有 581,012 个示例,虽然不完全符合大数据的定义,但足够作为一个示例来管理,并且仍然突出了一些规模问题。

幸运的是,这些数据已经是简单的 CSV 格式,不需要太多的清洗或其他准备工作即可用于 PySpark MLlib。covtype.data 文件应该被提取并复制到您的本地或云存储(如 AWS S3)中。

开始pyspark-shell。如果要构建决策森林,给 shell 分配足够的内存是很有帮助的。如果有足够的内存,可以指定--driver-memory 8g或类似的参数。

CSV 文件基本上包含表格数据,组织成行和列。有时这些列在标题行中有命名,虽然这里并非如此。列名在伴随文件covtype.info中给出。从概念上讲,CSV 文件的每列也有一个类型,可以是数字或字符串,但 CSV 文件本身并未指定这一点。

这是自然而然的解析数据的方法,因为这是 PySpark 处理表格数据的抽象,具有定义的列模式,包括列名和类型。PySpark 内置支持读取 CSV 数据。让我们使用内置的 CSV 读取器将数据集读取为 DataFrame:

data_without_header = spark.read.option("inferSchema", True)\
                      .option("header", False).csv("data/covtype.data")
data_without_header.printSchema()
...
root
 |-- _c0: integer (nullable = true)
 |-- _c1: integer (nullable = true)
 |-- _c2: integer (nullable = true)
 |-- _c3: integer (nullable = true)
 |-- _c4: integer (nullable = true)
 |-- _c5: integer (nullable = true)
 ...

这段代码将输入作为 CSV 文件读取,并且不尝试解析第一行作为列名的标题。它还请求通过检查数据来推断每列的类型。它正确推断出所有列都是数字,具体来说是整数。不幸的是,它只能将列命名为 _c0 等。

我们可以查看covtype.info 文件获取列名。

$ cat data/covtype.info

...
[...]
7.	Attribute information:

Given is the attribute name, attribute type, the measurement unit and
a brief description.  The forest cover type is the classification
problem.  The order of this listing corresponds to the order of
numerals along the rows of the database.

Name                                    Data Type
Elevation                               quantitative
Aspect                                  quantitative
Slope                                   quantitative
Horizontal_Distance_To_Hydrology        quantitative
Vertical_Distance_To_Hydrology          quantitative
Horizontal_Distance_To_Roadways         quantitative
Hillshade_9am                           quantitative
Hillshade_Noon                          quantitative
Hillshade_3pm                           quantitative
Horizontal_Distance_To_Fire_Points      quantitative
Wilderness_Area (4 binary columns)      qualitative
Soil_Type (40 binary columns)           qualitative
Cover_Type (7 types)                    integer

Measurement                  Description

meters                       Elevation in meters
azimuth                      Aspect in degrees azimuth
degrees                      Slope in degrees
meters                       Horz Dist to nearest surface water features
meters                       Vert Dist to nearest surface water features
meters                       Horz Dist to nearest roadway
0 to 255 index               Hillshade index at 9am, summer solstice
0 to 255 index               Hillshade index at noon, summer soltice
0 to 255 index               Hillshade index at 3pm, summer solstice
meters                       Horz Dist to nearest wildfire ignition point
0 (absence) or 1 (presence)  Wilderness area designation
0 (absence) or 1 (presence)  Soil Type designation
1 to 7                       Forest Cover Type designation
...

查看列信息后,显然一些特征确实是数值型的。Elevation 是以米为单位的海拔;Slope 以度数表示。然而,Wilderness_Area 是不同的,因为据说它跨越四列,每列都是 0 或 1。事实上,Wilderness_Area 是一个分类值,而不是数值型的。

这四列实际上是一种独热编码或者 1-of-N 编码。当对分类特征执行这种编码时,一个具有N个不同值的分类特征变成了N个数值特征,每个特征取值为 0 或 1。精确地说,N个值中有一个值为 1,其余值为 0。例如,一个关于天气的分类特征可以是cloudyrainy或者clear,将变成三个数值特征,其中cloudy表示为1,0,0rainy表示为0,1,0,等等。这三个数值特征可以被看作是is_cloudyis_rainyis_clear特征。同样地,有 40 列其实只是一个Soil_Type分类特征。

这不是将分类特征编码为数字的唯一可能方法。另一种可能的编码方法是简单地为分类特征的每个可能值分配一个唯一的数值。例如,cloudy可能变成 1.0,rainy变成 2.0,依此类推。目标本身Cover_Type是一个编码为 1 到 7 的分类值。

当将分类特征编码为单个数值特征时要小心。原始的分类值没有排序,但编码为数字后似乎有了排序。将编码特征视为数值会导致无意义的结果,因为算法实际上是假装rainycloudy更大,且大两倍。只要不将编码的数值作为数字使用,这种做法是可以接受的。

我们已经看到了分类特征的两种编码方式。也许,将这些特征直接包含其值,如“Rawah Wilderness Area”,而不编码(并且以两种方式编码)可能更简单和直接。这可能是历史的产物;数据集发布于 1998 年。出于性能原因或者为了匹配当时更多用于回归问题的库的格式,数据集通常以这些方式编码数据。

无论如何,在继续之前,给这个 DataFrame 添加列名是非常有用的,以便更轻松地处理:

from pyspark.sql.types import DoubleType
from pyspark.sql.functions import col

colnames = ["Elevation", "Aspect", "Slope", \
            "Horizontal_Distance_To_Hydrology", \
            "Vertical_Distance_To_Hydrology", "Horizontal_Distance_To_Roadways", \
            "Hillshade_9am", "Hillshade_Noon", "Hillshade_3pm", \
            "Horizontal_Distance_To_Fire_Points"] + \ ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/1.png)
[f"Wilderness_Area_{i}" for i in range(4)] + \ [f"Soil_Type_{i}" for i in range(40)] + \ ["Cover_Type"]

data = data_without_header.toDF(*colnames).\
                          withColumn("Cover_Type",
                                    col("Cover_Type").cast(DoubleType()))

data.head()
...
Row(Elevation=2596,Aspect=51,Slope=3,Horizontal_Distance_To_Hydrology=258,...)

1

  • 连接集合。

与荒野和土壤相关的列名为Wilderness_Area_0Soil_Type_0等,一小段 Python 代码即可生成这 44 个名称,无需逐个输入。最后,目标Cover_Type列被最初转换为double值,因为在所有 PySpark MLlib API 中,需要将其作为double而不是int使用。这一点稍后会变得明显。

你可以调用data.show来查看数据集的部分行,但显示的宽度太大,很难一眼看完。data.head以原始Row对象显示,这种情况下更易读。

现在我们熟悉了数据集并对其进行了处理,可以训练一个决策树模型。

我们的第一个决策树

在第三章中,我们立即在所有可用数据上建立了一个推荐模型。这创造了一个可以被具有一些音乐知识的任何人检查的推荐系统:观察用户的听音乐习惯和推荐,我们有些感觉它产生了良好的结果。在这里,这是不可能的。我们不知道如何对科罗拉多州的一个新地块进行 54 特征描述,或者可以从这样一个地块期望什么样的森林覆盖。

相反,我们必须直接开始保留一些数据以评估生成的模型。以前,AUC 指标用于评估保留的听数据和推荐预测之间的一致性。AUC 可视为随机选择一个好的推荐高于随机选择一个坏的推荐的概率。这里的原则是相同的,尽管评估指标将会不同:准确性。大多数——90%——的数据将再次用于训练,稍后,我们会看到这个训练集的一个子集将被保留用于交叉验证(CV 集)。这里保留的另外 10% 实际上是第三个子集,一个真正的测试集。

(train_data, test_data) = data.randomSplit([0.9, 0.1])
train_data.cache()
test_data.cache()

数据需要做更多的准备工作,以便在 MLlib 中与分类器一起使用。输入的 DataFrame 包含许多列,每列都持有一个可以用来预测目标列的特征。MLlib 要求所有的输入被收集到 一个 列中,其值是一个向量。PySpark 的 VectorAssembler 类是线性代数意义上向量的一个抽象,只包含数字。在大多数意图和目的上,它们就像一个简单的 double 值数组(浮点数)。当然,一些输入特征在概念上是分类的,即使它们在输入中都用数字表示。

幸运的是,VectorAssembler 类可以完成这项工作:

from pyspark.ml.feature import VectorAssembler

input_cols = colnames[:-1] ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/1.png)
vector_assembler = VectorAssembler(inputCols=input_cols,
                                    outputCol="featureVector")

assembled_train_data = vector_assembler.transform(train_data)

assembled_train_data.select("featureVector").show(truncate = False)
...
+------------------------------------------------------------------- ...
|featureVector                                                       ...
+------------------------------------------------------------------- ...
|(54,[0,1,2,5,6,7,8,9,13,18],[1874.0,18.0,14.0,90.0,208.0,209.0, ...
|(54,[0,1,2,3,4,5,6,7,8,9,13,18],1879.0,28.0,19.0,30.0,12.0,95.0, ...
...

![1

排除标签,Cover_Type

VectorAssembler 的关键参数是要合并成特征向量的列,以及包含特征向量的新列的名称。在这里,所有列——当然除了目标列——都作为输入特征包含在内。生成的 DataFrame 有一个新的 featureVector 列,如图所示。

输出看起来并不像一系列数字,但这是因为它显示的是一个原始的向量表示,表示为一个 SparseVector 实例以节省存储空间。因为 54 个值中大多数是 0,它仅存储非零值及其索引。这个细节在分类中并不重要。

VectorAssembler是当前 MLlib Pipelines API 中的Transformer示例。它根据一些逻辑将输入的 DataFrame 转换为另一个 DataFrame,并且可以与其他转换组合成管道。在本章的后面,这些转换将连接成一个实际的Pipeline。在这里,转换只是直接调用,已足以构建第一个决策树分类器模型:

from pyspark.ml.classification import DecisionTreeClassifier

classifier = DecisionTreeClassifier(seed = 1234, labelCol="Cover_Type",
                                    featuresCol="featureVector",
                                    predictionCol="prediction")

model = classifier.fit(assembled_train_data)
print(model.toDebugString)
...
DecisionTreeClassificationModel: uid=DecisionTreeClassifier_da03f8ab5e28, ...
  If (feature 0 <= 3036.5)
   If (feature 0 <= 2546.5)
    If (feature 10 <= 0.5)
     If (feature 0 <= 2412.5)
      If (feature 3 <= 15.0)
       Predict: 4.0
      Else (feature 3 > 15.0)
       Predict: 3.0
     Else (feature 0 > 2412.5)
       ...

同样,分类器的基本配置包括列名:包含输入特征向量的列和包含目标值以预测的列。因为模型将来会用于预测目标的新值,所以需要给出一个列名来存储预测结果。

打印模型的表示形式显示了它的树结构的一部分。它由一系列关于特征的嵌套决策组成,比较特征值与阈值。(由于历史原因,这些特征只用数字而不是名称来表示,这一点很不幸。)

决策树能够在构建过程中评估输入特征的重要性。也就是说,它们可以估计每个输入特征对于做出正确预测的贡献程度。从模型中获取这些信息很简单:

import pandas as pd

pd.DataFrame(model.featureImportances.toArray(),
            index=input_cols, columns=['importance']).\
            sort_values(by="importance", ascending=False)
...
                                  importance
Elevation                         0.826854
Hillshade_Noon                    0.029087
Soil_Type_1                       0.028647
Soil_Type_3                       0.026447
Wilderness_Area_0                 0.024917
Horizontal_Distance_To_Hydrology  0.024862
Soil_Type_31                      0.018573
Wilderness_Area_2                 0.012458
Horizontal_Distance_To_Roadways   0.003608
Hillshade_9am                     0.002840
...

这将重要性值(数值越高越好)与列名配对,并按重要性从高到低顺序打印它们。海拔似乎是最重要的特征;大多数特征在预测覆盖类型时估计几乎没有任何重要性!

生成的DecisionTreeClassificationModel本身也是一个转换器,因为它可以将包含特征向量的数据框转换为同样包含预测的数据框。

例如,看看模型对训练数据的预测,并将其与已知的正确覆盖类型进行比较可能会很有趣:

predictions = model.transform(assembled_train_data)
predictions.select("Cover_Type", "prediction", "probability").\
            show(10, truncate = False)

...
+----------+----------+------------------------------------------------ ...
|Cover_Type|prediction|probability                                      ...
+----------+----------+------------------------------------------------ ...
|6.0       |4.0       |0.0,0.0,0.028372324539571926,0.2936784469885515, ...
|6.0       |3.0       |0.0,0.0,0.024558587479935796,0.6454654895666132, ...
|6.0       |3.0       |[0.0,0.0,0.024558587479935796,0.6454654895666132, ...
|6.0       |3.0       |[0.0,0.0,0.024558587479935796,0.6454654895666132, ...
...

有趣的是,输出还包含一个probability列,显示了模型对每种可能结果正确的估计概率。这表明在这些情况下,它相当确信答案是 3,并且几乎可以肯定答案不是 1。

细心的读者可能会注意到,概率向量实际上有八个值,即使只有七种可能的结果。向量中索引为 1 到 7 的值包含了结果 1 到 7 的概率。然而,索引为 0 的值总是显示为 0.0 的概率。这可以忽略,因为 0 不是有效的结果,正如所述的那样。这是表示这一信息的向量的一种特殊方式,值得注意。

基于以上片段,看起来模型可能需要进一步改进。它的预测看起来经常是错误的。与[第三章中的 ALS 实现一样,DecisionTreeClassifier实现有几个需要选择值的超参数,这些参数都被默认设置。在这里,测试集可以用来产生使用这些默认超参数构建的模型的预期准确性的无偏评估。

现在我们将使用MulticlassClassificationEvaluator来计算准确率和其他评估模型预测质量的指标。这是 MLlib 中评估器的一个示例,负责以某种方式评估输出 DataFrame 的质量:

from pyspark.ml.evaluation import MulticlassClassificationEvaluator

evaluator = MulticlassClassificationEvaluator(labelCol="Cover_Type",
                                        predictionCol="prediction")

evaluator.setMetricName("accuracy").evaluate(predictions)
evaluator.setMetricName("f1").evaluate(predictions)

...
0.6989423087953562
0.6821216079701136

在给出包含“标签”(目标或已知正确输出值)的列和包含预测的列名之后,它发现这两者大约有 70%的匹配率。这就是分类器的准确率。它还可以计算其他相关的度量值,如 F1 分数。在这里,我们将使用准确率来评估分类器。

这个单一数字很好地总结了分类器输出的质量。然而,有时候查看混淆矩阵也很有用。这是一个表格,其中为每个可能的目标值(target)都有一行和一列。因为有七个目标类别值,所以这是一个 7×7 的矩阵,其中每行对应一个实际正确值,每列对应一个按顺序预测的值。在第i行和第j列的条目计数了真实类别i被预测为类别j的次数。因此,正确的预测在对角线上计数,而其他则是预测。

可以直接使用 DataFrame API 计算混淆矩阵,利用其更一般的操作符。

confusion_matrix = predictions.groupBy("Cover_Type").\
  pivot("prediction", range(1,8)).count().\
  na.fill(0.0).\ ![1  orderBy("Cover_Type")confusion_matrix.show()...+----------+------+------+-----+---+---+---+-----+|Cover_Type|     1|     2|    3|  4|  5|  6|    7|+----------+------+------+-----+---+---+---+-----+|       1.0|133792| 51547|  109|  0|  0|  0| 5223||       2.0| 57026|192260| 4888| 57|  0|  0|  750||       3.0|     0|  3368|28238|590|  0|  0|    0||       4.0|     0|     0| 1493|956|  0|  0|    0||       5.0|     0|  8282|  283|  0|  0|  0|    0||       6.0|     0|  3371|11872|406|  0|  0|    0||       7.0|  8122|    74|    0|  0|  0|  0|10319|+----------+------+------+-----+---+---+---+-----+```![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO3-1)

用 0 替换空值。

电子表格用户可能已经意识到这个问题就像计算数据透视表一样。数据透视表通过两个维度对值进行分组,这些值成为输出的行和列,并在这些分组内计算一些聚合,例如在这里计数。这也可以作为几个数据库中的 PIVOT 函数提供,并受到 Spark SQL 的支持。通过这种方式计算可能更加优雅和强大。

尽管 70%的准确率听起来不错,但是否出色或糟糕并不立即清楚。一个简单的方法能建立基线吗?就像一个停止的时钟每天会准确两次一样,随机猜测每个例子的分类也偶尔会得出正确答案。

我们可以构建这样一个随机的“分类器”,方法是按照其在训练集中的普遍性随机选择一个类别。例如,如果训练集中有 30%的覆盖类型 1,则随机分类器会有 30%的概率猜测“1”。每个分类在测试集中的正确性与其在测试集中的普遍性成比例。如果测试集中有 40%的覆盖类型 1,则猜测“1”将有 40%的准确率。因此,覆盖类型 1 将在 30% x 40% = 12%的时间内被正确猜测,并对总体准确率贡献 12%。因此,我们可以通过对这些概率乘积求和来评估准确性:

from pyspark.sql import DataFrame

def class_probabilities(data):
total = data.count()
return data.groupBy("Cover_Type").count().\ 1
orderBy("Cover_Type").\ 2
select(col("count").cast(DoubleType())).
withColumn("count_proportion", col("count")/total).
select("count_proportion").collect()

train_prior_probabilities = class_probabilities(train_data)
test_prior_probabilities = class_probabilities(test_data)

train_prior_probabilities
...

[Row(count_proportion=0.36455357859838705),
Row(count_proportion=0.4875111371136425),
Row(count_proportion=0.06155716924206445),
Row(count_proportion=0.00468236760696409),
Row(count_proportion=0.016375858943914835),
Row(count_proportion=0.029920118693908142),
Row(count_proportion=0.03539976980111887)]

...

train_prior_probabilities = [p[0] for p in train_prior_probabilities]
test_prior_probabilities = [p[0] for p in test_prior_probabilities]

sum([train_p * cv_p for train_p, cv_p in zip(train_prior_probabilities,
test_prior_probabilities)]) 3
...

0.37735294664034547


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO4-1)

按类别计数

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO4-2)

按类别排序计数

![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO4-3)

在训练集和测试集中求对数乘积

随机猜测当时的准确率为 37%,这使得 70%看起来像是一个很好的结果。但后者是使用默认超参数实现的。通过探索超参数对树构建过程的实际影响,我们可以取得更好的效果。这正是我们将在下一节中做的。

# 决策树超参数

在 第三章,ALS 算法揭示了几个超参数,我们必须通过使用不同数值组合构建模型,然后使用某些度量评估每个结果的质量来选择这些数值。尽管度量标准现在是多类准确率而不是 AUC,但过程是相同的。控制决策树决策方式的超参数也将大不相同:最大深度、最大 bin 数、纯度度量和最小信息增益。

*最大深度* 简单地限制了决策树中的层级数。它是分类器用于分类示例时将做出的最大连锁决策数量。为了避免过度拟合训练数据,限制这一点非常有用,正如前面在宠物店示例中所示。

决策树算法负责提出在每个级别尝试的潜在决策规则,例如宠物店示例中的 `weight >= 100` 或 `weight >= 500` 决策。决策始终具有相同的形式:对于数值特征,决策形式为 `feature >= value`;对于分类特征,形式为 `feature in (value1, value2, …)`。因此,要尝试的决策规则集合实际上是要插入到决策规则中的一组值。在 PySpark MLlib 实现中称为 *bins*。更多的 bin 数量需要更多的处理时间,但可能会导致找到更优的决策规则。

什么使得决策规则变得好?直觉上,一个好的规则能够有意义地通过目标类别值来区分示例。例如,将 Covtype 数据集分成只有类别 1-3 和 4-7 的例子的规则会非常好,因为它清晰地将一些类别与其他类别分开。一个导致与整个数据集中所有类别几乎相同混合的规则似乎不会有帮助。遵循这样一个决策的任一分支会导致可能目标值的大致相同分布,因此并不会真正取得向有信心的分类的进展。

换句话说,好的规则将训练数据的目标值分成相对同质或“纯净”的子集。选择最佳规则意味着最小化它引起的两个子集的不纯度。有两种常用的不纯度度量:基尼不纯度和熵。

*基尼不纯度*与随机猜测分类器的准确性直接相关。在一个子集内,它是随机选择的分类中一个被选择的例子(根据子集中类别的分布)*不正确*的概率。为了计算这个值,我们首先将每个类别乘以其在所有类别中的比例。然后从 1 中减去所有值的总和。如果一个子集有*N*个类别,*p*[*i*]是类别*i*的例子比例,则其基尼不纯度在基尼不纯度方程中给出:

<math alttext="upper I Subscript upper G Baseline left-parenthesis p right-parenthesis equals 1 minus sigma-summation Underscript i equals 1 Overscript upper N Endscripts p Subscript i Superscript 2" display="block"><mrow><msub><mi>I</mi> <mi>G</mi></msub> <mrow><mo>(</mo> <mi>p</mi> <mo>)</mo></mrow> <mo>=</mo> <mn>1</mn> <mo>-</mo> <munderover><mo>∑</mo> <mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow> <mi>N</mi></munderover> <msubsup><mi>p</mi> <mi>i</mi> <mn>2</mn></msubsup></mrow></math>

如果子集只包含一个类别,那么这个值为 0,因为它完全是“纯净的”。当子集中有*N*个类别时,这个值大于 0,当且仅当各类别出现次数相同时值最大——即最大不纯度。

*熵*是另一种来自信息论的不纯度度量。其本质更难以解释,但它捕捉了子集中目标值集合关于数据预测的不确定性程度。包含一个类别的子集表明该子集的结果是完全确定的,并且熵为 0——没有不确定性。另一方面,包含每种可能类别的子集则表明对于该子集的预测有很大的不确定性,因为数据已经观察到各种目标值。这就有很高的熵。因此,低熵和低基尼不纯度一样,是一个好事。熵由熵方程定义:

<math alttext="upper I Subscript upper E Baseline left-parenthesis p right-parenthesis equals sigma-summation Underscript i equals 1 Overscript upper N Endscripts p Subscript i Baseline log left-parenthesis StartFraction 1 Over p Subscript i Baseline EndFraction right-parenthesis equals minus sigma-summation Underscript i equals 1 Overscript upper N Endscripts p Subscript i Baseline log left-parenthesis p Subscript i Baseline right-parenthesis" display="block"><mrow><msub><mi>I</mi> <mi>E</mi></msub> <mrow><mo>(</mo> <mi>p</mi> <mo>)</mo></mrow> <mo>=</mo> <munderover><mo>∑</mo> <mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow> <mi>N</mi></munderover> <msub><mi>p</mi> <mi>i</mi></msub> <mo form="prefix">log</mo> <mrow><mo>(</mo> <mfrac><mn>1</mn> <msub><mi>p</mi> <mi>i</mi></msub></mfrac> <mo>)</mo></mrow> <mo>=</mo> <mo>-</mo> <munderover><mo>∑</mo> <mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow> <mi>N</mi></munderover> <msub><mi>p</mi> <mi>i</mi></msub> <mo form="prefix">log</mo> <mrow><mo>(</mo> <msub><mi>p</mi> <mi>i</mi></msub> <mo>)</mo></mrow></mrow></math>

有趣的是,不确定性是有单位的。因为对数是自然对数(以*e*为底),单位是*nats*,是更熟悉的*bits*的对应物(通过使用以 2 为底的对数可以得到)。它确实是在测量信息,因此在使用熵来进行决策树时,谈论决策规则的*信息增益*也很常见。

在给定数据集中,其中一个或另一个度量可能更适合选择决策规则。在某种程度上,它们是相似的。两者都涉及加权平均:按*p*[i]加权值的总和。PySpark 实现的默认值是基尼不纯度。

最后,*最小信息增益*是一个超参数,它对候选决策规则施加最小信息增益或不纯度减少要求。那些不能足够改善子集不纯度的规则将被拒绝。与较低的最大深度类似,这可以帮助模型抵抗过拟合,因为仅仅能帮助将训练输入划分的决策,可能实际上在将来的数据中并不帮助划分。

现在我们理解了决策树算法的相关超参数,接下来我们将在下一节调整我们的模型以提高其性能。

# 调整决策树

从数据看,不明显哪种不纯度度量可以带来更好的准确性,或者最大深度或者箱数目是否足够而不过度。幸运的是,就像在第三章中一样,让 PySpark 尝试多个这些值的组合并报告结果是很简单的。

首先,需要设置一个流水线,封装我们在前几节中执行的两个步骤——创建特征向量和使用它创建决策树模型。创建`VectorAssembler`和`DecisionTreeClassifier`,并将这两个`Transformer`链接在一起,结果是一个单一的`Pipeline`对象,它将这两个操作作为一个操作整合在一起:

from pyspark.ml import Pipeline

assembler = VectorAssembler(inputCols=input_cols, outputCol="featureVector")
classifier = DecisionTreeClassifier(seed=1234, labelCol="Cover_Type",
featuresCol="featureVector",
predictionCol="prediction")

pipeline = Pipeline(stages=[assembler, classifier])


当然,流水线可以更长,更复杂。这是最简单的情况。现在我们还可以使用 PySpark ML API 内置的支持,定义应该使用的超参数组合的`ParamGridBuilder`。现在是定义评估指标的时候了,将用于选择“最佳”超参数,这是`MulticlassClassificationEvaluator`:

from pyspark.ml.tuning import ParamGridBuilder

paramGrid = ParamGridBuilder().
addGrid(classifier.impurity, ["gini", "entropy"]).
addGrid(classifier.maxDepth, [1, 20]).
addGrid(classifier.maxBins, [40, 300]).
addGrid(classifier.minInfoGain, [0.0, 0.05]).
build()

multiclassEval = MulticlassClassificationEvaluator().
setLabelCol("Cover_Type").
setPredictionCol("prediction").
setMetricName("accuracy")


这意味着将建立一个模型,并对四个超参数的两个值进行评估。这是 16 个模型。它们将通过多类精度进行评估。最后,`TrainValidationSplit`将这些组件结合在一起——创建模型的流水线、模型评估指标和要尝试的超参数——并且可以在训练数据上运行评估。值得注意的是,这里也可以使用`CrossValidator`来执行完整的 k 折交叉验证,但它的成本是*k*倍,并且在处理大数据时添加的价值不如`TrainValidationSplit`大。因此,这里使用了`TrainValidationSplit`:

from pyspark.ml.tuning import TrainValidationSplit

validator = TrainValidationSplit(seed=1234,
estimator=pipeline,
evaluator=multiclassEval,
estimatorParamMaps=paramGrid,
trainRatio=0.9)

validator_model = validator.fit(train_data)


这将花费几分钟甚至更长时间,具体取决于您的硬件,因为它正在构建和评估许多模型。请注意,训练比例参数设置为 0.9。这意味着训练数据实际上由`TrainValidationSplit`进一步分为 90%/10%子集。前者用于训练每个模型。剩余的 10%输入数据保留为交叉验证集以评估模型。如果已经保留了一些数据用于评估,那么为什么我们还要保留原始数据的 10%作为测试集?

如果 CV 集的目的是评估适合*训练*集的*参数*,那么测试集的目的是评估适合 CV 集的*超参数*。换句话说,测试集确保了对最终选择的模型及其超参数准确度的无偏估计。

假设这个过程选择的最佳模型在 CV 集上表现出 90%的准确率。预计它将在未来的数据上表现出 90%的准确率似乎是合理的。然而,构建这些模型的过程中存在随机性。由于偶然因素,这个模型和评估结果可能会特别好。顶级模型和评估结果可能会受益于一点运气,因此其准确度估计可能稍微乐观一些。换句话说,超参数也可能会出现过拟合的情况。

要真正评估这个最佳模型在未来示例中的表现如何,我们需要对未用于训练的示例进行评估。但我们也需要避免使用 CV 集中用于评估它的示例。这就是为什么第三个子集,即测试集,被保留的原因。

验证器的结果包含它找到的最佳模型。这本身就是它找到的最佳整体*管道*的表示,因为我们提供了一个要运行的管道实例。要查询由`DecisionTreeClassifier`选择的参数,必须手动从最终的`PipelineModel`中提取`DecisionTreeClassificationModel`,这是管道中的最后一个阶段:

from pprint import pprint

best_model = validator_model.bestModel
pprint(best_model.stages[1].extractParamMap())

...
{Param(...name='predictionCol', doc='prediction column name.'): 'prediction',
Param(...name='probabilityCol', doc='...'): 'probability',
[...]
Param(...name='impurity', doc='...'): 'entropy',
Param(...name='maxDepth', doc='...'): 20,
Param(...name='minInfoGain', doc='...'): 0.0,
[...]
Param(...name='featuresCol', doc='features column name.'): 'featureVector',
Param(...name='maxBins', doc='...'): 40,
[...]
Param(...name='labelCol', doc='label column name.'): 'Cover_Type'}
...
}


此输出包含关于拟合模型的大量信息,但它还告诉我们,熵显然作为杂质度量效果最好,并且最大深度为 20 并不出奇。最佳模型仅使用 40 个箱似乎有些令人惊讶,但这可能表明 40 个“足够”而不是“比 300 更好”。最后,没有最小信息增益比小最小信息增益更好,这可能意味着该模型更容易欠拟合而不是过拟合。

您可能想知道是否可以查看每个模型在每种超参数组合下实现的准确率。超参数和评估结果通过`getEstimatorParamMaps`和`validationMetrics`公开。它们可以组合在一起,按度量值排序显示所有参数组合:

validator_model = validator.fit(train_data)

metrics = validator_model.validationMetrics
params = validator_model.getEstimatorParamMaps()
metrics_and_params = list(zip(metrics, params))

metrics_and_params.sort(key=lambda x: x[0], reverse=True)
metrics_and_params

...
[(0.9130409881445563,
{Param(...name='minInfoGain' ...): 0.0,
Param(...name='maxDepth'...): 20,
Param(...name='maxBins' ...): 40,
Param(...name='impurity'...): 'entropy'}),
(0.9112655352131498,
{Param(...name='minInfoGain',...): 0.0,
Param(...name='maxDepth' ...): 20,
Param(...name='maxBins'...): 300,
Param(...name='impurity'...: 'entropy'}),
...


这个模型在 CV 集上达到了什么准确率?最后,这个模型在测试集上达到了什么准确率?

metrics.sort(reverse=True)
print(metrics[0])
...

0.9130409881445563
...

multiclassEval.evaluate(best_model.transform(test_data)) 1

...
0.9138921373048084


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO5-1)

`best_Model` 是一个完整的流水线。

结果都约为 91%。偶然情况下,来自 CV 集的估计一开始就相当不错。事实上,测试集显示非常不同的结果并不常见。

这是重新审视过拟合问题的一个有趣时刻。如前所述,可能会构建一个非常深奥的决策树,使其非常好或完全适合给定的训练示例,但由于过于密切地拟合了训练数据的特异性和噪声,无法推广到其他示例。这是大多数机器学习算法(不仅仅是决策树)常见的问题。

当决策树过度拟合时,在用于拟合模型的相同训练数据上,其准确率会很高,但在其他示例上准确率会很低。在这里,最终模型对其他新示例的准确率约为 91%。准确率也可以很容易地在模型训练时的相同数据上评估,`trainData`。这样的准确率约为 95%。差异不大,但表明决策树在某种程度上过度拟合了训练数据。更低的最大深度可能是更好的选择。

到目前为止,我们已经将所有输入特征(包括分类特征)隐式地视为数值。通过将分类特征作为确切的分类特征处理,我们可以进一步提高模型的性能。我们将在接下来探讨这一点。

# 分类特征再探讨

我们数据集中的分类特征被单热编码为多个二进制 0/1 值。将这些单独特征视为数值处理是可行的,因为对于“数值”特征的任何决策规则都会选择 0 到 1 之间的阈值,而且所有阈值都是等价的,因为所有值都是 0 或 1。

当然,这种编码方式迫使决策树算法单独考虑底层分类特征的值。由于特征如土壤类型被分解成许多特征,并且决策树单独处理特征,因此更难以关联相关土壤类型的信息。

例如,九种不同的土壤类型实际上是莱顿家族的一部分,它们可能以决策树可以利用的方式相关联。如果将土壤类型编码为一个具有 40 个土壤值的单一分类特征,则树可以直接表达诸如“如果土壤类型是九种莱顿家族类型之一”的规则。然而,当编码为 40 个特征时,树必须学习关于土壤类型的九个决策序列才能达到相同的表达能力,这种表达能力可能会导致更好的决策和更高效的树。

然而,将 40 个数值特征代表一个 40 值分类特征增加了内存使用量并减慢了速度。

如何撤销独热编码?例如,将四列编码的荒野类型替换为一个列,该列将荒野类型编码为 0 到 3 之间的数字,如`Cover_Type`:

def unencode_one_hot(data):
wilderness_cols = ['Wilderness_Area_' + str(i) for i in range(4)]
wilderness_assembler = VectorAssembler().
setInputCols(wilderness_cols).
setOutputCol("wilderness")

unhot_udf = udf(lambda v: v.toArray().tolist().index(1)) ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/1.png)

with_wilderness = wilderness_assembler.transform(data).\
  drop(*wilderness_cols).\ ![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/2.png)
  withColumn("wilderness", unhot_udf(col("wilderness")))

soil_cols = ['Soil_Type_' + str(i) for i in range(40)]
soil_assembler = VectorAssembler().\
                  setInputCols(soil_cols).\
                  setOutputCol("soil")
with_soil = soil_assembler.\
            transform(with_wilderness).\
            drop(*soil_cols).\
            withColumn("soil", unhot_udf(col("soil")))

return with_soil

![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO6-1)

注意 UDF 定义

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO6-2)

删除一热编码列;不再需要

这里使用`VectorAssembler`来将 4 个荒野和土壤类型列合并成两个`Vector`列。这些`Vector`中的值都是 0,只有一个位置是 1。没有简单的 DataFrame 函数可以做到这一点,因此我们必须定义自己的 UDF 来操作列。这将把这两个新列转换为我们需要的类型的数字。

现在我们可以通过上面定义的函数转换我们的数据集,删除独热编码:

unenc_train_data = unencode_one_hot(train_data)
unenc_train_data.printSchema()
...
root
|-- Elevation: integer (nullable = true)
|-- Aspect: integer (nullable = true)
|-- Slope: integer (nullable = true)
|-- Horizontal_Distance_To_Hydrology: integer (nullable = true)
|-- Vertical_Distance_To_Hydrology: integer (nullable = true)
|-- Horizontal_Distance_To_Roadways: integer (nullable = true)
|-- Hillshade_9am: integer (nullable = true)
|-- Hillshade_Noon: integer (nullable = true)
|-- Hillshade_3pm: integer (nullable = true)
|-- Horizontal_Distance_To_Fire_Points: integer (nullable = true)
|-- Cover_Type: double (nullable = true)
|-- wilderness: string (nullable = true)
|-- soil: string (nullable = true)
...

unenc_train_data.groupBy('wilderness').count().show()
...

+----------+------+
|wilderness| count|
+----------+------+
| 3| 33271|
| 0|234532|
| 1| 26917|
| 2|228144|
+----------+------+


从这里开始,几乎可以使用与上述相同的过程来调整建立在这些数据上的决策树模型的超参数,并选择和评估最佳模型。但是有一个重要的区别。这两个新的数值列并没有任何信息表明它们实际上是分类值的编码。将它们视为数字是不正确的,因为它们的排序是没有意义的。模型仍然会构建,但由于这些特征中的一些信息不可用,精度可能会受到影响。

在内部,MLlib 可以存储关于每列的额外元数据。这些数据的细节通常对调用方隐藏,但包括列是否编码为分类值以及它取多少个不同的值等信息。要添加这些元数据,需要通过`VectorIndexer`处理数据。它的工作是将输入转换为正确标记的分类特征列。虽然我们已经做了大部分工作,将分类特征转换为 0 索引的值,但`VectorIndexer`将负责元数据。

我们需要将此阶段添加到`Pipeline`中:

from pyspark.ml.feature import VectorIndexer

cols = unenc_train_data.columns
inputCols = [c for c in cols if c!='Cover_Type']

assembler = VectorAssembler().setInputCols(inputCols).setOutputCol("featureVector")

indexer = VectorIndexer().
setMaxCategories(40).\ 1
setInputCol("featureVector").setOutputCol("indexedVector")

classifier = DecisionTreeClassifier().setLabelCol("Cover_Type").
setFeaturesCol("indexedVector").
setPredictionCol("prediction")

pipeline = Pipeline().setStages([assembler, indexer, classifier])


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_making_predictions_with_decision_trees___span_class__keep_together__and_decision_forests__span__CO7-1)

大于等于 40,因为土壤有 40 个值

这种方法假设训练集中至少包含每个分类特征的所有可能值一次。也就是说,只有当所有 4 个土壤值和所有 40 个荒野值出现在训练集中时,它才能正确工作,以便所有可能的值都能得到映射。在这里,情况确实如此,但对于一些标签非常少出现的小训练数据集来说,情况可能并非如此。在这些情况下,可能需要手动创建并添加一个`VectorIndexerModel`,并手动提供完整的值映射。

除此之外,这个过程和之前是一样的。你应该发现它选择了一个类似的最佳模型,但是在测试集上的准确率大约是 93%。通过在前几节中将分类特征视为实际的分类特征,分类器的准确率提高了将近 2%。

我们已经训练并调整了一个决策树。现在,我们将转向随机森林,这是一个更强大的算法。正如我们将在下一节看到的,使用 PySpark 实现它们在这一点上将会非常简单。

# 随机森林

如果你已经跟着代码示例走过来,你可能会注意到你的结果与书中代码清单中呈现的结果略有不同。这是因为在构建决策树时存在随机因素,而当你决定使用什么数据和探索什么决策规则时,随机性就会发挥作用。

算法在每个级别并不考虑每一个可能的决策规则。这样做将需要大量的时间。对于一个包含*N*个值的分类特征,存在 2^(*N*)–2 个可能的决策规则(每个子集除了空集和整个集合)。对于一个即使是适度大的*N*,这将产生数十亿个候选决策规则。

相反,决策树使用几个启发式方法来确定实际考虑哪些规则。挑选规则的过程也涉及一些随机性;每次只查看随机选择的少数几个特征,并且仅来自训练数据的随机子集的值。这种做法在速度上稍微牺牲了一点准确性,但也意味着决策树算法不会每次都构建相同的树。这是件好事。

出于同样的原因,通常“众人的智慧”会胜过个体的预测。为了说明这一点,来做个快速测验:伦敦有多少辆黑色出租车?

不要先看答案;先猜测。

我猜大约 10,000 辆,这远低于正确答案约为 19,000 辆。因为我猜得比较少,你更有可能比我猜得多,所以我们的平均答案将更接近正确。这里又出现了均值回归。办公室里 13 个人的非正式投票平均猜测确实更接近:11,170。

这种效应的关键在于猜测是独立的,并且不会相互影响。(你没有偷看,对吧?)如果我们都同意并使用相同的方法进行猜测,这个练习将毫无用处,因为猜测将会是相同的答案——可能是完全错误的答案。如果我仅仅通过提前说明我的猜测来影响你,结果甚至会更糟。

不只一棵树,而是许多树将会很棒,每棵树都会产生合理但不同和独立的目标值估计。它们的集体平均预测应该接近真实答案,比任何单棵树的预测更接近。这个过程中的*随机性*有助于创建这种独立性。这是*随机森林*的关键。

通过构建许多树注入随机性,每棵树都看到不同的随机数据子集——甚至是特征子集。这使得整个森林对过度拟合的倾向较小。如果特定特征包含嘈杂数据或只在*训练*集中具有迷惑性预测能力,那么大多数树大部分时间都不会考虑这个问题特征。大多数树不会适应噪声,并倾向于“否决”在森林中适应噪声的树。

随机森林的预测只是树预测的加权平均值。对于分类目标,这可以是多数投票或基于树产生的概率平均值的最可能值。与决策树一样,随机森林也支持回归,而在这种情况下,森林的预测是每棵树预测数的平均值。

虽然随机森林是一种更强大且复杂的分类技术,好消息是在本章开发的流水线中使用它实际上几乎没有区别。只需将`RandomForestClassifier`放入`DecisionTreeClassifier`的位置,并像以前一样继续即可。实际上,没有更多的代码或 API 需要理解以便使用它:

from pyspark.ml.classification import RandomForestClassifier

classifier = RandomForestClassifier(seed=1234, labelCol="Cover_Type",
featuresCol="indexedVector",
predictionCol="prediction")


注意,这个分类器还有另一个超参数:要构建的树的数量。像最大箱子超参数一样,更高的值应该在一定程度上产生更好的结果。然而,代价是,构建许多树当然比构建一棵树需要花费的时间长得多。

从类似调整过程中产生的最佳随机森林模型的准确率一开始就达到了 95%——比以前建立的最佳决策树高出约 2%,尽管从另一个角度来看,这是从 7%降到 5%的误差率减少了 28%。通过进一步调整,你可能会获得更好的结果。

顺便说一句,在这一点上,我们对特征重要性有了更可靠的了解:

forest_model = best_model.stages[1]

feature_importance_list = list(zip(input_cols,
forest_model.featureImportances.toArray()))
feature_importance_list.sort(key=lambda x: x[1], reverse=True)

pprint(feature_importance_list)
...
(0.28877055118903183,Elevation)
(0.17288279582959612,soil)
(0.12105056811661499,Horizontal_Distance_To_Roadways)
(0.1121550648692802,Horizontal_Distance_To_Fire_Points)
(0.08805270405239551,wilderness)
(0.04467393191338021,Vertical_Distance_To_Hydrology)
(0.04293099150373547,Horizontal_Distance_To_Hydrology)
(0.03149644050848614,Hillshade_Noon)
(0.028408483578137605,Hillshade_9am)
(0.027185325937200706,Aspect)
(0.027075578474331806,Hillshade_3pm)
(0.015317564027809389,Slope)


随机森林在大数据背景下非常吸引人,因为树被认为是独立构建的,而大数据技术如 Spark 和 MapReduce 固有地需要*数据并行*问题,即整体解决方案的各部分可以独立计算在数据的各部分上。树可以且应该仅在特征或输入数据的子集上训练,使得并行构建树变得微不足道。

# 进行预测

构建分类器,虽然是一个有趣且微妙的过程,但并非最终目标。目标是进行预测。这才是回报,而且相对来说相当容易。

最终的“最佳模型”实际上是一整套操作流程。它包含了如何将输入转换为模型使用的方式,并包括模型本身,可以进行预测。它可以在新输入的数据框架上操作。与我们开始的`data`数据框架唯一的区别是缺少`Cover_Type`列。当我们进行预测时——尤其是关于未来的预测,如 Bohr 先生所说——输出当然是未知的。

为了证明这一点,请尝试从测试数据输入中删除`Cover_Type`并获得预测:

unenc_test_data = unencode_one_hot(test_data)
bestModel.transform(unenc_test_data.drop("Cover_Type")).
select("prediction").show()

...
+----------+
|prediction|
+----------+
| 6.0|
+----------+


结果应该是 6.0,这对应于原始 Covtype 数据集中的第 7 类(原始特征为 1 索引)。本示例描述的土地的预测覆盖类型是 Krummholz。

# 下一步该去哪里

本章介绍了机器学习中的两种相关且重要的类型,分类和回归,以及一些构建和调整模型的基础概念:特征、向量、训练和交叉验证。它演示了如何使用 Covtype 数据集,通过位置和土壤类型等因素预测森林覆盖类型,使用 PySpark 实现了决策树和随机森林。

就像第三章中的推荐系统一样,继续探索超参数对准确性的影响可能会很有用。大多数决策树超参数在时间和准确性之间进行权衡:更多的箱子和树通常可以提高准确性,但会遇到收益递减的点。

这里的分类器结果非常准确。通常情况下,超过 95%的准确率是不寻常的。一般来说,通过包含更多特征或将现有特征转换为更具预测性的形式,可以进一步提高准确性。这是在迭代改进分类器模型中常见且重复的步骤。例如,对于这个数据集,编码水平和垂直距离到水面特征的两个特征可以产生第三个特征:直线距离到水面特征。这可能比任何原始特征都更有用。或者,如果可能收集更多数据,我们可以尝试添加像土壤湿度这样的新信息来改善分类。

当然,并非所有真实世界中的预测问题都与 Covtype 数据集完全相同。例如,有些问题需要预测连续的数值,而不是分类值。对于这种类型的*回归*问题,相同的分析和代码适用;在这种情况下,`RandomForestRegressor`类将非常有用。

此外,决策树和随机森林并非唯一的分类或回归算法,也不是仅有的 PySpark 实现算法。每种算法的操作方式都与决策树和随机森林有很大不同。然而,许多元素是相同的:它们都可以插入到 `Pipeline` 中,在数据帧的列上操作,并且有超参数需要使用训练、交叉验证和测试数据子集来选择。同样的一般原则,也可以用于这些其他算法来建模分类和回归问题。

这些是监督学习的例子。当一些或所有目标值未知时会发生什么?接下来的章节将探讨在这种情况下可以采取的措施。


# 第五章:使用 K 均值聚类进行异常检测

分类和回归是机器学习中强大且深入研究的技术。第四章展示了如何使用分类器作为未知值的预测器。但是有一个问题:为了预测新数据的未知值,我们必须知道许多先前见过的示例的目标值。只有当数据科学家知道自己在寻找什么并且可以提供大量示例,输入才能产生已知输出时,分类器才能提供帮助。这些被统称为*监督学习*技术,因为它们的学习过程为输入中的每个示例接收正确的输出值。

然而,有时对于某些或所有示例,正确的输出是未知的。考虑将电子商务网站的客户根据其购物习惯和喜好分组的问题。输入特征包括他们的购买、点击、人口统计信息等。输出应该是客户的分组:也许一个组将代表注重时尚的购买者,另一个组可能对应于价格敏感的猎奇者,等等。

如果要为每个新客户确定这个目标标签,您将很快在应用监督学习技术(如分类器)时遇到问题:您事先不知道谁应被认为是时尚意识强的人。实际上,您甚至不确定“时尚意识强”的定义是否能够有效地将网站的客户分组起来!

幸运的是,*无监督学习*技术可以提供帮助。这些技术不会学习预测目标值,因为没有目标值可用。然而,它们可以学习数据中的结构,并找到相似输入的分组,或者学习哪些类型的输入可能发生,哪些不可能。本章将介绍使用 MLlib 中的聚类实现的无监督学习。具体来说,我们将使用 K 均值聚类算法来识别网络流量数据中的异常。异常检测通常用于发现欺诈、检测网络攻击或发现服务器或其他传感器设备中的问题。在这些情况下,能够发现以前从未见过的新类型异常是非常重要的——新形式的欺诈、入侵和服务器故障模式。无监督学习技术在这些情况下很有用,因为它们可以学习输入数据通常的外观,并因此在新数据与过去数据不同之时进行检测。这样的新数据不一定是攻击或欺诈;它只是不寻常,因此值得进一步调查。

我们将从 K 均值聚类算法的基础开始。接着介绍 KDD Cup 1999 数据集。然后使用 PySpark 创建我们的第一个 K 均值模型。然后我们将讨论在实施 K 均值算法时确定好的*k*值(簇的数量)的方法。接下来,我们通过实现一位热编码方法来改进我们的模型,通过归一化输入特征和使用先前被丢弃的分类特征。最后,我们将回顾熵指标并探索一些我们模型的结果。

# K 均值聚类

异常检测的固有问题,顾名思义,是寻找不寻常的事物。如果我们已经知道数据集中“异常”的含义,我们可以很容易地通过监督学习检测数据中的异常。算法会接收标记为“正常”和“异常”的输入,并学会区分两者。然而,异常的本质在于它们是未知的未知。换句话说,一旦观察并理解了的异常就不再是异常了。

聚类是最知名的无监督学习类型。聚类算法试图在数据中找到自然的分组。那些相似但不同于其他数据点的数据点可能代表一个有意义的分组,因此聚类算法试图将这样的数据放入同一簇中。

K 均值聚类可能是最广泛使用的聚类算法。它试图在数据集中检测*k*个簇,其中*k*由数据科学家给定。*k*是模型的超参数,合适的值将取决于数据集。事实上,在本章中选择一个合适的*k*值将是一个核心情节点。

当数据集包含客户活动或交易等信息时,“相似”是什么意思?K 均值需要一个数据点之间距离的概念。通常使用简单的欧氏距离来测量 K 均值中数据点之间的距离,正如现在这篇文章中所做的一样,这也是 Spark MLlib 支持的两种距离函数之一,另一种是余弦距离。欧氏距离适用于所有特征都是数值的数据点。“相似”的点是那些中间距离较小的点。

对于 K 均值来说,一个簇就是一个点:所有构成该簇的点的中心。事实上,这些仅仅是包含所有数值特征的特征向量,并且可以称为向量。然而,在这里把它们看作点可能更直观,因为它们在欧几里得空间中被视为点。

此中心称为聚类的*质心*,是点的算术平均值,因此得名 K-*means*。算法首先选择一些数据点作为初始的聚类质心。然后将每个数据点分配给最近的质心。然后对于每个簇,计算新的聚类质心作为刚分配到该簇的数据点的平均值。这个过程重复进行。

现在我们将介绍一个用例,描述 K 均值聚类如何帮助我们识别网络中潜在的异常活动。

# 发现异常网络流量

网络攻击越来越频繁地出现在新闻中。一些攻击试图用网络流量淹没计算机,以排挤合法流量。但在其他情况下,攻击试图利用网络软件中的漏洞来未经授权地访问计算机。当计算机被大量流量轰炸时很明显,但检测利用漏洞可以像在大量网络请求的巨大干草堆中寻找针一样困难。

一些攻击行为遵循已知模式。例如,快速连续访问机器上的每个端口并非任何正常软件程序所需。然而,这是攻击者寻找可能易受攻击的计算机服务的典型第一步。

如果你计算远程主机在短时间内访问的不同端口数量,你可能会得到一个相当好的预测端口扫描攻击的特征。几个端口可能是正常的;数百个表示攻击。检测网络连接其他特征的其他类型攻击也是如此——发送和接收的字节数、TCP 错误等。

那么未知的未知情况呢?最大的威胁可能是从未被检测和分类的威胁。检测潜在网络入侵的一部分是检测异常情况。这些连接不被认为是攻击,但与过去观察到的连接不相似。

在这里,像 K 均值这样的无监督学习技术可以用来检测异常网络连接。K 均值可以根据每个连接的统计信息进行聚类。结果的聚类本身并不有趣,但它们集体定义了与过去连接类似的连接类型。与聚类不接近的任何连接可能是异常的。聚类之所以有趣,是因为它们定义了正常连接的区域;其他一切都是不寻常的,可能是异常的。

## KDD Cup 1999 数据集

[KDD Cup](https://oreil.ly/UtYd9) 是由计算机协会的一个特别兴趣小组组织的年度数据挖掘竞赛。每年,他们会提出一个机器学习问题,并提供一个数据集,邀请研究人员提交详细描述他们对问题的最佳解决方案的论文。1999 年的主题是网络入侵,数据集仍然可以在[KDD 网站](https://oreil.ly/ezBDa)上找到。我们需要从该网站下载*kddcupdata.data.gz*和*kddcup.info*文件。本章的其余部分将演示如何使用 Spark 构建系统来检测异常网络流量。

不要使用此数据集构建真实的网络入侵系统!该数据并不一定反映当时的真实网络流量——即使它反映了,它也反映了 20 多年前的流量模式。

幸运的是,组织者已经将原始网络数据处理成关于各个网络连接的摘要信息。数据集大小约为 708 MB,包含约 490 万个连接。对于我们的目的来说,这是一个很大甚至是庞大的数据集,绝对足够了。对于每个连接,数据集包含诸如发送的字节数、登录尝试、TCP 错误等信息。每个连接是一个 CSV 格式的数据行,包含 38 个特征。特征信息和顺序可以在*kddcup.info*文件中找到。

解压*kddcup.data.gz*数据文件并将其复制到您的存储中。例如,假设文件位于*data/kddcup.data*。让我们看看数据的原始形式:

head -n 1 data/kddcup.data

...

0,tcp,http,SF,215,45076,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,...


例如,这个连接是一个 TCP 连接到 HTTP 服务——发送了 215 字节,接收了 45,706 字节。用户已登录等等。

许多特征是计数,例如在第 17 列中列出的`num_file_creations`,如*kddcup.info*文件中所示。许多特征取值为 0 或 1,表示行为的存在或不存在,例如在第 15 列中的`su_attempted`。它们看起来像是来自第四章的独热编码分类特征,但并非以同样的方式分组和相关。每个特征都像是一个是/否特征,因此可以说是一个分类特征。通常情况下,将分类特征转换为数字并视其具有顺序是不总是有效的。但是,在二元分类特征的特殊情况下,在大多数机器学习算法中,将其映射为取值为 0 和 1 的数值特征将效果很好。

其余的是像`dst_host_srv_rerror_rate`这样的比率,位于倒数第二列,并且取值范围从 0.0 到 1.0,包括 0.0 和 1.0。

有趣的是,标签位于最后一个字段中。大多数连接被标记为`normal.`,但有些被识别为各种类型的网络攻击的示例。这些将有助于学习区分已知攻击和正常连接,但问题在于异常检测和发现潜在的新攻击和未知攻击。对于我们的目的,这个标签将大部分被忽略。

# 对聚类的初步尝试

打开`pyspark-shell`,并将 CSV 数据加载为数据帧。这又是一个没有头部信息的 CSV 文件。需要按照附带的*kddcup.info*文件中给出的列名提供列名。

data_without_header = spark.read.option("inferSchema", True).
option("header", False).
csv("data/kddcup.data")

column_names = [ "duration", "protocol_type", "service", "flag",
"src_bytes", "dst_bytes", "land", "wrong_fragment", "urgent",
"hot", "num_failed_logins", "logged_in", "num_compromised",
"root_shell", "su_attempted", "num_root", "num_file_creations",
"num_shells", "num_access_files", "num_outbound_cmds",
"is_host_login", "is_guest_login", "count", "srv_count",
"serror_rate", "srv_serror_rate", "rerror_rate", "srv_rerror_rate",
"same_srv_rate", "diff_srv_rate", "srv_diff_host_rate",
"dst_host_count", "dst_host_srv_count",
"dst_host_same_srv_rate", "dst_host_diff_srv_rate",
"dst_host_same_src_port_rate", "dst_host_srv_diff_host_rate",
"dst_host_serror_rate", "dst_host_srv_serror_rate",
"dst_host_rerror_rate", "dst_host_srv_rerror_rate",
"label"]

data = data_without_header.toDF(*column_names)


从探索数据集开始。数据中有哪些标签,每个标签有多少个?以下代码简单地按标签计数并按计数降序打印结果:

from pyspark.sql.functions import col
data.select("label").groupBy("label").count().
orderBy(col("count").desc()).show(25)

...
+----------------+-------+
| label| count|
+----------------+-------+
| smurf.|2807886|
| neptune.|1072017|
| normal.| 972781|
| satan.| 15892|
...
| phf.| 4|
| perl.| 3|
| spy.| 2|
+----------------+-------+


有 23 个不同的标签,最频繁的是`smurf.`和`neptune.`攻击。

注意,数据中包含非数值特征。例如,第二列可能是`tcp`、`udp`或`icmp`,但是 K 均值聚类需要数值特征。最终的标签列也是非数值的。因此,在开始时,这些特征将被简单地忽略。

除此之外,创建数据的 K 均值聚类与第四章中看到的模式相同。`VectorAssembler`创建特征向量,`KMeans`实现从特征向量创建模型,而`Pipeline`则将它们全部串联起来。从生成的模型中,可以提取并检查聚类中心。

from pyspark.ml.feature import VectorAssembler
from pyspark.ml.clustering import KMeans, KMeansModel
from pyspark.ml import Pipeline

numeric_only = data.drop("protocol_type", "service", "flag").cache()

assembler = VectorAssembler().setInputCols(numeric_only.columns[:-1]).
setOutputCol("featureVector")

kmeans = KMeans().setPredictionCol("cluster").setFeaturesCol("featureVector")

pipeline = Pipeline().setStages([assembler, kmeans])
pipeline_model = pipeline.fit(numeric_only)
kmeans_model = pipeline_model.stages[1]

from pprint import pprint
pprint(kmeans_model.clusterCenters())

...
[array([4.83401949e+01, 1.83462155e+03, 8.26203190e+02, 5.71611720e-06,
6.48779303e-04, 7.96173468e-06...]),
array([1.0999000e+04, 0.0000000e+00, 1.3099374e+09, 0.0000000e+00,
0.0000000e+00, 0.0000000e+00,...])]


不容易直观地解释这些数字,但每个数字代表模型生成的一个聚类中心(也称为质心)。这些值是每个数值输入特征的质心坐标。

打印了两个向量,这意味着 K 均值将*k*=2 个群集适合于数据。对于已知至少具有 23 种不同连接类型的复杂数据集来说,这几乎肯定不足以准确建模数据中的不同分组。

这是使用给定标签获取直观感觉的好机会,以了解这两个聚类中的内容,通过计算每个聚类内的标签数量。

with_cluster = pipeline_model.transform(numeric_only)

with_cluster.select("cluster", "label").groupBy("cluster", "label").count().
orderBy(col("cluster"), col("count").desc()).show(25)

...
+-------+----------------+-------+
|cluster| label| count|
+-------+----------------+-------+
| 0| smurf.|2807886|
| 0| neptune.|1072017|
| 0| normal.| 972781|
| 0| satan.| 15892|
| 0| ipsweep.| 12481|
...
| 0| phf.| 4|
| 0| perl.| 3|
| 0| spy.| 2|
| 1| portsweep.| 1|
+-------+----------------+-------+


结果显示聚类并没有提供任何帮助。只有一个数据点最终进入了群集 1!

# 选择 k 值

显然,两个聚类是不够的。对于这个数据集来说,适当的聚类数量是多少?显然,数据中有 23 种不同的模式,因此*k*至少应该是 23,甚至可能更多。通常,会尝试多个*k*值来找到最佳值。但是,“最佳”是什么?

如果每个数据点都接近其最近的质心,则可以认为聚类是好的,其中“接近”由欧氏距离定义。这是评估聚类质量的简单常见方法,通过所有点上这些距离的均值,或者有时是距离平方的均值。实际上,`KMeansModel`提供了一个`ClusteringEvaluator`方法,可以计算平方距离的和,并且可以轻松地用于计算平均平方距离。

对几个*k*值手动评估聚类成本是相当简单的。请注意,此代码可能需要运行 10 分钟或更长时间:

from pyspark.sql import DataFrame
from pyspark.ml.evaluation import ClusteringEvaluator

from random import randint

def clustering_score(input_data, k):
input_numeric_only = input_data.drop("protocol_type", "service", "flag")
assembler = VectorAssembler().setInputCols(input_numeric_only.columns[:-1]).
setOutputCol("featureVector")
kmeans = KMeans().setSeed(randint(100,100000)).setK(k).
setPredictionCol("cluster").
setFeaturesCol("featureVector")
pipeline = Pipeline().setStages([assembler, kmeans])
pipeline_model = pipeline.fit(input_numeric_only)

evaluator = ClusteringEvaluator(predictionCol='cluster',
                                featuresCol="featureVector")
predictions = pipeline_model.transform(numeric_only)
score = evaluator.evaluate(predictions)
return score

for k in list(range(20,100, 20)):
print(clustering_score(numeric_only, k)) 1

...
(20,6.649218115128446E7)
(40,2.5031424366033625E7)
(60,1.027261913057096E7)
(80,1.2514131711109027E7)
(100,7235531.565096531)


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO1-1)

分数将使用科学计数法显示。

打印的结果显示随着*k*的增加,分数下降。请注意,分数使用科学计数法显示;第一个值超过了 10⁷,而不仅仅是略高于 6。

同样,您的数值将会有所不同。聚类依赖于随机选择的初始质心集。

然而,这一点是显而易见的。随着添加更多的簇,总是可以将数据点放置在最近的质心附近。事实上,如果*k*被选为数据点的数量,那么平均距离将为 0,因为每个点将成为自己的一个包含一个点的簇!

更糟糕的是,在先前的结果中,*k*=80 的距离比*k*=60 的距离要大。这不应该发生,因为更高的*k*至少应该允许与更低的*k*一样好的聚类。问题在于,对于给定的*k*,K-means 不一定能找到最优的聚类。它的迭代过程可以从一个随机起始点收敛到一个局部最小值,这可能很好但不是最优的。

即使在使用更智能的方法选择初始质心时,这一点仍然是真实的。[K-means++和 K-means||](https://oreil.ly/zes8d)是选择算法的变体,更有可能选择多样化、分离的质心,并更可靠地导致良好的聚类。事实上,Spark MLlib 实现了 K-means||。然而,所有这些算法在选择时仍然具有随机性,并不能保证最优的聚类。

选择的随机起始簇集合*k*=80 可能导致特别不理想的聚类,或者在达到局部最优之前可能会提前停止。

我们可以通过延长迭代时间来改善它。算法通过`setTol`设定了一个阈值,用于控制被认为是显著的簇质心移动的最小量;较低的值意味着 K-means 算法将允许质心继续移动更长时间。通过`setMaxIter`增加最大迭代次数也可以防止算法在可能的情况下过早停止,但可能会增加计算量。

def clustering_score_1(input_data, k):
input_numeric_only = input_data.drop("protocol_type", "service", "flag")
assembler = VectorAssembler().
setInputCols(input_numeric_only.columns[:-1]).
setOutputCol("featureVector")
kmeans = KMeans().setSeed(randint(100,100000)).setK(k).setMaxIter(40).\ 1
setTol(1.0e-5).\ 2
setPredictionCol("cluster").setFeaturesCol("featureVector")
pipeline = Pipeline().setStages([assembler, kmeans])
pipeline_model = pipeline.fit(input_numeric_only)
#
evaluator = ClusteringEvaluator(predictionCol='cluster',
featuresCol="featureVector")
predictions = pipeline_model.transform(numeric_only)
score = evaluator.evaluate(predictions)
#
return score

for k in list(range(20,101, 20)):
print(k, clustering_score_1(numeric_only, k))


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO2-1)

从默认的 20 增加。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO2-2)

从默认的 1.0e-4 减少。

这一次,至少得分是一致递减的:

(20,1.8041795813813403E8)
(40,6.33056876207124E7)
(60,9474961.544965891)
(80,9388117.93747141)
(100,8783628.926311461)


我们希望找到一个点,在这个点之后增加*k*不会显著减少得分,或者在*k*与得分之间的图形中找到一个“拐点”,该图形通常是递减的,但最终会趋于平缓。在这里,看起来过了 100 之后递减明显。*k*的合适值可能超过 100。

# 使用 SparkR 进行可视化

此时,重新进行聚类之前了解更多关于数据的信息可能会有帮助。特别是查看数据点的图表可能会有所帮助。

Spark 本身没有用于可视化的工具,但流行的开源统计环境[R](https://www.r-project.org)具有用于数据探索和数据可视化的库。此外,Spark 还通过[SparkR](https://oreil.ly/XX0Q9)提供与 R 的基本集成。本简短部分将演示使用 R 和 SparkR 对数据进行聚类和探索聚类。

SparkR 是本书中贯穿始终的 `spark-shell` 的一个变体,可以通过命令 `sparkR` 调用。它运行一个本地 R 解释器,就像 `spark-shell` 运行 Scala shell 的变体作为本地进程一样。运行 `sparkR` 的机器需要一个本地安装的 R,Spark 不包含在内。例如,在 Ubuntu 等 Linux 发行版上可以通过 `sudo apt-get install r-base` 安装它,或者在 macOS 上可以通过 [Homebrew](http://brew.sh) 使用 `brew install R` 安装。

SparkR 是一个类似于 R 的命令行 shell 环境。要查看可视化效果,需要在能够显示图片的类 IDE 环境中运行这些命令。[RStudio](https://www.rstudio.com) 是 R 的 IDE(也适用于 SparkR);它运行在桌面操作系统上,因此只有在本地实验 Spark 而不是在集群上时才能使用它。

如果你在本地运行 Spark,请[下载](https://oreil.ly/JZGQm)免费版的 RStudio 并安装它。如果不是,那么本示例的大部分仍可在命令行上使用 `sparkR` 运行,例如在集群上,尽管无法以此方式显示可视化结果。

如果通过 RStudio 运行,请启动 IDE 并配置 `SPARK_HOME` 和 `JAVA_HOME`,如果本地环境尚未设置它们,则设置为指向 Spark 和 JDK 安装目录:

Sys.setenv(SPARK_HOME = "/path/to/spark") 1
Sys.setenv(JAVA_HOME = "/path/to/java") library(SparkR, lib.loc = c(file.path(Sys.getenv("SPARK_HOME"), "R", "lib"))) sparkR.session(master = "local[*]",
sparkConfig = list(spark.driver.memory = "4g"))


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#comarker1a)

当然,需要用实际路径替换。

注意,如果在命令行上运行 `sparkR`,则不需要这些步骤。相反,它接受命令行配置参数,如 `--driver-memory`,就像 `spark-shell` 一样。

SparkR 是围绕相同的 DataFrame 和 MLlib API 的 R 语言包装器,这些 API 已在本章中展示过。因此,可以重新创建数据的 K-means 简单聚类:

clusters_data <- read.df("/path/to/kddcup.data", "csv", 1
inferSchema = "true", header = "false") colnames(clusters_data) <- c( 2
"duration", "protocol_type", "service", "flag", "src_bytes", "dst_bytes", "land", "wrong_fragment", "urgent", "hot", "num_failed_logins", "logged_in", "num_compromised", "root_shell", "su_attempted", "num_root", "num_file_creations", "num_shells", "num_access_files", "num_outbound_cmds", "is_host_login", "is_guest_login", "count", "srv_count", "serror_rate", "srv_serror_rate", "rerror_rate", "srv_rerror_rate", "same_srv_rate", "diff_srv_rate", "srv_diff_host_rate", "dst_host_count", "dst_host_srv_count", "dst_host_same_srv_rate", "dst_host_diff_srv_rate", "dst_host_same_src_port_rate", "dst_host_srv_diff_host_rate", "dst_host_serror_rate", "dst_host_srv_serror_rate", "dst_host_rerror_rate", "dst_host_srv_rerror_rate", "label")
numeric_only <- cache(drop(clusters_data, 3
c("protocol_type", "service", "flag", "label")))
kmeans_model <- spark.kmeans(numeric_only, ~ ., 4
k = 100, maxIter = 40, initMode = "k-means||")


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#comarker1b)

替换为 *kddcup.data* 的路径。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#comarker2)

命名列。

![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#comarker3)

再次删除非数值列。

![4](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#comarker4)

`~ .` 表示所有列。

从这里开始,为每个数据点分配一个集群非常简单。上述操作展示了使用 SparkR API 的用法,这些 API 自然对应于核心 Spark API,但表现为类似 R 语法的 R 库。实际的聚类是使用同一基于 JVM 的 Scala 语言实现的 MLlib 执行的。这些操作实际上是对不在 R 中执行的分布式操作的一种 *句柄* 或远程控制。

R 有其自己丰富的分析库集合,以及其自己类似的 dataframe 概念。因此,有时将一些数据拉入 R 解释器以使用这些与 Spark 无关的本地 R 库是很有用的。

当然,R 及其库不是分布式的,因此不可能将 4,898,431 个数据点的整个数据集导入 R。不过,只导入一个样本非常容易:

clustering <- predict(kmeans_model, numeric_only) clustering_sample <- collect(sample(clustering, FALSE, 0.01)) 1

str(clustering_sample)
... 'data.frame': 48984 obs. of 39 variables:
$ duration : int 0 0 0 0 0 0 0 0 0 0 ... $ src_bytes : int 181 185 162 254 282 310 212 214 181 ... $ dst_bytes : int 5450 9020 4528 849 424 1981 2917 3404 ... $ land : int 0 0 0 0 0 0 0 0 0 0 ... ...
$ prediction : int 33 33 33 0 0 0 0 0 33 33 ...


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO3-1)

无重复抽样的 1%样本

`clustering_sample`实际上是一个本地的 R 数据框,而不是 Spark DataFrame,因此可以像 R 中的任何其他数据一样进行操作。上面的`str`显示了数据框的结构。

例如,可以提取聚类分配,然后显示关于分配分布的统计信息:

clusters <- clustering_sample["prediction"] 1
data <- data.matrix(within(clustering_sample, rm("prediction"))) 2

table(clusters)
... clusters
0 11 14 18 23 25 28 30 31 33 36 ... 47294 3 1 2 2 308 105 1 27 1219 15 ...


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO4-1)

只有聚类分配列

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO4-2)

除了聚类分配以外的所有内容。

例如,这显示大多数点都属于聚类 0。虽然在 R 中可以对这些数据进行更多操作,但是这超出了本书的范围。

要可视化数据,需要一个名为`rgl`的库。只有在 RStudio 中运行这个示例时,它才能正常工作。首先,安装(仅需一次)并加载这个库:

install.packages("rgl")
library(rgl)


请注意,R 可能会提示您下载其他软件包或编译器工具来完成安装,因为安装该软件包意味着编译其源代码。

这个数据集是 38 维的。为了在随机投影中可视化它,最多必须将其投影到三维空间中:

random_projection <- matrix(data = rnorm(3ncol(data)), ncol = 3) 1
random_projection_norm <-
random_projection / sqrt(rowSums(random_projection
random_projection))
projected_data <- data.frame(data %*% random_projection_norm) 2


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO5-1)

进行一个随机的 3 维投影并进行归一化。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO5-2)

投影并创建一个新的数据框。

通过选择三个随机单位向量并将数据投影到它们上面,这将从一个 38 维的数据集创建一个 3 维的数据集。这是一种简单粗糙的降维方法。当然,还有更复杂的降维算法,如主成分分析或奇异值分解。这些算法在 R 中也有实现,但运行时间更长。在这个例子中,为了可视化的目的,随机投影可以更快地达到类似的结果。

最后,可以在交互式 3D 可视化中绘制聚类点:

num_clusters <- max(clusters)
palette <- rainbow(num_clusters)
colors = sapply(clusters, function(c) palette[c])
plot3d(projected_data, col = colors, size = 10)


请注意,这将需要在支持`rgl`库和图形的环境中运行 RStudio。

在图 5-1 中的结果可视化显示了三维空间中的数据点。许多点重叠在一起,结果稀疏且难以解释。然而,可视化的主要特征是其 L 形状。点似乎沿着两个不同的维度变化,而其他维度变化较小。

这是有道理的,因为数据集有两个特征的量级比其他特征大得多。而大多数特征的值在 0 到 1 之间,而字节发送和字节接收特征的值则在 0 到数万之间变化。因此,点之间的欧氏距离几乎完全由这两个特征决定。其他特征几乎不存在!因此,通过标准化消除这些规模差异非常重要,以便让特征处于近乎相等的地位。

![aaps 0501](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_0501.png)

###### 图 5-1\. 随机 3-D 投影

# 特征标准化

我们可以通过将每个特征转换为标准分来标准化每个特征。这意味着从每个值中减去特征的值的平均值,并除以标准差,如标准分公式所示:

<math alttext="n o r m a l i z e d Subscript i Baseline equals StartFraction f e a t u r e Subscript i Baseline minus mu Subscript i Baseline Over sigma Subscript i Baseline EndFraction" display="block"><mrow><mi>n</mi> <mi>o</mi> <mi>r</mi> <mi>m</mi> <mi>a</mi> <mi>l</mi> <mi>i</mi> <mi>z</mi> <mi>e</mi> <msub><mi>d</mi> <mi>i</mi></msub> <mo>=</mo> <mfrac><mrow><mi>f</mi><mi>e</mi><mi>a</mi><mi>t</mi><mi>u</mi><mi>r</mi><msub><mi>e</mi> <mi>i</mi></msub> <mo>-</mo><msub><mi>μ</mi> <mi>i</mi></msub></mrow> <msub><mi>σ</mi> <mi>i</mi></msub></mfrac></mrow></math>

实际上,减去均值对聚类没有影响,因为这种减法实际上是以相同方向和相同数量移动所有数据点。这并不影响点与点之间的欧氏距离。

MLlib 提供了`StandardScaler`,这是一种可以执行此类标准化并轻松添加到聚类管道中的组件。

我们可以在更高范围的*k*上使用标准化数据运行相同的测试:

from pyspark.ml.feature import StandardScaler

def clustering_score_2(input_data, k):
input_numeric_only = input_data.drop("protocol_type", "service", "flag")
assembler = VectorAssembler().
setInputCols(input_numeric_only.columns[:-1]).
setOutputCol("featureVector")
scaler = StandardScaler().setInputCol("featureVector").
setOutputCol("scaledFeatureVector").
setWithStd(True).setWithMean(False)
kmeans = KMeans().setSeed(randint(100,100000)).
setK(k).setMaxIter(40).
setTol(1.0e-5).setPredictionCol("cluster").
setFeaturesCol("scaledFeatureVector")
pipeline = Pipeline().setStages([assembler, scaler, kmeans])
pipeline_model = pipeline.fit(input_numeric_only)
#
evaluator = ClusteringEvaluator(predictionCol='cluster',
featuresCol="scaledFeatureVector")
predictions = pipeline_model.transform(numeric_only)
score = evaluator.evaluate(predictions)
#
return score

for k in list(range(60, 271, 30)):
print(k, clustering_score_2(numeric_only, k))
...
(60,1.2454250178069293)
(90,0.7767730051608682)
(120,0.5070473497003614)
(150,0.4077081720067704)
(180,0.3344486714980788)
(210,0.276237617334138)
(240,0.24571877339169032)
(270,0.21818167354866858)


这有助于使维度更平等,并且点之间的绝对距离(因此成本)在绝对数值上要小得多。然而,上述输出还没有提供一个明显的*k*值,超过这个值增加对成本的改进很少。

另一个对标准化数据点进行的 3-D 可视化揭示了更丰富的结构,正如预期的那样。一些点在一个方向上以规则的离散间隔分布;这些很可能是数据中离散维度的投影,比如计数。有 100 个聚类,很难辨别哪些点来自哪些聚类。一个大聚类似乎占主导地位,许多聚类对应于小而紧凑的子区域(其中一些在整个 3-D 可视化的放大细节中被省略)。结果显示在图 5-2 中,虽然并未必然推进分析,但是作为一个有趣的合理性检查。

![aaps 0502](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_0502.png)

###### 图 5-2\. 随机 3-D 投影,标准化

# 类别变量

标准化是向前迈出的一步,但可以做更多工作来改进聚类。特别是,一些特征完全被忽略,因为它们不是数字型的。这样做等于丢失了宝贵的信息。以某种形式将它们加回来,应该会产生更为明智的聚类。

之前,由于非数值特征无法与 MLlib 中 K-means 使用的欧氏距离函数一起使用,因此排除了三个分类特征。这与“随机森林”中所述的问题相反,其中数值特征用于表示分类值,但是希望使用分类特征。

分类特征可以通过使用一位有效编码转换为几个二进制指示特征,这可以视为数值维度。例如,第二列包含协议类型:`tcp`、`udp`或`icmp`。这个特征可以被看作是*三*个特征,就好像数据集中有“是 TCP”、“是 UDP”和“是 ICMP”一样。单个特征值`tcp`可能会变成`1,0,0`;`udp`可能会变成`0,1,0`;依此类推。

在这里,MLlib 提供了实现此转换的组件。事实上,像`protocol_type`这样的字符串值特征的一位有效编码实际上是一个两步过程。首先,使用`StringIndexer`将字符串值转换为整数索引(如 0、1、2 等)。然后,使用`OneHotEncoder`将这些整数索引编码成一个向量。这两个步骤可以看作是一个小的`Pipeline`。

from pyspark.ml.feature import OneHotEncoder, StringIndexer

def one_hot_pipeline(input_col):
indexer = StringIndexer().setInputCol(input_col).
setOutputCol(input_col + "-_indexed")
encoder = OneHotEncoder().setInputCol(input_col + "indexed").
setOutputCol(input_col + "_vec")
pipeline = Pipeline().setStages([indexer, encoder])
return pipeline, input_col + "_vec" 1


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO6-1)

返回管道和输出向量列的名称

这种方法生成一个`Pipeline`,可以作为整体聚类流水线的一个组件添加;流水线可以组合。现在要做的就是确保将新的向量输出列添加到`VectorAssembler`的输出中,并像以前一样进行缩放、聚类和评估。此处省略了源代码以保持简洁,但可以在本章节附带的存储库中找到。

(60,39.739250062068685)
(90,15.814341529964691)
(120,3.5008631362395413)
(150,2.2151974068685547)
(180,1.587330730808905)
(210,1.3626704802348888)
(240,1.1202477806210747)
(270,0.9263659836264369)


这些示例结果表明,可能是*k*=180,这个值使得分数略微趋于平稳。至少现在聚类已经使用了所有的输入特征。

# 使用带熵的标签

早些时候,我们使用每个数据点的给定标签来快速检查聚类质量的合理性。这个概念可以进一步形式化,并用作评估聚类质量和因此选择*k*的替代手段。

标签告诉我们关于每个数据点真实特性的一些信息。一个好的聚类似乎应该与这些人工标记的标签一致。它应该将共享标签的点聚在一起,并且不应该将许多不同标签的点混在一起。它应该生成具有相对均匀标签的聚类。

您可能还记得“随机森林”中关于同质性的指标:基尼不纯度和熵。这些都是每个聚类中标签比例的函数,并产生一个在标签倾向于少数或一个标签时较低的数字。这里将使用熵进行说明:

from math import log

def entropy(counts):
values = [c for c in counts if (c > 0)]
n = sum(values)
p = [v/n for v in values]
return sum([-1*(p_v) * log(p_v) for p_v in p])


一个好的聚类应该具有标签集合是同质的聚类,因此具有低熵。因此,可以使用熵的加权平均作为聚类得分:

from pyspark.sql import functions as fun
from pyspark.sql import Window

cluster_label = pipeline_model.
transform(data).
select("cluster", "label") 1

df = cluster_label.
groupBy("cluster", "label").
count().orderBy("cluster") 2

w = Window.partitionBy("cluster")

p_col = df['count'] / fun.sum(df['count']).over(w)
with_p_col = df.withColumn("p_col", p_col)

result = with_p_col.groupBy("cluster").
agg(-fun.sum(col("p_col") * fun.log2(col("p_col")))
.alias("entropy"),
fun.sum(col("count"))
.alias("cluster_size"))

result = result.withColumn('weightedClusterEntropy',
col('entropy') * col('cluster_size')) 3

weighted_cluster_entropy_avg = result.
agg(fun.sum(
col('weightedClusterEntropy'))).
collect()
weighted_cluster_entropy_avg[0][0]/data.count()


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO7-1)

预测每个数据点的聚类。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO7-2)

统计每个聚类的标签

![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO7-3)

由聚类大小加权的平均熵。

与之前一样,此分析可用于获取*k*的合适值的某些想法。熵不一定会随*k*的增加而减少,因此可以寻找局部最小值。在这里,结果再次表明*k*=180 是一个合理的选择,因为其得分实际上比 150 和 210 低:

(60,0.03475331900669869)
(90,0.051512668026335535)
(120,0.02020028911919293)
(150,0.019962563512905682)
(180,0.01110240886325257)
(210,0.01259738444250231)
(240,0.01357435960663116)
(270,0.010119881917660544)


# 聚类活动

最后,我们可以有信心地将完整的归一化数据集聚类到*k*=180。同样,我们可以打印每个集群的标签,以便对得到的聚类结果有所了解。每个集群似乎只由一种类型的攻击主导,并且只包含少量类型:

pipeline_model = fit_pipeline_4(data, 180) 1
count_by_cluster_label = pipeline_model.transform(data).
select("cluster", "label").
groupBy("cluster", "label").
count().orderBy("cluster", "label")
count_by_cluster_label.show()

...
+-------+----------+------+
|cluster| label| count|
+-------+----------+------+
| 0| back.| 324|
| 0| normal.| 42921|
| 1| neptune.| 1039|
| 1|portsweep.| 9|
| 1| satan.| 2|
| 2| neptune.|365375|
| 2|portsweep.| 141|
| 3|portsweep.| 2|
| 3| satan.| 10627|
| 4| neptune.| 1033|
| 4|portsweep.| 6|
| 4| satan.| 1|
...


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_anomaly_detection__span_class__keep_together__with_k_means_clustering__span__CO8-1)

参见`fit_pipeline_4`定义的相关源代码。

现在我们可以制作一个真正的异常检测器。异常检测相当于测量新数据点到其最近质心的距离。如果此距离超过某个阈值,则为异常。此阈值可能选择为已知数据中第 100 个最远数据点的距离:

import numpy as np

from pyspark.spark.ml.linalg import Vector, Vectors
from pyspark.sql.functions import udf

k_means_model = pipeline_model.stages[-1]
centroids = k_means_model.clusterCenters

clustered = pipeline_model.transform(data)

def dist_func(cluster, vec):
return float(np.linalg.norm(centroids[cluster] - vec))
dist = udf(dist_func)

threshold = clustered.select("cluster", "scaledFeatureVector").
withColumn("dist_value",
dist(col("cluster"), col("scaledFeatureVector"))).
orderBy(col("dist_value").desc()).take(100)


最后一步可以是在所有新数据点到达时应用此阈值。例如,Spark Streaming 可以用于将此函数应用于从 Kafka 或云存储文件等来源接收的小批量输入数据。超过阈值的数据点可能会触发发送电子邮件或更新数据库的警报。

# 下一步该去哪里

`KMeansModel`本身就是异常检测系统的核心。前面的代码演示了如何将其应用于数据以检测异常。这段代码也可以在[Spark Streaming](https://oreil.ly/UHHBR)中使用,以几乎实时地对新数据进行评分,并可能触发警报或审核。

MLlib 还包括一种称为`StreamingKMeans`的变体,它可以在`StreamingKMeansModel`中增量地更新聚类。我们可以使用它来持续学习,大致了解新数据如何影响聚类,而不仅仅是评估新数据与现有聚类的关系。它也可以与 Spark Streaming 集成。但是,它尚未针对新的基于 DataFrame 的 API 进行更新。

此模型只是一个简单的模型。例如,在此示例中使用欧几里得距离,因为它是 Spark MLlib 当前支持的唯一距离函数。未来可能会使用能更好地考虑特征分布和相关性的距离函数,例如[马氏距离](https://oreil.ly/PKG7A)。

还有更复杂的[集群质量评估指标](https://oreil.ly/9yE9P),即使没有标签,也可以应用于选择*k*,例如[轮廓系数](https://oreil.ly/LMN1h)。这些指标通常评估的不仅是一个集群内点的接近度,还包括点到其他集群的接近度。最后,可以应用不同的模型来替代简单的 K 均值聚类;例如,[高斯混合模型](https://oreil.ly/KTgD6)或[DBSCAN](https://oreil.ly/xlshs)可以捕捉数据点与集群中心之间更微妙的关系。Spark MLlib 已经实现了[高斯混合模型](https://oreil.ly/LG84u),其他模型的实现可能会在未来出现在 Spark MLlib 或其他基于 Spark 的库中。

当然,聚类不仅仅用于异常检测。事实上,它更常与实际集群关系重要的用例相关联!例如,聚类还可以用于根据客户的行为、偏好和属性进行分组。每个集群本身可能代表一种有用的可区分客户类型。这是一种更加数据驱动的客户分段方式,而不是依赖于任意的通用分割,如“年龄 20-34 岁”和“女性”。


# 第六章:使用 LDA 和 Spark NLP 理解维基百科

近年来,随着非结构化文本数据的增长,获取相关和期望的信息变得困难。语言技术提供了强大的方法,可以用来挖掘文本数据并获取我们所寻找的信息。在本章中,我们将使用 PySpark 和 Spark NLP(自然语言处理)库使用一种这样的技术——主题建模。具体来说,我们将使用潜在狄利克雷算法(LDA)来理解维基百科文档的数据集。

*主题建模*,是自然语言处理中最常见的任务之一,是一种用于数据建模的统计方法,有助于发现文档集合中存在的潜在主题。从数百万个文档中提取主题分布在许多方面都很有用,例如,识别某个产品或所有产品投诉的原因,或在新闻文章中识别主题。主题建模的最流行算法是 LDA。它是一个生成模型,假设文档由主题分布表示。主题本身由词语分布表示。PySpark MLlib 提供了一个专为分布式环境设计的优化版本的 LDA。我们将使用 Spark NLP 对数据进行预处理,并使用 Spark MLlib 的 LDA 构建一个简单的主题建模管道来从数据中提取主题。

在本章中,我们将着手于根据潜在(隐藏)主题和关系来提炼人类知识的谦虚任务。我们将应用 LDA 到包含在维基百科中的文章语料库中。我们将从理解 LDA 的基础知识开始,并在 PySpark 中实施它。然后,我们将下载数据集,并通过安装 Spark NLP 来设置我们的编程环境。这将紧随数据预处理。你将见证到 Spark NLP 库提供的开箱即用方法的强大功能,这使得 NLP 任务变得更加容易。

然后,我们将使用 TF-IDF(词频-逆文档频率)技术对我们文档中的术语进行评分,并将结果输出到我们的 LDA 模型中。最后,我们将浏览模型分配给输入文档的主题。我们应该能够理解一个条目属于哪个桶,而无需阅读它。让我们首先复习一下 LDA 的基础知识。

# 潜在狄利克雷分配

潜在狄利克雷分配背后的理念是,文档是基于一组主题生成的。在这个过程中,我们假设每个文档在主题上分布,每个主题在一组术语上分布。每个文档和每个词都是从这些分布中抽样生成的。LDA 学习者向后工作,并试图识别观察到的最有可能的分布。

它试图将语料库提炼为一组相关的*主题*。每个主题捕捉数据中的一个变化线索,通常对应语料库讨论的一个想法。一个文档可以是多个主题的一部分。你可以把 LDA 看作是一种*软聚类*文档的方式。不深入数学细节,LDA 主题模型描述了两个主要属性:在抽样特定文档时选择主题的机会,以及在选择主题时选择特定术语的机会。例如,LDA 可能会发现一个与术语“阿西莫夫”和“机器人”强相关的主题,并与文档“基地系列”和“科幻小说”相关联。通过仅选择最重要的概念,LDA 可以丢弃一些不相关的噪音并合并共现的线索,以得到数据的更简单的表现形式。

我们可以在各种任务中应用这种技术。例如,当提供输入条目时,它可以帮助我们推荐类似的维基百科条目。通过封装语料库中的变异模式,它可以基于比简单计算单词出现和共现更深的理解来打分。接下来,让我们看一下 PySpark 的 LDA 实现。

## PySpark 中的 LDA

PySpark MLlib 提供了 LDA 实现作为其聚类算法之一。以下是一些示例代码:

from pyspark.ml.linalg import Vectors
from pyspark.ml.clustering import LDA

df = spark.createDataFrame([[1, Vectors.dense([0.0, 1.0])],
[2, Vectors.dense([2.0, 3.0])],],
["id", "features"])

lda = LDA(k=2, seed=1) 1
lda.setMaxIter(10)

model = lda.fit(df)

model.vocabSize()
2

model.describeTopics().show() 2
+-----+-----------+--------------------+
|topic|termIndices| termWeights|
+-----+-----------+--------------------+
| 0| [0, 1]|[0.53331100994293...|
| 1| [1, 0]|0.50230220117597...|
+-----+-----------+--------------------+


![1 我们将 LDA 应用于数据帧,主题数(*k*)设定为 2。![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_understanding_wikipedia___span_class__keep_together__with_lda_and_spark_nlp__span__CO1-2)

描述我们主题中每个术语关联的概率权重的数据帧。

我们将探索 PySpark 的 LDA 实现及其应用于维基百科数据集时相关的参数。不过首先,我们需要下载相关的数据集。接下来我们将做这件事。

# 获取数据

Wikipedia 提供其所有文章的 dump。完整的 dump 以单个大 XML 文件的形式出现。这些可以被[下载](https://oreil.ly/DhGlJ),然后放置在云存储解决方案(如 AWS S3 或 GCS,Google Cloud Storage)或 HDFS 上。例如:

$ curl -s -L https://dumps.wikimedia.org/enwiki/latest/
$ enwiki-latest-pages-articles-multistream.xml.bz2
$ | bzip2 -cd
$ | hadoop fs -put - wikidump.xml


这可能需要一些时间。

处理这些数据量最合理的方式是使用几个节点的集群进行。要在本地机器上运行本章的代码,更好的选择是使用[Wikipedia 导出页面](https://oreil.ly/Rrpmr)生成一个较小的 dump。尝试获取多个页面数多且子类别少的多个类别,例如生物学、健康和几何学。为了使下面的代码起作用,请将 dump 下载到*ch06-LDA/*目录并将其重命名为*wikidump.xml*。

我们需要将 Wikipedia XML 转储文件转换为 PySpark 轻松处理的格式。在本地机器上工作时,我们可以使用便捷的 [WikiExtractor 工具](https://oreil.ly/pfwrE)。它从 Wikipedia 数据库转储中提取和清理文本,例如我们所拥有的。

使用 pip 安装它:

$ pip3 install wikiextractor


然后,只需在包含下载文件的目录中运行以下命令:

$ wikiextractor wikidump.xml


输出存储在名为`text`的给定目录中的一个或多个文件中。每个文件将以以下格式包含多个文档:

$ mv text wikidump 1
$ tree wikidump
...
wikidump
└── AA
└── wiki_00

...
$ head -n 5 wikidump/AA/wiki_00
...

Mathematics

Mathematics (from Greek: ) includes the study of such topics as numbers ...
...


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_understanding_wikipedia___span_class__keep_together__with_lda_and_spark_nlp__span__CO2-1)

将 text 目录重命名为 wikidump

接下来,让我们在开始处理数据之前熟悉 Spark NLP 库。

# Spark NLP

Spark NLP 库最初由 [John Snow Labs](https://oreil.ly/E9KVt) 于 2017 年初设计,作为一种本地于 Spark 的注释库,以充分利用 Spark SQL 和 MLlib 模块的功能。灵感来自于尝试使用 Spark 分发其他 NLP 库,这些库通常没有考虑并发性或分布式计算。

Spark NLP 具有与任何其他注释库相同的概念,但在注释存储方式上有所不同。大多数注释库将注释存储在文档对象中,但 Spark NLP 为不同类型的注释创建列。注释器实现为转换器、估计器和模型。在下一节中,当我们将它们应用于我们的数据集进行预处理时,我们将查看它们。在此之前,让我们在系统上下载并设置 Spark NLP。

## 设置您的环境:

通过 pip 安装 Spark NLP:

pip3 install spark-nlp==3.2.3


启动 PySpark shell:

pyspark --packages com.johnsnowlabs.nlp:spark-nlp_2.12:3.4.4


让我们在 PySpark shell 中导入 Spark NLP:

import sparknlp

spark = sparknlp.start()


现在,您可以导入我们将使用的相关 Spark NLP 模块:

from sparknlp.base import DocumentAssembler, Finisher
from sparknlp.annotator import (Lemmatizer, Stemmer,
Tokenizer, Normalizer,
StopWordsCleaner)
from sparknlp.pretrained import PretrainedPipeline


现在我们已经设置好了编程环境,让我们开始处理我们的数据集。我们将从将数据解析为 PySpark DataFrame 开始。

# 解析数据

WikiExtractor 的输出可能会根据输入转储的大小创建多个目录。我们希望将所有数据导入为单个 DataFrame。让我们从指定的输入目录开始:

data_source = 'wikidump//'


我们使用 `sparkContext` 可访问的 `wholeTextFiles` 方法导入数据。该方法将数据读取为 RDD。我们将其转换为 DataFrame,因为这是我们想要的:

raw_data = spark.sparkContext.wholeTextFiles(data_source).toDF()
raw_data.show(1, vertical=True)
...

-RECORD 0-------------------
_1 | file:/home/analyt...
_2 | <doc id="18831" u...


结果 DataFrame 将由两列组成。记录的数量将对应于已读取的文件数量。第一列包含文件路径,第二列包含相应的文本内容。文本包含多个条目,但我们希望每行对应一个单独的条目。基于我们之前看到的条目结构,我们可以使用几个 PySpark 工具:`split` 和 `explode` 来分隔条目。

from pyspark.sql import functions as fun
df = raw_data.withColumn('content', fun.explode(fun.split(fun.col("_2"),
"")))
df = df.drop(fun.col('_2')).drop(fun.col('_1'))

df.show(4, vertical=True)
...
-RECORD 0-----------------------
content | <doc id="18831" u...
-RECORD 1-----------------------
content |
<doc id="5627588...
-RECORD 2-----------------------
content |
<doc id="3354393...
-RECORD 3-----------------------
content |
<doc id="5999808...
only showing top 4 rows


`split` 函数用于根据提供的模式将 DataFrame 字符串 `Column` 拆分为数组。在之前的代码中,我们根据 *</doc>* 字符串将组合的文档 XML 字符串拆分为数组。这实际上为我们提供了一个包含多个文档的数组。然后,我们使用 `explode` 将根据 `split` 函数返回的数组中的每个元素创建新行。这导致为每个文档创建相应的行。

通过我们之前操作的 `content` 列获取的结构进行遍历:

df.show(1, truncate=False, vertical=True)
...
-RECORD 0


content |
Mathematics

Mathematics (from Greek: ) includes the study of such topics as numbers...


我们可以通过提取条目的标题来进一步拆分我们的 `content` 列:

df = df.withColumn('title', fun.split(fun.col('content'), '\n').getItem(2))
.withColumn('content', fun.split(fun.col('content'), '\n').getItem(4))
df.show(4, vertical=True)
...
-RECORD 0-----------------------
content | In mathematics, a...
title | Tertiary ideal
-RECORD 1-----------------------
content | In algebra, a bin...
title | Binomial (polynom...
-RECORD 2-----------------------
content | Algebra (from ) i...
title | Algebra
-RECORD 3-----------------------
content | In set theory, th...
title | Kernel (set theory)
only showing top 4 rows
...


现在我们有了解析后的数据集,让我们使用 Spark NLP 进行预处理。

# 使用 Spark NLP 准备数据

我们之前提到,基于文档注释器模型的库(如 Spark NLP)具有“文档”的概念。在 PySpark 中本地不存在这样的概念。因此,Spark NLP 的一个核心设计原则之一是与 MLlib 强大的互操作性。通过提供与 DataFrame 兼容的转换器,将文本列转换为文档,并将注释转换为 PySpark 数据类型。

我们首先通过 `DocumentAssembler` 创建我们的 `document` 列:

document_assembler = DocumentAssembler()
.setInputCol("content")
.setOutputCol("document")
.setCleanupMode("shrink")

document_assembler.transform(df).select('document').limit(1).collect()
...

Row(document=[Row(annotatorType='document', begin=0, end=289, result='...',
metadata={'sentence': '0'}, embeddings=[])])


我们可以在解析部分利用 Spark NLP 的 [`DocumentNormalizer`](https://oreil.ly/UL1vp) 注释器。

我们可以像之前的代码中所做的那样直接转换输入的 DataFrame。但是,我们将使用 `DocumentAssembler` 和其他必需的注释器作为 ML 流水线的一部分。

我们将使用以下注释器作为我们的预处理流水线的一部分:`Tokenizer`、`Normalizer`、`StopWordsCleaner` 和 `Stemmer`。

让我们从 `Tokenizer` 开始:

Split sentence to tokens(array)

tokenizer = Tokenizer()
.setInputCols(["document"])
.setOutputCol("token")


`Tokenizer` 是一个基础注释器。几乎所有基于文本的数据处理都以某种形式的分词开始,这是将原始文本分解成小块的过程。标记可以是单词、字符或子词(n-gram)。大多数经典的自然语言处理算法都期望标记作为基本输入。正在开发许多将字符作为基本输入的深度学习算法。大多数自然语言处理应用程序仍然使用分词。

接下来是 `Normalizer`:

clean unwanted characters and garbage

normalizer = Normalizer()
.setInputCols(["token"])
.setOutputCol("normalized")
.setLowercase(True)


`Normalizer` 清理前一步骤中的标记,并从文本中删除所有不需要的字符。

接下来是 `StopWordsCleaner`:

remove stopwords

stopwords_cleaner = StopWordsCleaner()
.setInputCols("normalized")
.setOutputCol("cleanTokens")
.setCaseSensitive(False)


这个注释器从文本中删除*停用词*。停用词如“the”、“is”和“at”非常常见,可以在不显著改变文本含义的情况下删除。从文本中删除停用词在希望处理只有最重要的语义单词而忽略文章和介词等很少有语义相关性的词时非常有用。

最后是 `Stemmer`:

stem the words to bring them to the root form.

stemmer = Stemmer()
.setInputCols(["cleanTokens"])
.setOutputCol("stem")


`Stemmer` 返回单词的硬干出来,目的是检索单词的有意义部分。*Stemming* 是将单词减少到其根词干的过程,目的是检索有意义的部分。例如,“picking”,“picked” 和 “picks” 都以 “pick” 作为根。

我们几乎完成了。在完成我们的 NLP 管道之前,我们需要添加`Finisher`。当我们将数据帧中的每一行转换为文档时,Spark NLP 添加了自己的结构。`Finisher` 非常关键,因为它帮助我们恢复预期的结构,即一个令牌数组:

finisher = Finisher()
.setInputCols(["stem"])
.setOutputCols(["tokens"])
.setOutputAsArray(True)
.setCleanAnnotations(False)


现在我们已经准备好所有必需的组件。让我们构建我们的管道,以便每个阶段可以按顺序执行:

from pyspark.ml import Pipeline
nlp_pipeline = Pipeline(
stages=[document_assembler,
tokenizer,
normalizer,
stopwords_cleaner,
stemmer,
finisher])


执行管道并转换数据帧:

nlp_model = nlp_pipeline.fit(df) 1

processed_df = nlp_model.transform(df) 2

processed_df.printSchema()
...

root
|-- content: string (nullable = true)
|-- title: string (nullable = true)
|-- document: array (nullable = true)
| |-- element: struct (containsNull = true)
| | |-- annotatorType: string (nullable = true)
| | |-- begin: integer (nullable = false)
| | |-- end: integer (nullable = false)
| | |-- result: string (nullable = true)
| | |-- metadata: map (nullable = true)
| | | |-- key: string
| | | |-- value: string (valueContainsNull = true)
| | |-- embeddings: array (nullable = true)
| | | |-- element: float (containsNull = false)
|-- token: array (nullable = true)
| |-- element: struct (containsNull = true)
| | |-- annotatorType: string (nullable = true)
| | |-- begin: integer (nullable = false)
| | |-- end: integer (nullable = false)
| | |-- result: string (nullable = true)
| | |-- metadata: map (nullable = true)
| | | |-- key: string
| | | |-- value: string (valueContainsNull = true)
| | |-- embeddings: array (nullable = true)
| | | |-- element: float (containsNull = false)
|-- normalized: array (nullable = true)
| |-- element: struct (containsNull = true)
| | |-- annotatorType: string (nullable = true)
| | |-- begin: integer (nullable = false)
| | |-- end: integer (nullable = false)
| | |-- result: string (nullable = true)
| | |-- metadata: map (nullable = true)
| | | |-- key: string
| | | |-- value: string (valueContainsNull = true)
| | |-- embeddings: array (nullable = true)
| | | |-- element: float (containsNull = false)
|-- cleanTokens: array (nullable = true)
| |-- element: struct (containsNull = true)
| | |-- annotatorType: string (nullable = true)
| | |-- begin: integer (nullable = false)
| | |-- end: integer (nullable = false)
| | |-- result: string (nullable = true)
| | |-- metadata: map (nullable = true)
| | | |-- key: string
| | | |-- value: string (valueContainsNull = true)
| | |-- embeddings: array (nullable = true)
| | | |-- element: float (containsNull = false)
|-- stem: array (nullable = true)
| |-- element: struct (containsNull = true)
| | |-- annotatorType: string (nullable = true)
| | |-- begin: integer (nullable = false)
| | |-- end: integer (nullable = false)
| | |-- result: string (nullable = true)
| | |-- metadata: map (nullable = true)
| | | |-- key: string
| | | |-- value: string (valueContainsNull = true)
| | |-- embeddings: array (nullable = true)
| | | |-- element: float (containsNull = false)
|-- tokens: array (nullable = true)
| |-- element: string (containsNull = true)


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_understanding_wikipedia___span_class__keep_together__with_lda_and_spark_nlp__span__CO3-1)

训练管道。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_understanding_wikipedia___span_class__keep_together__with_lda_and_spark_nlp__span__CO3-2)

将管道应用于转换数据帧。

NLP 管道创建了我们不需要的中间列。让我们删除这些冗余列:

tokens_df = processed_df.select('title','tokens')
tokens_df.show(2, vertical=True)
...

-RECORD 0----------------------
title | Tertiary ideal
tokens | mathemat, tertia...
-RECORD 1----------------------
title | Binomial (polynom...
tokens | algebra, binomi,...
only showing top 2 rows


接下来,我们将了解 TF-IDF 的基础知识,并在预处理数据集 `token_df` 上实施它,该数据集在构建 LDA 模型之前已经获得。

# TF-IDF

在应用 LDA 之前,我们需要将数据转换为数字表示。我们将使用术语频率-逆文档频率方法来获得这样的表示。大致来说,TF-IDF 用于确定与给定文档对应的术语的重要性。这里是 Python 代码中该公式的表示。我们实际上不会使用这段代码,因为 PySpark 提供了自己的实现。

import math

def term_doc_weight(term_frequency_in_doc, total_terms_in_doc,
term_freq_in_corpus, total_docs):
tf = term_frequency_in_doc / total_terms_in_doc
doc_freq = total_docs / term_freq_in_corpus
idf = math.log(doc_freq)
tf * idf
}


TF-IDF 捕捉了有关术语与文档相关性的两种直觉。首先,我们预期一个术语在文档中出现的次数越多,它对该文档的重要性就越大。其次,在全局意义上,并非所有术语都是相等的。在整个语料库中很少出现的词比大多数文档中出现的词更有意义;因此,该度量使用术语在整个语料库中出现的逆数。 

语料库中单词的频率往往呈指数分布。一个常见的词可能出现的次数是一个稍微常见的词的十倍,而后者可能出现的次数是一个稀有词的十到一百倍。基于原始逆文档频率的度量会赋予稀有词巨大的权重,并几乎忽略所有其他词的影响。为了捕捉这种分布,该方案使用逆文档频率的*对数*。这通过将它们之间的乘法差距转换为加法差距,使文档频率之间的差异变得温和。

该模型依赖于一些假设。它将每个文档视为“词袋”,即不关注单词的顺序、句子结构或否定。通过表示每个术语一次,该模型难以处理 *一词多义*,即同一个词具有多个含义的情况。例如,模型无法区分“Radiohead is the best band ever” 和 “I broke a rubber band” 中的“band” 的使用。如果这两个句子在语料库中频繁出现,它可能会将“Radiohead” 与“rubber” 关联起来。

现在我们继续实现使用 PySpark 的 TF-IDF。

# 计算 TF-IDFs

首先,我们将使用 `CountVectorizer` 计算 TF(术语频率;即文档中每个术语的频率),它会跟踪正在创建的词汇表,以便我们可以将我们的主题映射回相应的单词。TF 创建一个矩阵,统计词汇表中每个词在每个文本主体中出现的次数。然后,根据其频率给每个词赋予一个权重。我们在拟合时获取数据的词汇表并在转换步骤中获取计数:

from pyspark.ml.feature import CountVectorizer
cv = CountVectorizer(inputCol="tokens", outputCol="raw_features")

train the model

cv_model = cv.fit(tokens_df)

transform the data. Output column name will be raw_features.

vectorized_tokens = cv_model.transform(tokens_df)


然后,我们进行 IDF(文档中包含某个术语的反向频率),它会减少常见术语的权重:

from pyspark.ml.feature import IDF
idf = IDF(inputCol="raw_features", outputCol="features")

idf_model = idf.fit(vectorized_tokens)

vectorized_df = idf_model.transform(vectorized_tokens)


结果将如下所示:

vectorized_df = vectorized_df.drop(fun.col('raw_features'))

vectorized_df.show(6)
...

+--------------------+--------------------+--------------------+
| title| tokens| features|
+--------------------+--------------------+--------------------+
| Tertiary ideal|[mathemat, tertia...|(2451,[1,6,43,56,...|
|Binomial (polynom...|[algebra, binomi,...|(2451,[0,10,14,34...|
| Algebra|[algebra, on, bro...|(2451,[0,1,5,6,15...|
| Kernel (set theory)|[set, theori, ker...|(2451,[2,3,13,19,...|
|Generalized arith...|[mathemat, gener,...|(2451,[1,2,6,45,4...|
+--------------------+--------------------+--------------------+


在所有预处理和特征工程完成后,我们现在可以创建我们的 LDA 模型。这将在下一节中进行。

# 创建我们的 LDA 模型

我们之前提到过,LDA 将语料库提炼为一组相关主题。我们将在本节后面进一步查看此类主题的示例。在此之前,我们需要决定我们的 LDA 模型所需的两个超参数。它们是主题数量(称为 *k*)和迭代次数。

有多种方法可以选择 *k*。用于此目的的两种流行指标是困惑度和主题一致性。前者由 PySpark 的实现提供。基本思想是试图找出这些指标改善开始变得不显著的 *k*。如果您熟悉 K-means 的寻找簇数的“拐点法”,这类似。根据语料库的大小,这可能是一个资源密集和耗时的过程,因为您需要为多个 *k* 值构建模型。另一个选择可能是尝试创建数据集的代表性样本,并使用它来确定 *k*。您可以阅读相关资料并尝试这一点。

由于您现在可能正在本地工作,我们将暂时设定合理的值(*k* 为 5,`max_iter` 为 50)。

让我们创建我们的 LDA 模型:

from pyspark.ml.clustering import LDA

num_topics = 5
max_iter = 50

lda = LDA(k=num_topics, maxIter=max_iter)
model = lda.fit(vectorized_df)

lp = model.logPerplexity(vectorized_df)

print("The upper bound on perplexity: " + str(lp))
...

The upper bound on perplexity: 6.768323190833805


困惑度是衡量模型对样本预测能力的指标。低困惑度表明概率分布在预测样本方面表现良好。在比较不同模型时,选择困惑度值较低的模型。

现在我们已经创建了我们的模型,我们想将主题输出为人类可读的形式。我们将从我们的预处理步骤中生成的词汇表中获取词汇表,从 LDA 模型中获取主题,并将它们映射起来。

vocab = cv_model.vocabulary ![1raw_topics = model.describeTopics().collect() ![2

topic_inds = [ind.termIndices for ind in raw_topics] 3

topics = []
for topic in topic_inds:
_topic = []
for ind in topic:
_topic.append(vocab[ind])
topics.append(_topic)


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_understanding_wikipedia___span_class__keep_together__with_lda_and_spark_nlp__span__CO4-1)

创建对我们词汇表的引用。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_understanding_wikipedia___span_class__keep_together__with_lda_and_spark_nlp__span__CO4-2)

使用`describeTopics`从 LDA 模型获取生成的主题,并将它们加载到 Python 列表中。

![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_understanding_wikipedia___span_class__keep_together__with_lda_and_spark_nlp__span__CO4-3)

从我们的主题中获取词汇表术语的索引。

现在让我们生成从我们的主题索引到我们的词汇表的映射:

for i, topic in enumerate(topics, start=1):
print(f"topic {i}: {topic}")
...

topic 1: 'islam', 'health', 'drug', 'empir', 'medicin', 'polici',...
topic 2: ['formula', 'group', 'algebra', 'gener', 'transform', ...
topic 3: ['triangl', 'plane', 'line', 'point', 'two', 'tangent', ...
topic 4: ['face', 'therapeut', 'framework', 'particl', 'interf', ...
topic 5: ['comput', 'polynomi', 'pattern', 'internet', 'network', ...


上述结果并不完美,但在主题中可以注意到一些模式。主题 1 主要与健康有关。它还包含对伊斯兰教和帝国的引用。这可能是因为它们在医学历史中被引用,反之亦然,或者其他原因?主题 2 和 3 与数学有关,后者倾向于几何学。主题 5 是计算和数学的混合体。即使您没有阅读任何文档,您也可以以合理的准确度猜测它们的类别。这很令人兴奋!

现在我们还可以检查我们的输入文档与哪些主题最相关。单个文档可以具有多个显著的主题关联。目前,我们只会查看与之最相关的主题。

让我们在我们的输入数据帧上运行 LDA 模型的转换操作:

lda_df = model.transform(vectorized_df)
lda_df.select(fun.col('title'), fun.col('topicDistribution')).
show(2, vertical=True, truncate=False)
...
-RECORD 0-------------------------------------
title | Tertiary ideal
topicDistribution | [5.673953573608612E-4,...
-RECORD 1----------------------------------...
title | Binomial (polynomial) ...
topicDistribution | [0.0019374384060205127...
only showing top 2 rows


正如您所看到的,每个文档都与其相关的主题概率分布相关联。为了获取每个文档的相关主题,我们想找出具有最高概率得分的主题索引。然后,我们可以将其映射到之前获取的主题。

我们将编写一个 PySpark UDF,以查找每条记录的最高主题概率分数:

from pyspark.sql.types import IntegerType
max_index = fun.udf(lambda x: x.tolist().index(max(x)) + 1, IntegerType())
lda_df = lda_df.withColumn('topic_index',
max_index(fun.col('topicDistribution')))


lda_df.select('title', 'topic_index').show(10, truncate=False)
...

+----------------------------------+-----------+
|title |topic_index|
+----------------------------------+-----------+
|Tertiary ideal |2 |
|Binomial (polynomial) |2 |
|Algebra |2 |
|Kernel (set theory) |2 |
|Generalized arithmetic progression|2 |
|Schur algebra |2 |
|Outline of algebra |2 |
|Recurrence relation |5 |
|Rational difference equation |5 |
|Polynomial arithmetic |2 |
+----------------------------------+-----------+
only showing top 11 rows


Topic 2,如果你还记得,与数学有关。输出结果符合我们的预期。您可以扫描更多数据集,查看其表现。您可以使用`where`或`filter`命令选择特定主题,并与之前生成的主题列表进行比较,以更好地理解已创建的聚类。正如本章开头所承诺的那样,我们能够将文章分成不同的主题,而无需阅读它们!

# 接下来的步骤

在本章中,我们对维基百科语料库执行了 LDA。在这个过程中,我们还学习了使用令人惊叹的 Spark NLP 库和 TF-IDF 技术进行文本预处理。您可以通过改进模型的预处理和超参数调整来进一步构建这一过程。此外,当用户提供输入时,您甚至可以尝试基于文档相似性推荐类似条目。这样的相似性度量可以通过使用从 LDA 获得的概率分布向量来获得。

此外,还存在多种其他方法来理解大型文本语料库。例如,被称为潜在语义分析(LSA)的技术在类似的应用中非常有用,并且在本书的上一版本中也使用了相同数据集。深度学习,在[第十章中进行了探讨,也提供了进行主题建模的途径。您可以自行探索这些方法。


# 第七章:出租车行程数据的地理空间和时间数据分析

地理空间数据指的是数据中嵌入有某种形式的位置信息。这类数据每天由数十亿来源(如手机和传感器)大规模生成。关于人类和机器移动的数据以及来自遥感的数据对我们的经济和整体福祉至关重要。地理空间分析可以为我们提供处理所有这些数据并将其用于解决面临问题的工具和方法。

在地理空间分析方面,PySpark 和 PyData 生态系统在过去几年中发生了显著发展。它们被各行各业用来处理富有位置信息的数据,并对我们的日常生活产生影响。地方交通是一个可以明显看到地理空间数据应用的日常活动领域。过去几年中数字打车服务的流行使我们更加关注地理空间技术。在本章中,我们将利用我们在该领域中的 PySpark 和数据分析技能来处理一个数据集,该数据集包含有关纽约市出租车行程的信息。

了解出租车经济的一个重要统计数据是*利用率*:出租车在道路上被一名或多名乘客占用的时间比例。影响利用率的一个因素是乘客的目的地:在正午将乘客送至联合广场附近的出租车很可能在一两分钟内找到下一个乘客,而在凌晨 2 点将乘客送至史泰登岛的出租车可能需要驾驶回曼哈顿才能找到下一个乘客。我们希望量化这些影响,并找出出租车在其将乘客卸下的区域(曼哈顿、布鲁克林、皇后区、布朗克斯、史泰登岛或其他地方,如纽瓦克自由国际机场)找到下一个乘客的平均时间。

我们将从设置数据集开始,然后深入进行地理空间分析。我们将学习关于 GeoJSON 格式的知识,并结合 PyData 生态系统中的工具与 PySpark 使用。我们将使用 GeoPandas 来处理*地理空间信息*,如经度和纬度点以及空间边界。最后,我们将通过执行会话化类型的分析来处理数据的时间特征,比如日期和时间。这将帮助我们了解纽约市出租车的利用情况。PySpark 的 DataFrame API 提供了处理时间数据的内置数据类型和方法。

让我们通过下载数据集并使用 PySpark 进行探索来开始吧。

# 准备数据

对于此分析,我们只考虑 2013 年 1 月的票价数据,解压后大约为 2.5 GB 数据。您可以访问[2013 年每个月的数据](https://oreil.ly/7m7Ki),如果您有一个足够大的 PySpark 集群可供使用,可以对整年的数据重新进行以下分析。现在,让我们在客户机上创建一个工作目录,并查看票价数据的结构:

$ mkdir taxidata
$ cd taxidata
$ curl -O https://storage.googleapis.com/aas-data-sets/trip_data_1.csv.zip
$ unzip trip_data_1.csv.zip
$ head -n 5 trip_data_1.csv

...

medallion,hack_license,vendor_id,rate_code,store_and_fwd_flag,...
89D227B655E5C82AECF13C3F540D4CF4,BA96DE419E711691B9445D6A6307C170,CMT,1,...
0BD7C8F5BA12B88E0B67BED28BEA73D8,9FD8F69F0804BDB5549F40E9DA1BE472,CMT,1,...
0BD7C8F5BA12B88E0B67BED28BEA73D8,9FD8F69F0804BDB5549F40E9DA1BE472,CMT,1,...
DFD2202EE08F7A8DC9A57B02ACB81FE2,51EE87E3205C985EF8431D850C786310,CMT,1,...


文件头后的每一行表示 CSV 格式中的单个出租车行程。对于每次行程,我们有一些有关出租车的属性(中介牌号的散列版本)以及驾驶员的信息(出租车驾驶执照的散列版本,这就是出租车驾驶许可证的称呼),有关行程何时开始和结束的一些时间信息,以及乘客上下车的经度/纬度坐标。

创建一个*taxidata*目录,并将行程数据复制到存储中:

$ mkdir taxidata
$ mv trip_data_1.csv taxidata/


在这里我们使用了本地文件系统,但您可能不是这种情况。现在更常见的是使用云原生文件系统,如 AWS S3 或 GCS。在这种情况下,您需要分别将数据上传到 S3 或 GCS。

现在开始 PySpark shell:

$ pyspark


一旦 PySpark shell 加载完成,我们就可以从出租车数据创建一个数据集,并检查前几行,就像我们在其他章节中所做的那样:

taxi_raw = pyspark.read.option("header", "true").csv("taxidata")
taxi_raw.show(1, vertical=True)

...

RECORD 0----------------------------------
medallion | 89D227B655E5C82AE...
hack_license | BA96DE419E711691B...
vendor_id | CMT
rate_code | 1
store_and_fwd_flag | N
pickup_datetime | 2013-01-01 15:11:48
dropoff_datetime | 2013-01-01 15:18:10
passenger_count | 4
trip_time_in_secs | 382
trip_distance | 1.0
pickup_longitude | -73.978165
pickup_latitude | 40.757977
dropoff_longitude | -73.989838
dropoff_latitude | 40.751171
only showing top 1 row

...


乍看之下,这看起来是一个格式良好的数据集。让我们再次查看 DataFrame 的架构:

taxi_raw.printSchema()
...
root
|-- medallion: string (nullable = true)
|-- hack_license: string (nullable = true)
|-- vendor_id: string (nullable = true)
|-- rate_code: integer (nullable = true)
|-- store_and_fwd_flag: string (nullable = true)
|-- pickup_datetime: string (nullable = true)
|-- dropoff_datetime: string (nullable = true)
|-- passenger_count: integer (nullable = true)
|-- trip_time_in_secs: integer (nullable = true)
|-- trip_distance: double (nullable = true)
|-- pickup_longitude: double (nullable = true)
|-- pickup_latitude: double (nullable = true)
|-- dropoff_longitude: double (nullable = true)
|-- dropoff_latitude: double (nullable = true)
...


我们将`pickup_datetime`和`dropoff_datetime`字段表示为`Strings`,并将接送地点的个体`(x,y)`坐标存储在其自己的`Doubles`字段中。我们希望将日期时间字段转换为时间戳,因为这样可以方便地进行操作和分析。

## 将日期时间字符串转换为时间戳

如前所述,PySpark 提供了处理时间数据的开箱即用方法。

具体来说,我们将使用`to_timestamp`函数解析日期时间字符串并将其转换为时间戳:

from pyspark.sql import functions as fun

taxi_raw = taxi_raw.withColumn('pickup_datetime',
fun.to_timestamp(fun.col('pickup_datetime'),
"yyyy-MM-dd HH:mm:ss"))
taxi_raw = taxi_raw.withColumn('dropoff_datetime',
fun.to_timestamp(fun.col('dropoff_datetime'),
"yyyy-MM-dd HH:mm:ss"))


让我们再次查看架构:

taxi_raw.printSchema()
...

root
|-- medallion: string (nullable = true)
|-- hack_license: string (nullable = true)
|-- vendor_id: string (nullable = true)
|-- rate_code: integer (nullable = true)
|-- store_and_fwd_flag: string (nullable = true)
|-- pickup_datetime: timestamp (nullable = true)
|-- dropoff_datetime: timestamp (nullable = true)
|-- passenger_count: integer (nullable = true)
|-- trip_time_in_secs: integer (nullable = true)
|-- trip_distance: double (nullable = true)
|-- pickup_longitude: double (nullable = true)
|-- pickup_latitude: double (nullable = true)
|-- dropoff_longitude: double (nullable = true)
|-- dropoff_latitude: double (nullable = true)

...


现在,`pickup_datetime`和`dropoff_datetime`字段都是时间戳了。干得好!

我们提到这个数据集包含 2013 年 1 月的行程。不过,不要只听我们的话。我们可以通过对`pickup_datetime`字段进行排序来确认数据中的最新日期时间。为此,我们使用 DataFrame 的`sort`方法结合 PySpark 列的`desc`方法:

taxi_raw.sort(fun.col("pickup_datetime").desc()).show(3, vertical=True)
...

-RECORD 0----------------------------------
medallion | EA00A64CBDB68C77D...
hack_license | 2045C77002FA0F2E0...
vendor_id | CMT
rate_code | 1
store_and_fwd_flag | N
pickup_datetime | 2013-01-31 23:59:59
dropoff_datetime | 2013-02-01 00:08:39
passenger_count | 1
trip_time_in_secs | 520
trip_distance | 1.5
pickup_longitude | -73.970528
pickup_latitude | 40.75502
dropoff_longitude | -73.981201
dropoff_latitude | 40.769104
-RECORD 1----------------------------------
medallion | E3F00BB3F4E710383...
hack_license | 10A2B96DE39865918...
vendor_id | CMT
rate_code | 1
store_and_fwd_flag | N
pickup_datetime | 2013-01-31 23:59:59
dropoff_datetime | 2013-02-01 00:05:16
passenger_count | 1
trip_time_in_secs | 317
trip_distance | 1.0
pickup_longitude | -73.990685
pickup_latitude | 40.719158
dropoff_longitude | -74.003288
dropoff_latitude | 40.71521
-RECORD 2----------------------------------
medallion | 83D8E776A05EEF731...
hack_license | E6D27C8729EF55D20...
vendor_id | CMT
rate_code | 1
store_and_fwd_flag | N
pickup_datetime | 2013-01-31 23:59:58
dropoff_datetime | 2013-02-01 00:04:19
passenger_count | 1
trip_time_in_secs | 260
trip_distance | 0.8
pickup_longitude | -73.982452
pickup_latitude | 40.77277
dropoff_longitude | -73.989227
dropoff_latitude | 40.766754
only showing top 3 rows
...


在确保数据类型正确后,让我们检查数据中是否存在任何不一致之处。

## 处理无效记录

任何在大规模、真实世界数据集上工作过的人都知道,这些数据集中必然包含至少一些不符合编写处理代码人员期望的记录。许多 PySpark 管道由于无效记录导致解析逻辑抛出异常而失败。在进行交互式分析时,我们可以通过关注关键变量来感知数据中潜在的异常。

在我们的案例中,包含地理空间和时间信息的变量存在不一致性是值得注意的。这些列中的空值肯定会影响我们的分析结果。

geospatial_temporal_colnames = ["pickup_longitude", "pickup_latitude",
"dropoff_longitude", "dropoff_latitude",
"pickup_datetime", "dropoff_datetime"]
taxi_raw.select([fun.count(fun.when(fun.isnull(c), c)).
alias(c) for c in geospatial_temporal_colnames]).
show()
...

+----------------+---------------+-----------------
|pickup_longitude|pickup_latitude|dropoff_longitude
+----------------+---------------+-----------------
| 0| 0| 86
+----------------+---------------+-----------------
+----------------+---------------+----------------+
|dropoff_latitude|pickup_datetime|dropoff_datetime|
+----------------+---------------+----------------+
| 86| 0| 0|
+----------------+---------------+----------------+


让我们从数据中删除空值:

taxi_raw = taxi_raw.na.drop(subset=geospatial_temporal_colnames)


另一个常识检查是检查纬度和经度记录中值为零的情况。我们知道对于我们关注的地区,这些值是无效的:

print("Count of zero dropoff, pickup latitude and longitude records")
taxi_raw.groupBy((fun.col("dropoff_longitude") == 0) |
(fun.col("dropoff_latitude") == 0) |
(fun.col("pickup_longitude") == 0) |
(fun.col("pickup_latitude") == 0)).\ 1
count().show()
...

Count of zero dropoff, pickoff latitude and longitude records
+---------------+
| ... | count|
+------+--------+
| true | 285909|
| false|14490620|
+---------------+


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_geospatial_and_temporal_data_analysis___span_class__keep_together__on_taxi_trip_data__span__CO1-1)

如果任何记录的任一条件为 `True`,则多个`OR`条件将为真。

我们有很多这样的情况。如果看起来一辆出租车带乘客去了南极,我们可以相当有信心地认为该记录是无效的,并应从分析中排除。我们不会立即删除它们,而是在下一节结束时回顾它们,看看它们如何影响我们的分析。

在生产环境中,我们逐个处理这些异常,通过检查各个任务的日志,找出抛出异常的代码行,然后调整代码以忽略或修正无效记录。这是一个繁琐的过程,常常感觉像是在玩打地鼠游戏:就在我们修复一个异常时,我们发现分区内稍后出现的记录中又有另一个异常。

当数据科学家处理新数据集时,一个常用的策略是在他们的解析代码中添加 `try-except` 块,以便任何无效记录都可以被写入日志,而不会导致整个作业失败。如果整个数据集中只有少数无效记录,我们可能可以忽略它们并继续分析。

现在我们已经准备好我们的数据集,让我们开始地理空间分析。

# 地理空间分析

有两种主要类型的地理空间数据——矢量和栅格——以及用于处理每种类型的不同工具。在我们的案例中,我们有出租车行程记录的纬度和经度,以及以 GeoJSON 格式存储的矢量数据,表示纽约不同区域的边界。我们已经查看了纬度和经度点。让我们先看看 GeoJSON 数据。

## GeoJSON 简介

我们将用于纽约市各区域边界的数据以*GeoJSON*格式呈现。GeoJSON 中的核心对象称为*要素*,由一个*几何*实例和一组称为*属性*的键值对组成。几何是如点、线或多边形等形状。一组要素称为`FeatureCollection`。让我们下载纽约市区地图的 GeoJSON 数据,并查看其结构。

在客户端机器的*taxidata*目录中,下载数据并将文件重命名为稍短的名称:

$ url="https://nycdatastables.s3.amazonaws.com/
2013-08-19T18:15:35.172Z/nyc-borough-boundaries-polygon.geojson"
$ curl -O \(url \) mv nyc-borough-boundaries-polygon.geojson nyc-boroughs.geojson


打开文件并查看要素记录。注意属性和几何对象,例如表示区域边界的多边形和包含区域名称及其他相关信息的属性。

$ head -n 7 data/trip_data_ch07/nyc-boroughs.geojson
...
{
"type": "FeatureCollection",

"features": [{ "type": "Feature", "id": 0, "properties": { "boroughCode": 5, ...
,
{ "type": "Feature", "id": 1, "properties": { "boroughCode": 5, ...


## GeoPandas

在选择用于执行地理空间分析的库时,首先要考虑的是确定你需要处理的数据类型。我们需要一个可以解析 GeoJSON 数据并处理空间关系的库,比如检测给定的经度/纬度对是否包含在表示特定区域边界的多边形内。我们将使用[GeoPandas 库](https://geopandas.org)来完成这项任务。GeoPandas 是一个开源项目,旨在使 Python 中的地理空间数据处理更加简单。它扩展了 pandas 库中使用的数据类型,允许对几何数据类型进行空间操作,我们在之前的章节中已经使用过 pandas 库。

使用 pip 安装 GeoPandas 包:

pip3 install geopandas


现在让我们开始研究出租车数据的地理空间方面。对于每次行程,我们有经度/纬度对,表示乘客上车和下车的位置。我们希望能够确定每个经度/纬度对属于哪个区域,并识别没有在五个区域之一开始或结束的行程。例如,如果一辆出租车将乘客从曼哈顿送往纽瓦克自由国际机场,那将是一个有效的行程,也值得分析,尽管它不会在五个区域中的任何一个结束。

要执行我们的区域分析,我们需要加载我们之前下载并存储在*nyc-boroughs.geojson*文件中的 GeoJSON 数据:

import geopandas as gdp

gdf = gdp.read_file("./data/trip_data_ch07/nyc-boroughs.geojson")


在使用 GeoJSON 特性处理出租车行程数据之前,我们应该花点时间考虑如何组织这些地理空间数据以实现最大效率。一种选择是研究针对地理空间查找进行优化的数据结构,比如四叉树,然后找到或编写我们自己的实现。不过,我们将尝试提出一个快速的启发式方法,以便我们可以跳过那部分工作。

我们将通过`gdf`迭代,直到找到一个几何图形包含给定经度/纬度的`point`。大多数纽约市的出租车行程始发和结束在曼哈顿,因此如果代表曼哈顿的地理空间特征在序列中较早出现,我们的搜索会相对快速结束。我们可以利用每个特征的`boroughCode`属性作为排序键,曼哈顿的代码等于 1,斯塔滕岛的代码等于 5。在每个行政区的特征中,我们希望与较大多边形相关联的特征优先于与较小多边形相关联的特征,因为大多数行程将会发生在每个行政区的“主要”区域之间。

我们将计算与每个特征几何相关联的区域,并将其存储为一个新列:

gdf = gdf.to_crs(3857)

gdf['area'] = gdf.apply(lambda x: x['geometry'].area, axis=1)
gdf.head(5)
...

boroughCode  borough        @id     geometry     area

0 5 Staten Island http://nyc.pediacities.com/Resource/Borough/St...
1 5 Staten Island http://nyc.pediacities.com/Resource/Borough/St...
2 5 Staten Island http://nyc.pediacities.com/Resource/Borough/St...
3 5 Staten Island http://nyc.pediacities.com/Resource/Borough/St...
4 4 Queens http://nyc.pediacities.com/Resource/Borough/Qu...


将特征按照行政区代码和每个特征几何区域的组合排序应该能解决问题:

gdf = gdf.sort_values(by=['boroughCode', 'area'], ascending=[True, False])
gdf.head(5)
...
boroughCode borough @id geometry area
72 1 Manhattan http://nyc.pediacities.com/Resource/Borough/Ma...
71 1 Manhattan http://nyc.pediacities.com/Resource/Borough/Ma...
51 1 Manhattan http://nyc.pediacities.com/Resource/Borough/Ma...
69 1 Manhattan http://nyc.pediacities.com/Resource/Borough/Ma...
73 1 Manhattan http://nyc.pediacities.com/Resource/Borough/Ma...


请注意,我们基于面积值按降序排序,因为我们希望最大的多边形首先出现,而`sort_values`默认按升序排序。

现在我们可以将`gdf` GeoPandas DataFrame 中排序后的特征广播到集群,并编写一个函数,利用这些特征来找出特定行程结束在五个行政区中的哪一个(如果有的话):

b_gdf = spark.sparkContext.broadcast(gdf)

def find_borough(latitude,longitude):
mgdf = b_gdf.value.apply(lambda x: x['borough'] if
x['geometry'].
intersects(gdp.
points_from_xy(
[longitude],
[latitude])[0])
else None, axis=1)
idx = mgdf.first_valid_index()
return mgdf.loc[idx] if idx is not None else None

find_borough_udf = fun.udf(find_borough, StringType())


我们可以将`find_borough`应用于`taxi_raw` DataFrame 中的行程,以创建一个按行政区划分的行程直方图:

df_with_boroughs = taxi_raw.
withColumn("dropoff_borough",
find_borough_udf(
fun.col("dropoff_latitude"),
fun.col('dropoff_longitude')))

df_with_boroughs.groupBy(fun.col("dropoff_borough")).count().show()
...
+-----------------------+--------+
| dropoff_borough | count|
+-----------------------+--------+
| Queens| 672192|
| null| 7942421|
| Brooklyn| 715252|
| Staten Island| 3338|
| Manhattan|12979047|
| Bronx| 67434|
+-----------------------+--------+


我们预料到,绝大多数行程的终点在曼哈顿区,而只有相对较少的行程终点在斯塔滕岛。一个令人惊讶的观察是,有多少行程的终点在任何一个行政区外;`null`记录的数量远远大于在布朗克斯结束的出租车行程的数量。

我们之前讨论过如何处理这些无效记录,但并未将其删除。现在由你来移除这些记录并从清理后的数据中创建直方图。完成后,你会注意到`null`条目的减少,留下了更合理的在城市外进行下车的观察数据。

在处理了数据的地理空间方面,让我们现在通过使用 PySpark 对数据的时间特性进行更深入的挖掘来执行会话化。

# PySpark 中的会话化

在这种分析中,我们希望分析单个实体随时间执行一系列事件的类型被称为*会话化*,通常在 Web 日志中执行以分析网站用户的行为。 PySpark 提供了`Window`和聚合函数,可以用来执行这种分析。这些允许我们专注于业务逻辑,而不是试图实现复杂的数据操作和计算。我们将在下一节中使用这些功能来更好地理解数据集中出租车的利用率。

会话化可以是揭示数据洞察力并构建可帮助人们做出更好决策的新数据产品的强大技术。例如,谷歌的拼写纠正引擎是建立在每天从其网站属性上发生的每个事件(搜索、点击、地图访问等)的用户活动会话之上的。为了识别可能的拼写纠正候选项,谷歌处理这些会话,寻找用户输入一个查询后没有点击任何内容、几秒钟后再输入稍有不同的查询,然后点击一个结果且不返回谷歌的情况。然后,计算这种模式对于任何一对查询发生的频率。如果发生频率足够高(例如,每次看到查询“untied stats”后几秒钟后都跟随查询“united states”),则我们认为第二个查询是对第一个查询的拼写纠正。

这项分析利用事件日志中体现的人类行为模式来构建一个拼写纠正引擎,该引擎使用的数据比任何从字典创建的引擎更为强大。该引擎可用于任何语言的拼写纠正,并能纠正可能不包含在任何字典中的单词(例如新创企业的名称)或像“untied stats”这样的查询,其中没有任何单词拼写错误!谷歌使用类似的技术来显示推荐和相关搜索,以及决定哪些查询应返回一个 OneBox 结果,即在搜索页面本身给出查询答案,而无需用户点击转到不同页面。OneBox 可用于天气、体育比赛得分、地址以及许多其他类型的查询。

到目前为止,每个实体发生的事件集合的信息分散在 DataFrame 的分区中,因此,为了分析,我们需要将这些相关事件放在一起并按时间顺序排列。在接下来的部分中,我们将展示如何使用高级 PySpark 功能有效地构建和分析会话。

## 构建会话:PySpark 中的二次排序

在 PySpark 中创建会话的简单方法是对要创建会话的标识符执行`groupBy`,然后按时间戳标识符进行后续事件排序。如果每个实体只有少量事件,这种方法将表现得相当不错。但是,由于这种方法要求任何特定实体的所有事件同时在内存中以进行排序,所以随着每个实体的事件数量越来越大,它将无法扩展。我们需要一种构建会话的方法,不需要将特定实体的所有事件同时保留在内存中以进行排序。

在 MapReduce 中,我们可以通过执行*二次排序*来构建会话,其中我们创建一个由标识符和时间戳值组成的复合键,对所有记录按复合键排序,然后使用自定义分区器和分组函数确保同一标识符的所有记录出现在同一输出分区中。幸运的是,PySpark 也可以通过使用`Window`函数支持类似的模式:

from pyspark.sql import Window

window_spec = Window.partitionBy("hack_license").
orderBy(fun.col("hack_license"),
fun.col("pickup_datetime"))


首先,我们使用`partitionBy`方法确保所有具有相同`license`列值的记录最终位于同一个分区中。然后,在每个分区内,我们按其`license`值对记录进行排序(使得同一驾驶员的所有行程出现在一起),然后再按其`pickupTime`排序,以使行程序列按排序顺序出现在分区内。现在,当我们聚合行程记录时,我们可以确保行程按照适合会话分析的方式进行排序。由于此操作触发了洗牌和相当数量的计算,并且我们需要多次使用结果,因此我们将它们缓存:

window_spec.cache()


执行会话化管道是一项昂贵的操作,而且会话化数据通常对我们可能要执行的许多不同分析任务都很有用。在可能希望稍后继续分析或与其他数据科学家合作的环境中,通过仅执行一次会话化大型数据集并将会话化数据写入诸如 S3 或 HDFS 之类的文件系统,可以分摊会话化成本,使其可用于回答许多不同的问题是一个好主意。仅执行一次会话化还是一种强制执行会话定义标准规则于整个数据科学团队的好方法,这对确保结果的苹果对苹果比较具有相同的好处。

此时,我们准备分析我们的会话数据,以查看司机在特定区域卸客后多长时间找到下一个乘客。我们将使用之前创建的`lag`函数以及`window_spec`对象来获取两次行程,并计算第一次行程的卸客时间和第二次行程的接客时间之间的持续时间(以秒为单位):

df_ with_ borough_durations = df_with_boroughs.
withColumn("trip_time_difference",
fun.col("pickup_datetime") - fun.lag(fun.col("pickup_datetime"),
1).
over(window_spec)).show(50, vertical=True)


现在,我们应该进行验证检查,确保大部分持续时间是非负的:

df_with_borough_durations.
selectExpr("floor(seconds / 3600) as hours").
groupBy("hours").
count().
sort("hours").
show()
...
+-----+--------+
|hours| count|
+-----+--------+
| -3| 2|
| -2| 16|
| -1| 4253|
| 0|13359033|
| 1| 347634|
| 2| 76286|
| 3| 24812|
| 4| 10026|
| 5| 4789|


只有少数记录具有负持续时间,当我们更仔细地检查它们时,似乎没有任何我们可以用来理解错误数据来源的共同模式。如果我们从输入数据集中排除这些负持续时间记录,并查看按区域的接车时间的平均值和标准差,我们可以看到这个:

df_with_borough_durations.
where("seconds > 0 AND seconds < 60604").
groupBy("borough").
agg(avg("seconds"), stddev("seconds")).
show()
...
+-------------+------------------+--------------------+
| borough| avg(seconds)|stddev_samp(seconds)|
+-------------+------------------+--------------------+
| Queens|2380.6603554494727| 2206.6572799118035|
| NA| 2006.53571169866| 1997.0891370324784|
| Brooklyn| 1365.394576250576| 1612.9921698951398|
|Staten Island| 2723.5625| 2395.7745475546385|
| Manhattan| 631.8473780726746| 1042.919915477234|
| Bronx|1975.9209786770646| 1704.006452085683|
+-------------+------------------+--------------------+


正如我们预期的那样,数据显示,曼哈顿的下车时间最短,约为 10 分钟。在布鲁克林结束的出租车行程的空闲时间是这个的两倍以上,而在史泰登岛结束的相对较少的行程平均需要司机近 45 分钟才能到达下一个乘客。

正如数据所显示的那样,出租车司机有很大的经济激励来根据乘客的最终目的地进行歧视;特别是在史泰登岛下车,司机需要大量的空闲时间。多年来,纽约市出租车和豪华轿车委员会一直在努力识别这种歧视行为,并对因乘客目的地而拒载的司机处以罚款。有趣的是尝试分析数据,找出可能表明司机与乘客对于乘客要下车的地方存在分歧的异常短程出租车行程。

# 未来的发展方向

在本章中,我们处理了真实数据集的时间和空间特征。到目前为止,您所获得的地理空间分析的熟悉度可以用于深入研究诸如 Apache Sedona 或 GeoMesa 等框架。与使用 GeoPandas 和 UDF 相比,它们将具有更陡峭的学习曲线,但会更高效。在地理空间和时间数据的数据可视化方面也有很大的应用空间。

此外,想象一下使用相同的技术在出租车数据上构建一个应用程序,根据当前交通模式和历史记录的最佳下一个位置,推荐出租车在下车后最好去的地方。您还可以从需要打车的人的角度查看信息:根据当前时间、地点和天气数据,我能在接下来的五分钟内从街上拦到出租车的概率是多少?这种信息可以整合到应用程序中,例如谷歌地图,帮助旅行者决定何时出发以及选择哪种出行方式。


# 第八章:金融风险估算

在投资金融市场时,您能期望损失多少?这正是金融统计量*风险价值*(VaR)试图衡量的数量。VaR 是一种简单的投资风险度量,试图提供关于投资组合在特定时间段内最大潜在损失的合理估计。VaR 统计量取决于三个参数:一个投资组合、一个时间段和一个概率。例如,具有 5%概率和两周的$1 百万 VaR 值表示相信,在两周内,投资组合仅有 5%的概率损失超过$1 百万。

自 1987 年股市崩盘后不久的发展以来,VaR 已被广泛应用于金融服务组织中。该统计量通过帮助确定其战略的风险特征,在这些机构的管理中发挥着关键作用。

许多估算此统计量的最复杂方法依赖于在随机条件下进行计算密集型市场模拟。这些方法背后的技术称为蒙特卡洛模拟,涉及提出数千甚至数百万个随机市场场景,并观察它们如何倾向于影响投资组合。这些场景被称为*试验*。PySpark 是进行蒙特卡洛模拟的理想工具。PySpark 能够利用数千个核心来运行随机试验并汇总它们的结果。作为通用的数据转换引擎,它还擅长执行围绕模拟的预处理和后处理步骤。它可以将原始金融数据转换为执行模拟所需的模型参数,并支持对结果进行临时分析。与使用 HPC 环境的传统方法相比,其简单的编程模型可以大大缩短开发时间。

我们还将讨论如何计算称为*条件风险价值*(CVaR)的相关统计量,有时也称为*预期缺口*,几年前,巴塞尔银行监管委员会提议将其作为比 VaR 更好的风险度量。CVaR 统计量与 VaR 统计量具有相同的三个参数,但考虑的是预期平均损失,而不是提供潜在损失值。具有 5%*q 值*和两周的$5 百万 CVaR 表示相信,在最差的 5%结果中平均损失为$5 百万。

在建模 VaR 的过程中,我们将介绍一些不同的概念、方法和软件包。我们将从介绍整章中将使用的基本金融术语开始,然后学习计算 VaR 的方法,包括蒙特卡洛模拟技术。之后,我们将使用 PySpark 和 pandas 下载和准备我们的数据集。我们将使用 2000 年代末和 2010 年代初的股市数据,包括国库债券价格以及各种公司的股票价值等市场指标。在预处理完成后,我们将创建一个线性回归模型来计算股票在一段时间内的价值变化。我们还将想出一种方法,在执行蒙特卡洛模拟时生成样本市场指标值。最后,我们将使用 PySpark 执行模拟并检查我们的结果。

让我们从定义我们将使用的基本金融术语开始。

# 术语

本章使用了一组特定于金融领域的术语:

仪器

可交易资产,例如债券、贷款、期权或股票投资。在任何特定时间,仪器被认为有一个*价值*,这是它可能被出售的价格。

投资组合

金融机构拥有的一系列工具。

回报

仪器或投资组合在一段时间内价值的变化。

损失

负回报。

指数

一种虚拟的仪器投资组合。例如,纳斯达克综合指数包含约 3000 只美国和国际主要公司的股票和类似的仪器。

市场因子

可用作特定时间金融环境宏观方面指标的价值,例如一个指数的值,美国的国内生产总值,或美元与欧元之间的汇率。我们通常将市场因子简称为*factors*。

# 计算 VaR 的方法

到目前为止,我们对 VaR 的定义相对开放。估算这一统计量需要提出一个关于如何计算投资组合的模型,并选择其回报可能遵循的概率分布。机构使用多种方法来计算 VaR,这些方法大多归结为几种一般方法之下。

## 方差-协方差

*方差-协方差*是迄今为止最简单且计算量最小的方法。其模型假设每个仪器的回报是正态分布的,这允许通过分析推导出一个估计。

## 历史模拟

*历史模拟*通过直接使用其分布而不依赖于摘要统计数据来从历史数据中推断风险。例如,为了确定投资组合的 95% VaR,我们可以查看过去 100 天内该投资组合的表现,并将其统计量估计为第五差的那天的值。该方法的缺点是历史数据可能有限,并且未能包括假设情况。例如,如果我们的投资组合中的工具历史记录缺少市场崩盘,我们想要模拟在这些情况下我们的投资组合的表现会发生什么?存在使历史模拟在这些问题上更为健壮的技术,例如向数据中引入“冲击”,但我们在这里不会涉及这些技术。

## 蒙特卡罗模拟

*蒙特卡罗模拟*,本章剩余部分将重点讨论,试图通过模拟投资组合在随机条件下的表现,减弱前一种方法中的假设。当我们无法从解析上导出一个概率分布的闭合形式时,我们通常可以通过重复抽样它依赖的更简单的随机变量来估计其概率密度函数,并观察其在总体中的表现。在其最一般的形式下,这种方法:

+   定义了市场条件与每个工具收益之间的关系。这种关系采用了根据历史数据拟合的模型形式。

+   定义了市场条件的分布,可以轻松进行抽样。这些分布是根据历史数据拟合的。

+   提出由随机市场条件组成的试验。

+   计算每次试验的总投资组合损失,并使用这些损失定义损失的经验分布。这意味着如果我们进行 100 次试验并希望估计 5%的 VaR,我们将选择第五大损失的试验的损失。要计算 5%的 CVaR,我们将找出最差五次试验的平均损失。

当然,蒙特卡罗方法也并非完美无缺。它依赖于用于生成试验条件和推断仪器性能的模型,并且这些模型必须进行简化假设。如果这些假设与现实不符,那么最终的概率分布也将不准确。

# 我们的模型

蒙特卡洛风险模型通常将每个工具的回报用一组市场因素表达。常见的市场因素可能是诸如标准普尔 500 指数、美国 GDP 或货币汇率等的指数值。然后,我们需要一个模型,根据这些市场条件预测每个工具的回报。在我们的模拟中,我们将使用一个简单的线性模型。根据我们之前对回报的定义,*因子回报*是一个特定时间内市场因素值的变化。例如,如果标准普尔 500 指数从 2000 上升到 2100,其回报将是 100。我们将从因子回报的简单转换中派生一组特征。也就是说,试验*t*中的市场因素向量*m[t]*通过某个函数ϕ转换,产生可能不同长度的特征向量*f[t]*:

+   *f[t] = ϕ(m[t])*

对于每个工具,我们将训练一个模型,为每个特征分配一个权重。要计算*r[it]*,即试验*t*中工具*i*的回报,我们使用*c[i]*,工具的截距项;*w[ij]*,工具*i*上特征*j*的回归权重;以及*f[tj]*,试验*t*中特征*j*的随机生成值:

<math alttext="r Subscript i t Baseline equals c Subscript i Baseline plus sigma-summation Underscript j equals 1 Overscript StartAbsoluteValue w Subscript i Baseline EndAbsoluteValue Endscripts w Subscript i j Baseline asterisk f Subscript t j" display="block"><mrow><msub><mi>r</mi> <mrow><mi>i</mi><mi>t</mi></mrow></msub> <mo>=</mo> <msub><mi>c</mi> <mi>i</mi></msub> <mo>+</mo> <munderover><mo>∑</mo> <mrow><mi>j</mi><mo>=</mo><mn>1</mn></mrow> <mrow><mrow><mo>|</mo></mrow><msub><mi>w</mi> <mi>i</mi></msub> <mrow><mo>|</mo></mrow></mrow></munderover> <msub><mi>w</mi> <mrow><mi>i</mi><mi>j</mi></mrow></msub> <mo>*</mo> <msub><mi>f</mi> <mrow><mi>t</mi><mi>j</mi></mrow></msub></mrow></math>

这意味着每个工具的回报被计算为市场因素特征的回报之和,乘以它们在该工具上的权重。我们可以使用历史数据为每个工具拟合线性模型(也称为进行线性回归)。如果 VaR 计算的视野是两周,那么回归将历史上每个(重叠的)两周区间视为一个标记点。

值得一提的是,我们本可以选择更复杂的模型。例如,模型不必是线性的:它可以是回归树,或者明确地结合领域特定的知识。

现在,我们有了计算市场因素导致的工具损失的模型,我们需要一个过程来模拟市场因素的行为。一个简单的假设是,每个市场因素的回报都遵循正态分布。为了捕捉市场因素通常相关的事实——当纳斯达克下跌时,道琼斯也可能在受苦——我们可以使用带有非对角协方差矩阵的多元正态分布:

<math alttext="m Subscript t Baseline tilde script upper N left-parenthesis mu comma normal upper Sigma right-parenthesis" display="block"><mrow><msub><mi>m</mi> <mi>t</mi></msub> <mo>∼</mo> <mi>𝒩</mi> <mrow><mo>(</mo> <mi>μ</mi> <mo>,</mo> <mi>Σ</mi> <mo>)</mo></mrow></mrow></math>

其中μ是因素回报的经验均值向量,Σ是因素回报的经验协方差矩阵。

与之前一样,我们本可以选择更复杂的模拟市场的方法,或者假设每个市场因素的分布类型不同,也许使用具有更厚尾部的分布。

# 获取数据

下载历史股价数据集,并将其放置在*data/stocks/*目录中:

$ mkdir stocks && cd stocks
$ url="https://raw.githubusercontent.com/
sryza/aas/master/ch09-risk/data/stocks.zip"
$ wget \(url \) unzip stocks.zip


很难找到大量格式良好的历史价格数据。本章中使用的数据集是从 Yahoo!下载的。

我们还需要风险因素的历史数据。对于我们的因素,我们将使用以下值:

+   iShares 20 Plus Year Treasury Bond ETF (NASDAQ: TLT)

+   iShares 美国信用债券 ETF(NYSEArca: CRED)

+   SPDR 黄金信托基金(NYSEArca: GLD)

下载并放置因子数据:

$ cd .. && mkdir factors && cd factors
$ url2 = "https://raw.githubusercontent.com/
sryza/aas/master/ch09-risk/data/factors.zip"
$ wget \(url2 \) unzip factors.zip
$ ls factors
...

NASDAQ%3ATLT.csv NYSEARCA%3ACRED.csv NYSEARCA%3AGLD.csv


让我们看看我们的一个因子:

$ !head -n 5 data/factors/NASDAQ%3ATLT.csv
...

Date,Open,High,Low,Close,Volume
31-Dec-13,102.29,102.55,101.17,101.86,7219195
30-Dec-13,102.15,102.58,102.08,102.51,4491711
27-Dec-13,102.07,102.31,101.69,101.81,4755262
26-Dec-13,102.35,102.36,102.01,102.10,4645323
24-Dec-13,103.23,103.35,102.80,102.83,4897009


下载完我们的数据集后,现在我们将对其进行准备。

# 准备数据

雅虎格式的 GOOGL 数据的前几行如下所示:

$ !head -n 5 data/stocks/GOOGL.csv
...

Date,Open,High,Low,Close,Volume
31-Dec-13,556.68,561.06,553.68,560.92,1358300
30-Dec-13,560.73,560.81,555.06,555.28,1236709
27-Dec-13,560.56,560.70,557.03,559.76,1570140
26-Dec-13,557.56,560.06,554.90,559.29,1338507
24-Dec-13,558.04,558.18,554.60,556.48,734170


让我们启动 PySpark shell:

$ pyspark --driver-memory 4g


将仪器数据集作为 DataFrame 读取:

stocks = spark.read.csv("data/stocks/", header='true', inferSchema='true')

stocks.show(2)
...

+----------+----+----+----+-----+------+
| Date|Open|High| Low|Close|Volume|
+----------+----+----+----+-----+------+
|2013-12-31|4.40|4.48|3.92| 4.07|561247|
|2013-12-30|3.93|4.42|3.90| 4.38|550358|
+----------+----+----+----+-----+------+


DataFrame 缺少仪器符号。让我们使用对应每行的输入文件名添加它:

from pyspark.sql import functions as fun

stocks = stocks.withColumn("Symbol", fun.input_file_name()).
withColumn("Symbol",
fun.element_at(fun.split("Symbol", "/"), -1)).
withColumn("Symbol",
fun.element_at(fun.split("Symbol", "."), 1))

stocks.show(2)
...
+---------+-------+-------+-------+-------+------+------+
| Date| Open| High| Low| Close|Volume|Symbol|
+---------+-------+-------+-------+-------+------+------+
|31-Dec-13|1884.00|1900.00|1880.00| 1900.0| 546| CLDN|
|30-Dec-13|1889.00|1900.00|1880.00| 1900.0| 1656| CLDN|
+---------+-------+-------+-------+-------+------+------+


我们将以类似的方式读取并处理因子数据集:

factors = spark.read.csv("data/factors", header='true', inferSchema='true')
factors = factors.withColumn("Symbol", fun.input_file_name()).
withColumn("Symbol",
fun.element_at(fun.split("Symbol", "/"), -1)).
withColumn("Symbol",
fun.element_at(fun.split("Symbol", "."), 1))


我们过滤掉历史少于五年的仪器:

from pyspark.sql import Window

stocks = stocks.withColumn('count', fun.count('Symbol').
over(Window.partitionBy('Symbol'))).
filter(fun.col('count') > 260*5 + 10)


不同类型的仪器可能在不同的日子交易,或者数据可能由于其他原因存在缺失值,因此确保我们的不同历史记录对齐非常重要。首先,我们需要将所有时间序列剪裁到同一时间段。为此,我们将首先将`Date`列的类型从字符串转换为日期:

stocks = stocks.withColumn('Date',
fun.to_date(fun.to_timestamp(fun.col('Date'),
'dd-MM-yy')))
stocks.printSchema()
...
root
|-- Date: date (nullable = true)
|-- Open: string (nullable = true)
|-- High: string (nullable = true)
|-- Low: string (nullable = true)
|-- Close: double (nullable = true)
|-- Volume: string (nullable = true)
|-- Symbol: string (nullable = true)
|-- count: long (nullable = false)


让我们将仪器的时间期限调整对齐:

from datetime import datetime

stocks = stocks.filter(fun.col('Date') >= datetime(2009, 10, 23)).
filter(fun.col('Date') <= datetime(2014, 10, 23))


我们还将在因子 DataFrame 中转换`Date`列的类型并调整时间段:

factors = factors.withColumn('Date',
fun.to_date(fun.to_timestamp(fun.col('Date'),
'dd-MMM-yy')))

factors = factors.filter(fun.col('Date') >= datetime(2009, 10, 23)).
filter(fun.col('Date') <= datetime(2014, 10, 23))


几千种仪器和三个因子的历史数据足够小,可以在本地读取和处理。即使是包含数十万个仪器和数千个因子的更大型模拟,情况也是如此。尽管我们迄今为止使用 PySpark 对数据进行预处理,但当我们实际运行模拟时,如 PySpark 这样的分布式系统的需求变得迫切,因为每个仪器可能需要大量计算。我们可以将 PySpark 的 DataFrame 转换为 pandas 的 DataFrame,仍然可以通过内存操作轻松地继续使用它。

stocks_pd_df = stocks.toPandas()
factors_pd_df = factors.toPandas()

factors_pd_df.head(5)
...
Date Open High Low Close Volume Symbol
0 2013-12-31 102.29 102.55 101.17 101.86 7219195
NASDAQ%253ATLT
1 2013-12-30 102.15 102.58 102.08 102.51 4491711
NASDAQ%253ATLT
2 2013-12-27 102.07 102.31 101.69 101.81 4755262
NASDAQ%253ATLT
3 2013-12-26 102.35 102.36 102.01 102.10 4645323
NASDAQ%253ATLT
4 2013-12-24 103.23 103.35 102.80 102.83 4897009
NASDAQ%253ATLT


我们将在下一节中使用这些 pandas 数据框架,试图拟合一个线性回归模型,以预测基于因子回报的仪器回报。

# 确定因子权重

记住 VaR 处理的是*特定时间范围内*的损失。我们关注的不是仪器的绝对价格,而是这些价格在给定时间段内的波动。在我们的计算中,我们将将这个长度设置为两周。以下函数利用 pandas 的`rolling`方法将价格时间序列转换为重叠的两周价格变动序列。请注意,我们使用 10 而不是 14 来定义窗口,因为金融数据不包括周末:

n_steps = 10
def my_fun(x):
return ((x.iloc[-1] - x.iloc[0]) / x.iloc[0])

stock_returns = stocks_pd_df.groupby('Symbol').Close.
rolling(window=n_steps).apply(my_fun)
factors_returns = factors_pd_df.groupby('Symbol').Close.\
rolling(window=n_steps).apply(my_fun)

stock_returns = stock_returns.reset_index().
sort_values('level_1').
reset_index()
factors_returns = factors_returns.reset_index().
sort_values('level_1').
reset_index()


有了这些回报历史数据,我们可以开始实现对仪器回报进行预测模型的目标。对于每个仪器,我们希望建立一个模型,根据相同时间段内因子的回报来预测其两周回报。为简单起见,我们将使用线性回归模型。

为了模拟仪器收益可能是因子收益的非线性函数的事实,我们可以在我们的模型中包含一些额外的特征,这些特征是从因子收益的非线性转换中导出的。例如,我们将为每个因子收益添加一个额外的特征:平方。我们的模型仍然是一个线性模型,因为响应变量是特征的线性函数。一些特征恰好是由因子收益的非线性函数确定的。请记住,这种特定的特征转换旨在演示一些可用的选项,不应被视为预测金融建模中的最新实践。

Create combined stocks DF

stocks_pd_df_with_returns = stocks_pd_df.
assign(stock_returns =
stock_returns['Close'])

Create combined factors DF

factors_pd_df_with_returns = factors_pd_df.
assign(factors_returns =
factors_returns['Close'],
factors_returns_squared =
factors_returns['Close']**2)

factors_pd_df_with_returns = factors_pd_df_with_returns.
pivot(index='Date',
columns='Symbol',
values=['factors_returns',
'factors_returns_squared']) 1

factors_pd_df_with_returns.columns = factors_pd_df_with_returns.
columns.
to_series().
str.
join('_').
reset_index()[0] 2

factors_pd_df_with_returns = factors_pd_df_with_returns.
reset_index()

print(factors_pd_df_with_returns.head(1))
...
0 Date factors_returns_NASDAQ%253ATLT \ 0 2009-10-23 0.01834

0 factors_returns_NYSEARCA%253ACRED
0 -0.006594

0 factors_returns_NYSEARCA%253AGLD \ 0 - 0.032623

0 factors_returns_squared_NASDAQ%253ATLT \ 0 0.000336

0 factors_returns_squared_NYSEARCA%253ACRED \ 0 0.000043

0 factors_returns_squared_NYSEARCA%253AGLD
0 0.001064
...

print(factors_pd_df_with_returns.columns)
...
Index(['Date', 'factors_returns_NASDAQ%253ATLT',
'factors_returns_NYSEARCA%253ACRED', 'factors_returns_NYSEARCA%253AGLD',
'factors_returns_squared_NASDAQ%253ATLT',
'factors_returns_squared_NYSEARCA%253ACRED',
'factors_returns_squared_NYSEARCA%253AGLD'],
dtype='object', name=0)
...


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_estimating_financial_risk_CO1-1)

将因子数据框从长格式转换为宽格式,以便每个周期的所有因子都在一行中

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_estimating_financial_risk_CO1-2)

展平多级索引数据框并修复列名

即使我们将进行许多回归分析——每个仪器一个——但每个回归中的特征数和数据点都很少,这意味着我们不需要使用 PySpark 的分布式线性建模能力。相反,我们将使用 scikit-learn 包提供的普通最小二乘回归:

from sklearn.linear_model import LinearRegression

For each stock, create input DF for linear regression training

stocks_factors_combined_df = pd.merge(stocks_pd_df_with_returns,
factors_pd_df_with_returns,
how="left", on="Date")

feature_columns = list(stocks_factors_combined_df.columns[-6:])

with pd.option_context('mode.use_inf_as_na', True):
stocks_factors_combined_df = stocks_factors_combined_df.
dropna(subset=feature_columns
+ ['stock_returns'])

def find_ols_coef(df):
y = df[['stock_returns']].values
X = df[feature_columns]

regr = LinearRegression()
regr_output = regr.fit(X, y)

return list(df[['Symbol']].values[0]) + \
            list(regr_output.coef_[0])

coefs_per_stock = stocks_factors_combined_df.
groupby('Symbol').
apply(find_ols_coef)

coefs_per_stock = pd.DataFrame(coefs_per_stock).reset_index()
coefs_per_stock.columns = ['symbol', 'factor_coef_list']

coefs_per_stock = pd.DataFrame(coefs_per_stock.
factor_coef_list.tolist(),
index=coefs_per_stock.index,
columns = ['Symbol'] + feature_columns)

coefs_per_stock


现在我们有了一个数据框,其中每一行都是一个仪器的模型参数集(系数、权重、协变量、回归变量,或者你希望称呼它们的任何东西)。

在任何真实的管道中的这一点上,了解这些模型对数据的拟合程度是很有用的。因为数据点来自时间序列,特别是时间间隔重叠,样本很可能是自相关的。这意味着常见的测量如 *R*² 很可能会高估模型对数据的拟合程度。[Breusch-Godfrey test](https://oreil.ly/9cwg6) 是评估这些效应的标准测试。评估模型的一种快速方法是将时间序列分成两组,留出足够的中间数据点,以使较早集合中的最后点与较晚集合中的第一点不自相关。然后在一组上训练模型,并查看其在另一组上的误差。

现在我们手头有了将因子收益映射到仪器收益的模型,接下来我们需要一个过程来通过生成随机因子收益来模拟市场条件。这就是我们接下来要做的。

# 抽样

为了想出一种生成随机因子收益的方法,我们需要决定因子收益向量上的概率分布,并从中抽样。数据实际上采用什么分布?从视觉上回答这类问题通常是有用的。

可视化连续数据上的概率分布的一种好方法是密度图,它绘制了分布的定义域与其概率密度函数。因为我们不知道支配数据的分布,所以我们没有一个可以在任意点给出其密度的方程,但我们可以通过一种称为*核密度估计*(KDE)的技术来近似它。在松散的方式中,核密度估计是一种平滑直方图的方法。它在每个数据点处将概率分布(通常是正态分布)居中。因此,一组两周回报样本将导致多个正态分布,每个分布具有不同的均值。为了在给定点估计概率密度,它评估该点处所有正态分布的概率密度函数并取它们的平均值。核密度图的平滑程度取决于其*带宽*,即每个正态分布的标准差。

我们将使用 pandas DataFrame 的内置方法之一来计算并绘制 KDE 图。以下代码片段创建了一个因子的密度图:

samples = factors_returns.loc[factors_returns.Symbol ==
factors_returns.Symbol.unique()[0]]['Close']
samples.plot.kde()


图 8-1 展示了我们历史上两周 20+年期国库券 ETF 回报的分布(概率密度函数)。

![两周 20+年期国库券 ETF 分布](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_0801.png)

###### 图 8-1\. 两周 20+年期国库券 ETF 分布

图 8-2 展示了美国信用债券两周收益的相同情况。

![aaps 0802](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_0802.png)

###### 图 8-2\. 美国信用债券 ETF 两周回报分布

我们将为每个因子的回报拟合正态分布。寻找一个更接近数据的更奇特分布,也许是具有更胖尾巴的分布,通常是值得的。然而,出于简化起见,我们将避免通过这种方式调整我们的模拟。

抽样因子回报的最简单方法是将正态分布拟合到每个因子,并从这些分布中独立抽样。然而,这忽略了市场因素通常相关的事实。如果国库券 ETF 下跌,信用债券 ETF 也可能下跌。不考虑这些相关性可能会使我们对我们的风险配置有一个比实际情况更乐观的看法。我们的因子回报是否相关?pandas 中的 Pearson 相关实现可以帮助我们找出:

f_1 = factors_returns.loc[factors_returns.Symbol ==
factors_returns.Symbol.unique()[0]]['Close']
f_2 = factors_returns.loc[factors_returns.Symbol ==
factors_returns.Symbol.unique()[1]]['Close']
f_3 = factors_returns.loc[factors_returns.Symbol ==
factors_returns.Symbol.unique()[2]]['Close']

pd.DataFrame({'f1': list(f_1), 'f2': list(f_2), 'f3': list(f_3)}).corr()
...

     f1 	   f2 	    f3

f1 1.000000 0.530550 0.074578
f2 0.530550 1.000000 0.206538
f3 0.074578 0.206538 1.000000


因为我们对角线之外有非零元素,看起来不像是这样。

## 多元正态分布

多元正态分布可以通过考虑因素之间的相关信息来帮助。来自多元正态分布的每个样本都是一个向量。给定所有维度但一个维度的值,沿该维度的值的分布是正态的。但是,在它们的联合分布中,变量不是独立的。

多元正态分布是用每个维度的均值和描述每对维度之间协方差的矩阵参数化的。对于 *N* 维度,协方差矩阵是 *N* 行 *N* 列,因为我们希望捕捉每对维度之间的协方差关系。当协方差矩阵是对角线时,多元正态分布减少到独立沿每个维度抽样,但在非对角线上放置非零值有助于捕捉变量之间的关系。

VaR 文献经常描述了一个步骤,其中因子权重被转换(去相关化),以便进行抽样。通常使用 Cholesky 分解或特征分解来实现这一点。NumPy 包的`MultivariateNormalDistribution`在幕后使用特征分解为我们处理这一步。

要将多元正态分布拟合到我们的数据中,首先我们需要找到其样本均值和协方差:

factors_returns_cov = pd.DataFrame({'f1': list(f_1),
'f2': list(f_2[:-1]),
'f3': list(f_3[:-2])})
.cov().to_numpy()
factors_returns_mean = pd.DataFrame({'f1': list(f_1),
'f2': list(f_2[:-1]),
'f3': list(f_3[:-2])}).
mean()


然后我们可以简单地创建一个由它们参数化的分布,并从中抽样一组市场条件:

from numpy.random import multivariate_normal

multivariate_normal(factors_returns_mean, factors_returns_cov)
...
array([ 0.02234821, 0.01838763, -0.01107748])


通过每个仪器模型和抽样因子回报的过程,我们现在具备运行实际试验所需的条件。让我们开始模拟并运行试验。

# 运行试验

由于运行试验的计算密集型,我们将求助于 PySpark 来帮助我们并行化它们。在每次试验中,我们希望抽样一组风险因素,用它们来预测每个仪器的回报,并总结所有这些回报以找到完整的试验损失。为了获得代表性分布,我们希望运行数千或数百万次这样的试验。

我们有几种选择来并行化模拟。我们可以沿着试验、仪器或两者并行化。要沿两者并行化,我们将创建一个仪器数据集和一个试验参数数据集,然后使用`crossJoin`转换来生成所有配对的数据集。这是最一般的方法,但它有几个缺点。首先,它需要显式创建一个试验参数的 DataFrame,我们可以通过使用一些随机种子的技巧来避免这一点。其次,它需要进行洗牌操作。

按仪器进行分区可能看起来像这样:

random_seed = 1496
instruments_dF = ...
def trialLossesForInstrument(seed, instrument):
...

instruments_DF.rdd.
flatMap(trialLossesForInstrument(random_seed, )).
reduceByKey(
+ _)


采用这种方法,数据被分区到仪器的 DataFrame 中,对于每个仪器,`flatMap`转换计算并产生相应的每个试验的损失。在所有任务中使用相同的随机种子意味着我们将生成相同的试验序列。`reduceByKey`将所有对应相同试验的损失总和在一起。这种方法的一个缺点是仍然需要对 *O*(|instruments| * |trials|) 的数据进行洗牌。

我们数千种工具的模型数据足够小,可以放入每个执行器的内存中,一些粗略的计算显示,即使是数百万种工具和数百个因子,情况可能仍然如此。一百万个工具乘以 500 个因子乘以每个因子权重所需的 8 字节,大约等于 4 GB,足够小,可以放入大多数现代集群机器上的每个执行器中。这意味着一个好的选项是将工具数据分布为广播变量。每个执行器都拥有工具数据的完整副本的优势在于,每次试验的总损失可以在单台机器上计算。不需要聚合。我们还广播了一些其他用于计算试验回报所需的数据。

b_coefs_per_stock = spark.sparkContext.broadcast(coefs_per_stock)
b_feature_columns = spark.sparkContext.broadcast(feature_columns)
b_factors_returns_mean = spark.sparkContext.broadcast(factors_returns_mean)
b_factors_returns_cov = spark.sparkContext.broadcast(factors_returns_cov)


使用分区试验方法(我们将使用此方法),我们从种子数据框开始。我们希望每个分区中都有不同的种子,以便每个分区生成不同的试验:

from pyspark.sql.types import IntegerType

parallelism = 1000
num_trials = 1000000
base_seed = 1496

seeds = [b for b in range(base_seed,
base_seed + parallelism)]
seedsDF = spark.createDataFrame(seeds, IntegerType())

seedsDF = seedsDF.repartition(parallelism)


随机数生成是一个耗时且需要大量 CPU 资源的过程。虽然我们这里没有采用这个技巧,但通常预先生成一组随机数并在多个作业中使用是非常有用的。不应在单个作业内使用相同的随机数,因为这会违反蒙特卡洛假设,即随机值是独立分布的。

对于每个种子,我们希望生成一组试验参数,并观察这些参数对所有工具的影响。我们将编写一个函数,用于计算多次试验中工具的全面回报。我们首先简单地应用我们之前为每个工具训练过的线性模型。然后我们对所有工具的回报进行平均。这假设我们在投资组合中持有每种工具的等值。如果我们持有每支股票的数量不同,则会使用加权平均。最后,在每个任务中需要生成一堆试验。由于选择随机数是过程的重要部分,使用强大的随机数生成器非常重要。Python 内置的`random`库包括一个梅森旋转器实现,非常适合这个目的。我们用它来从之前描述的多变量正态分布中进行抽样:

import random

from pyspark.sql.types import LongType, ArrayType
from pyspark.sql.functions import udf

def calculate_trial_return(x):

return x

trial_return_list = []

for i in range(num_trials/parallelism):
    random_int = random.randint(0, num_trials*num_trials)

    seed(x)

    random_factors = multivariate_normal(b_factors_returns_mean.value,
      b_factors_returns_cov.value)

    coefs_per_stock_df = b_coefs_per_stock.value
    returns_per_stock = coefs_per_stock_df[b_feature_columns.value] *
      (list(random_factors) + list(random_factors**2))

    trial_return_list.append(float(returns_per_stock.sum(axis=1).sum()/b_coefs_
      per_stock.value.size))

return trial_return_list

udf_return = udf(calculate_trial_return, ArrayType(DoubleType()))


随着我们的脚手架完成,我们可以使用它来计算一个数据框,其中每个元素都是单次试验的总回报:

from pyspark.sql.functions import col, explode

trials = seedsDF.withColumn("trial_return", udf_return(col("value")))
trials = trials.select('value', explode('trial_return')) 1

trials.cache()


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_estimating_financial_risk_CO2-1)

将试验回报的分割数组拆分为单独的数据框行

如果您还记得,我们一直在处理所有这些数字的原因是为了计算 VaR。`trials` 现在形成了一个投资组合回报的经验分布。要计算 5%的 VaR,我们需要找到一个我们预计在 5%的时间内表现不佳的回报,以及一个我们预计在 95%的时间内表现优异的回报。通过我们的经验分布,这简单地意味着找到那些比 5%的 trials 更差的值,以及比 95%的 trials 更好的值。我们可以通过将最差的 5%的 trials 拉到驾驶员中来实现这一点。我们的 VaR 是这个子集中最佳试验的回报:

trials.approxQuantile('trial_return', [0.05], 0.0)
...
-0.010831826593164014


我们可以使用几乎相同的方法找到 CVaR。与从最差的 5%的试验中获取最佳试验回报不同,我们从该组试验中获取回报的平均值:

trials.orderBy(col('trial_return').asc()).
limit(int(trials.count()/20)).
agg(fun.avg(col("trial_return"))).show()
...
+--------------------+
| avg(trial_return)|
+--------------------+
|-0.09002629251426077|
+--------------------+


# 可视化回报分布

除了在特定置信水平下计算 VaR 外,查看回报分布的更全面画面也很有用。它们是否呈正态分布?它们是否在极端处出现尖峰?正如我们为个体因素所做的那样,我们可以使用核密度估计来绘制联合概率分布的概率密度函数的估计(见图 8-3):

trials.plot.kde()


![aaps 0803](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_0803.png)

###### 图 8-3\. 两周回报分布

# 接下来该如何进行

这个练习中提出的模型是实际金融机构中使用的第一个粗糙版本。在建立准确的 VaR 模型时,我们忽略了一些非常重要的步骤。筛选市场因素集合可以使模型成功或失败,金融机构在其模拟中通常会纳入数百个因素。选择这些因素需要在历史数据上运行大量实验和大量创意。选择将市场因素映射到工具回报的预测模型也很重要。虽然我们使用了简单的线性模型,但许多计算使用非线性函数或通过布朗运动模拟随时间的路径。

最后,值得注意的是,用于模拟因子回报的分布需要仔细考虑。Kolmogorov-Smirnov 检验和卡方检验对于测试经验分布的正态性非常有用。Q-Q 图对于直观比较分布也很有用。通常,金融风险更好地通过具有比我们使用的正态分布更大尾部的分布来反映。混合正态分布是实现这些更大尾部的一种好方法。马克斯·哈斯和克里斯蒂安·皮戈尔施的文章“《金融经济学,厚尾分布》”([`oreil.ly/XSxhB`](https://oreil.ly/XSxhB))提供了关于其他厚尾分布的良好参考。

银行使用 PySpark 和大规模数据处理框架来计算历史方法下的 VaR。["使用历史数据评估风险价值模型"](https://oreil.ly/0JoXu),作者 Darryll Hendricks,提供了对历史 VaR 方法的概述和性能比较。

蒙特卡罗风险模拟不仅可以用于计算单一统计数据。其结果可以通过塑造投资决策来主动减少投资组合的风险。例如,如果在表现最差的试验中,某一类工具反复出现亏损,我们可以考虑将这些工具从投资组合中剔除,或者增加那些与其运动方向相反的工具。


# 第九章:分析基因组数据和 BDG 项目

下一代 DNA 测序(NGS)技术的出现迅速将生命科学转变为数据驱动的领域。然而,最好利用这些数据的问题正在于传统的计算生态系统,它基于难以使用的低级别分布式计算基元和半结构化文本文件格式的丛林。

本章将有两个主要目的。首先,我们介绍了一组流行的序列化和文件格式(Avro 和 Parquet),这些格式简化了数据管理中的许多问题。这些序列化技术使我们能够将数据转换为紧凑的、机器友好的二进制表示。这有助于在网络中传输数据,并帮助在不同编程语言之间实现交叉兼容。虽然我们将这些序列化技术应用于基因组数据,但这些概念在处理大量数据时也是有用的。

其次,我们展示了如何在 PySpark 生态系统中执行典型的基因组任务。具体来说,我们将使用 PySpark 和开源 ADAM 库来操作大量的基因组数据,并处理来自多个来源的数据,创建一个用于预测转录因子(TF)结合位点的数据集。为此,我们将从[ENCODE 数据集](https://oreil.ly/h0yOq)中加入基因组注释。本章将作为 ADAM 项目的教程,该项目包括一组专用于基因组的 Avro 模式、基于 PySpark 的 API 以及用于大规模基因组分析的命令行工具。除了其他应用程序外,ADAM 还提供了使用 PySpark 的[基因组分析工具包(GATK)](https://oreil.ly/k2YZH)的本地分布式实现。

我们将首先讨论生物信息学领域使用的各种数据格式,相关挑战以及序列化格式如何帮助。然后,我们将安装 ADAM 项目,并使用示例数据集探索其 API。然后,我们将使用多个基因组数据集准备一个用于预测 DNA 序列中特定类型蛋白质(CTCF 转录因子)结合位点的数据集。这些数据集将从公开可用的 ENCODE 数据集获取。由于基因组暗示了一个一维坐标系,许多基因组操作具有空间性质。ADAM 项目提供了一个面向基因组的 API,用于执行我们将使用的分布式空间连接。

对于那些感兴趣的人,生物学的一个很好的入门课程是[Eric Lander 的 EdX 课程](https://oreil.ly/WIky1)。想要了解生物信息学的人,可以参考 Arthur Lesk 的《生物信息学导论》(牛津大学出版社)。

# 解耦存储和建模

生物信息学家在处理文件格式上花费了大量时间,如*.fasta*, *.fastq*, *.sam*, *.bam*, *.vcf*, *.gvcf*, *.bcf*, *.bed*, *.gff*, *.gtf*, *.narrowPeak*, *.wig*, *.bigWig*, *.bigBed*, *.ped*, 和 *.tped*等等。一些科学家还觉得有必要为他们的自定义工具指定自己的自定义格式。此外,许多格式规范都是不完整或不明确的(这使得确保实现的一致性或合规性变得困难),并且指定 ASCII 编码数据。ASCII 数据在生物信息学中非常常见,但效率低且相对难以压缩。此外,数据必须始终被解析,需要额外的计算周期。

这特别令人困扰,因为所有这些文件格式本质上都只存储几种常见的对象类型:对齐的序列读取,调用的基因型,序列特征和表型。(在基因组学中,“序列特征”这个术语略有重载,但在本章中,我们指的是 UCSC 基因组浏览器轨迹的元素意义上的内容。)像[`biopython`](http://biopython.org)这样的库非常受欢迎,因为它们充斥着解析器(例如`Bio.SeqIO`),试图将所有文件格式读入少数常见的内存模型中(例如`Bio.Seq`,`Bio.SeqRecord`,`Bio.SeqFeature`)。

我们可以通过像 Apache Avro 这样的序列化框架一次性解决所有这些问题。关键在于 Avro 将数据模型(即显式模式)与底层存储文件格式以及语言的内存表示分离开来。Avro 指定了如何在不同进程之间传递特定类型的数据,无论是在互联网上运行的进程之间,还是尝试将数据写入特定文件格式的进程中。例如,使用 Avro 的 Java 程序可以将数据写入与 Avro 数据模型兼容的多种底层文件格式中。这使得每个进程都不必再担心与多种文件格式的兼容性:进程只需要知道如何读取 Avro,而文件系统则需要知道如何提供 Avro。

让我们以序列特征为例。我们首先使用 Avro 接口定义语言(IDL)为对象指定所需的模式:

enum Strand {
Forward,
Reverse,
Independent
}

record SequenceFeature {
string featureId;
string featureType; 1
string chromosome;
long startCoord;
long endCoord;
Strand strand;
double value;
map attributes;
}


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO1-1)

例如,“保护”,“蜈蚣”,“基因”

此数据类型可以用来编码基因组中特定位置的保守水平、启动子或核糖体结合位点的存在、转录因子结合位点等。可以将其视为 JSON 的二进制版本,但更为受限且性能更高。根据特定的数据模式,Avro 规范确定对象的精确二进制编码,以便可以在不同编程语言中的进程之间(甚至是不同编程语言中的进程)、通过网络或存储到磁盘进行轻松通信。Avro 项目包括用于处理来自多种语言的 Avro 编码数据的模块,包括 Java、C/C++、Python 和 Perl;然后,语言可以自由选择以最有利的方式将对象存储在内存中。数据建模与存储格式的分离提供了另一层灵活性/抽象;Avro 数据可以存储为 Avro 序列化的二进制对象(Avro 容器文件)、为快速查询存储为列式文件格式(Parquet 文件)或以文本 JSON 数据的方式存储以获得最大灵活性(最小效率)。最后,Avro 支持模式演化,允许用户在需要时添加新字段,而软件可以优雅地处理新旧版本的模式。

总体而言,Avro 是一种高效的二进制编码,允许您指定可演变的数据模式,从许多编程语言处理相同的数据,并使用多种格式存储数据。决定使用 Avro 模式存储数据使您摆脱了不断使用越来越多自定义数据格式的困扰,同时提高了计算性能。

在前面示例中使用的特定的 `SequenceFeature` 模型对于真实数据来说有些简单,但是大数据基因组学(BDG)项目已经定义了 Avro 模式来表示以下对象,以及许多其他对象:

+   `AlignmentRecord` 表示读取时的对齐记录。

+   `Variant` 表示已知的基因组变异和元数据。

+   `Genotype` 表示特定位点上的基因型。

+   `Feature` 表示基因组片段上的序列特征(注释)。

实际的模式可以在 [bdg-formats GitHub 仓库](https://oreil.ly/gCf1f) 中找到。BDG 格式可以替代广泛使用的“传统”格式(如 BAM 和 VCF),但更常见的是作为高性能的“中间”格式。(这些 BDG 格式最初的目标是取代 BAM 和 VCF 的使用,但它们顽固的普遍性使得这个目标变得难以实现。)相对于自定义的 ASCII 标准,Avro 提供了许多性能和数据建模的优势。

在本章的其余部分,我们将使用一些 BDG 模式来完成一些典型的基因组学任务。在此之前,我们需要安装 ADAM 项目。这将在下一节中进行。

# 设置 ADAM

BDG 的核心基因组工具集称为 ADAM。从一组映射读取开始,此核心包括可以执行标记重复、基质质量分数校正、插入缺失实线和变异调用等任务的工具。ADAM 还包含一个命令行界面,用于简化使用。与传统的 HPC 工具不同,ADAM 可以在集群中自动并行化,无需手动拆分文件或调度作业。

我们可以通过 pip 安装 ADAM 开始:

pip3 install bdgenomics.adam


可以在[GitHub 页面](https://oreil.ly/4eFnX)找到替代的安装方法。

ADAM 还附带一个提交脚本,可以方便地与 Spark 的 `spark-submit` 脚本进行交互:

adam-submit
...

Using ADAM_MAIN=org.bdgenomics.adam.cli.ADAMMain
Using spark-submit=/home/analytical-monk/miniconda3/envs/pyspark/bin/spark-submit

   e        888~-_         e            e    e
  d8b       888   \       d8b          d8b  d8b
 /Y88b      888    |     /Y88b        d888bdY88b
/  Y88b     888    |    /  Y88b      / Y88Y Y888b

/____Y88b 888 / /___Y88b / YY Y888b
/ Y88b 888
-~ / Y88b / Y888b

Usage: adam-submit [ --]

Choose one of the following commands:

ADAM ACTIONS
countKmers : Counts the k-mers/q-mers from a read dataset...
countSliceKmers : Counts the k-mers/q-mers from a slice dataset...
transformAlignments : Convert SAM/BAM to ADAM format and optionally...
transformFeatures : Convert a file with sequence features into...
transformGenotypes : Convert a file with genotypes into correspondi...
transformSequences : Convert a FASTA file as sequences into corresp...
transformSlices : Convert a FASTA file as slices into correspond...
transformVariants : Convert a file with variants into correspondin...
mergeShards : Merges the shards of a fil...
coverage : Calculate the coverage from a given ADAM fil...
CONVERSION OPERATION
adam2fastq : Convert BAM to FASTQ file
transformFragments : Convert alignments into fragment records
PRIN
print : Print an ADAM formatted fil
flagstat : Print statistics on reads in an ADAM file...
view : View certain reads from an alignment-record file.


此时,您应该能够从命令行运行 ADAM 并获得使用消息。如使用消息中所述,Spark 参数在 ADAM 特定参数之前给出。

设置好 ADAM 后,我们可以开始处理基因组数据。接下来,我们将使用一个样本数据集探索 ADAM 的 API。

# 介绍使用 ADAM 处理基因组数据的工作方式

我们将从一个包含一些映射的 *.bam* 文件开始,将其转换为相应的 BDG 格式(在这种情况下为 `AlignedRecord`),并将其保存到 HDFS 中。首先,我们获取一个合适的 *.bam* 文件:

Note: this file is 16 GB

curl -O ftp://ftp.ncbi.nlm.nih.gov/1000genomes/ftp/phase3/data
/HG00103/alignment/HG00103.mapped.ILLUMINA.bwa.GBR
.low_coverage.20120522.bam

or using Aspera instead (which is much faster)

ascp -i path/to/asperaweb_id_dsa.openssh -QTr -l 10G
anonftp@ftp.ncbi.nlm.nih.gov:/1000genomes/ftp/phase3/data
/HG00103/alignment/HG00103.mapped.ILLUMINA.bwa.GBR
.low_coverage.20120522.bam .


将下载的文件移动到我们将存储本章所有数据的目录中:

mv HG00103.mapped.ILLUMINA.bwa.GBR
.low_coverage.20120522.bam data/genomics


接下来,我们将使用 ADAM CLI。

## 使用 ADAM CLI 进行文件格式转换

然后,我们可以使用 ADAM 的 `transform` 命令将 *.bam* 文件转换为 Parquet 格式(在“Parquet 格式和列式存储”中描述)。这在集群和 `local` 模式下都可以工作:

adam-submit
--master yarn \ 1
--deploy-mode client
--driver-memory 8G
--num-executors 6
--executor-cores 4
--executor-memory 12G
--
transform \ 2
data/genomics/HG00103.mapped.ILLUMINA.bwa.GBR\ .low_coverage.20120522.bam
data/genomics/HG00103


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO2-1)

运行在 YARN 上的示例 Spark 参数

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO2-2)

ADAM 子命令本身

这应该会启动大量输出到控制台,包括跟踪作业进度的 URL。

结果数据集是 *data/genomics/reads/HG00103/* 目录中所有文件的串联,每个 *part-*.parquet* 文件都是一个 PySpark 任务的输出。您还会注意到,由于列式存储,数据比初始的 *.bam* 文件(其底层是经过 gzip 压缩的)更有效地压缩(请参见“Parquet 格式和列式存储”)。

$ du -sh data/genomics/HG00103*bam
16G data/genomics/HG00103. [...] .bam

$ du -sh data/genomics/HG00103/
13G data/genomics/HG00103


让我们看看这些对象在交互会话中是什么样子的。

## 使用 PySpark 和 ADAM 吸入基因组数据

首先,我们使用 ADAM 助手命令启动 PySpark shell。它会加载所有必要的 JAR 包。

pyadam

...

[...]
Welcome to
____ __
/ / ___ __/ /
_\ / _ / _ / / /
/
/ .
/_,// //_\ version 3.2.1
/
/

Using Python version 3.6.12 (default, Sep 8 2020 23:10:56)
Spark context Web UI available at http://192.168.29.60:4040
Spark context available as 'sc'.
SparkSession available as 'spark'.


在某些情况下,当尝试在 PySpark 中使用 ADAM 时,可能会遇到 TypeError 错误,其中提到 JavaPackage 对象不存在。这是一个已知问题,详细记录在[这里](https://oreil.ly/67uBd)。

在这种情况下,请尝试在线程中建议的解决方案。例如,可以运行以下命令启动带有 ADAM 的 PySpark shell:

!pyspark --conf spark.serializer=org.apache.spark.
serializer.KryoSerializer --conf spark.kryo.registrator=
org.bdgenomics.adam.serialization.ADAMKryoRegistrator
--jars find-adam-assembly.sh --driver-class-path
find-adam-assembly.sh


现在我们将加载对齐的读取数据作为`AlignmentDataset`:

from bdgenomics.adam.adamContext import ADAMContext

ac = ADAMContext(spark)

readsData = ac.loadAlignments("data/HG00103")

readsDataDF = readsData.toDF()
readsDataDF.show(1, vertical=True)

...

-RECORD 0-----------------------------------------
referenceName | hs37d5
start | 21810734
originalStart | null
end | 21810826
mappingQuality | 0
readName | SRR062640.14600566
sequence | TCCATTCCACTCAGTTT...
qualityScores | /MOONNCRQPIQIKRGL...
cigar | 92M8S
originalCigar | null
basesTrimmedFromStart | 0
basesTrimmedFromEnd | 0
readPaired | true
properPair | false
readMapped | false
mateMapped | true
failedVendorQualityChecks | false
duplicateRead | false
readNegativeStrand | false
mateNegativeStrand | false
primaryAlignment | true
secondaryAlignment | false
supplementaryAlignment | false
mismatchingPositions | null
originalQualityScores | null
readGroupId | SRR062640
readGroupSampleId | HG00103
mateAlignmentStart | 21810734
mateReferenceName | hs37d5
insertSize | null
readInFragment | 1
attributes | RG:Z:SRR062640\tX...
only showing top 1 row


由于数据的分区可能在您的系统上不同,因此无法保证哪个读取会先返回,您可能会得到不同的读取结果。

现在我们可以与数据集互动地提出问题,同时在后台跨集群执行计算。在这个数据集中有多少读取?

readsData.toDF().count()
...
160397565


这些数据集的读取是否源自所有人类染色体?

unique_chr = readsDataDF.select('referenceName').distinct().collect()
unique_chr = [u.referenceName for u in unique_chr]

unique_chr.sort()
...
1
10
11
12
[...]
GL000249.1
MT
NC_007605
X
Y
hs37d5


是的,我们观察到来自染色体 1 到 22、X 和 Y 的读取,还有一些不属于“主”染色体的其他染色体片段或位置不明的片段。让我们更仔细地分析一下代码:

readsData = ac.loadAlignments("data/HG00103") 1

readsDataDF = readsData.toDF() 2

unique_chr = readsDataDF.select('referenceName').distinct(). \ 3
collect() 4


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO3-1)

`AlignmentDataset`:一种包含所有数据的 ADAM 类型。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO3-2)

`DataFrame`:底层 Spark DataFrame。

![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO3-3)

这将聚合所有不同的 contig 名称;这将很小。

![4](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO3-4)

这将触发计算并将 DataFrame 中的数据带回客户端应用程序(即 shell)。

举个临床例子,假设我们正在测试一个个体的基因组,以检查其是否携带任何会导致其子代患囊性纤维化(CF)风险基因变异。我们的基因检测使用下一代 DNA 测序从多个相关基因生成读取结果,例如 CFTR 基因(其突变可能导致 CF)。在运行数据通过我们的基因分型管道后,我们确定 CFTR 基因似乎有一个早期终止密码子破坏其功能。然而,这种突变在[Human Gene Mutation Database](https://oreil.ly/wULRR)中从未报告过,也不在[Sickkids CFTR database](https://oreil.ly/u1L0j)中,该数据库汇总了 CF 基因变异。我们想要回到原始测序数据,查看潜在有害基因型调用是否是假阳性。为此,我们需要手动分析所有映射到该变异位点的读取,例如染色体 7 的 117149189 位点(见 Figure 9-1):

from pyspark.sql import functions as fun
cftr_reads = readsDataDF.where("referenceName == 7").
where(fun.col("start") <= 117149189).
where(fun.col("end") > 117149189)

cftr_reads.count()
...

9


现在可以手动检查这九个读取结果,或者通过自定义的对齐器处理它们,例如检查报告的致病变异是否是假阳性。

![aaps 0901](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_0901.png)

###### 图 9-1\. 在 CFTR 基因的 chr7:117149189 处的 HG00103 的整合基因组查看器可视化

假设我们正在作为临床实验室运行,为临床医生提供这样的携带者筛查服务。使用 AWS S3 等云存储系统存档原始数据可确保数据保持相对温暖(与磁带存档相比)。除了确保实际进行数据处理的可靠系统外,我们还可以轻松访问所有过去的数据进行质量控制,或在需要手动干预的情况下(例如前面介绍的 CFTR 示例)进行操作。除了快速访问全部数据的能力外,中心化还使得进行大规模分析研究(如人口遗传学、大规模质量控制分析等)变得更加容易。

现在我们熟悉了 ADAM API,让我们开始创建我们的转录因子预测数据集。

# 从 ENCODE 数据预测转录因子结合位点

在这个例子中,我们将使用公开可用的序列特征数据来构建一个简单的转录因子结合模型。TFs 是在基因组中结合到特定 DNA 序列的蛋白质,并帮助控制不同基因的表达。因此,它们对于确定特定细胞的表型至关重要,并参与许多生理和疾病过程。ChIP-seq 是一种基于 NGS 的分析方法,允许在特定细胞/组织类型中全基因组特征化特定 TF 的结合位点。然而,除了 ChIP-seq 的成本和技术难度外,它还需要对每种组织/TF 对进行单独的实验。相比之下,DNase-seq 是一种寻找全基因组开放染色质区域的分析方法,每种组织类型只需执行一次。我们不想通过为每种组织/TF 组合执行 ChIP-seq 实验来分析 TF 结合位点,而是希望仅凭借 DNase-seq 数据的可用性来预测新组织类型中的 TF 结合位点。

具体来说,我们将使用 DNase-seq 数据以及来自[HT-SELEX](https://oreil.ly/t5OEkL)的已知序列模式数据和[公开可用的 ENCODE 数据集](https://oreil.ly/eFJ9n)中的其他数据,预测 CTCF TF 的结合位点。我们选择了六种不同的细胞类型,这些细胞类型具有用于训练的 DNase-seq 和 CTCF ChIP-seq 数据。训练示例将是一个 DNase 敏感性(HS)峰(基因组的一个片段),TF 是否结合/未结合的二进制标签将根据 ChIP-seq 数据导出。

总结整体数据流程:主要的训练/测试示例将从 DNase-seq 数据中派生。每个开放染色质区域(基因组上的一个区间)将用于生成是否会在特定组织类型中结合特定 TF 的预测。为此,我们将 ChIP-seq 数据与 DNase-seq 数据进行空间连接;每个重叠部分都是 DNase seq 对象的正标签。最后,为了提高预测准确性,我们在 DNase-seq 数据的每个区间中生成一个额外的特征——到转录起始位点的距离(使用 GENCODE 数据集)。通过空间连接(可能进行聚合),将该特征添加到训练示例中。

我们将使用以下细胞系的数据:

GM12878

常见研究的淋巴母细胞系

K562

女性慢性髓系白血病

BJ

皮肤成纤维细胞

HEK293

胚胎肾

H54

胶质母细胞瘤

HepG2

肝细胞癌

首先,我们下载每个细胞系的 DNase 数据,格式为*.narrowPeak*:

mkdir data/genomics/dnase

curl -O -L "https://www.encodeproject.org/
files/ENCFF001UVC/@@download/ENCFF001UVC.bed.gz" |
gunzip > data/genomics/dnase/GM12878.DNase.narrowPeak 1
curl -O -L "https://www.encodeproject.org/
files/ENCFF001UWQ/@@download/ENCFF001UWQ.bed.gz" |
gunzip > data/genomics/dnase/K562.DNase.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001WEI/@@download/ENCFF001WEI.bed.gz" |
gunzip > data/genomics/dnase/BJ.DNase.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001UVQ/@@download/ENCFF001UVQ.bed.gz" |
gunzip > data/genomics/dnase/HEK293.DNase.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001SOM/@@download/ENCFF001SOM.bed.gz" |
gunzip > data/genomics/dnase/H54.DNase.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001UVU/@@download/ENCFF001UVU.bed.gz" |
gunzip > data/genomics/dnase/HepG2.DNase.narrowPeak

[...]


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO4-1)

流式解压缩

接下来,我们下载 CTCF TF 的 ChIP-seq 数据,同样是*.narrowPeak*格式,并且 GENCODE 数据,格式为 GTF:

mkdir data/genomics/chip-seq

curl -O -L "https://www.encodeproject.org/
files/ENCFF001VED/@@download/ENCFF001VED.bed.gz" |
gunzip > data/genomics/chip-seq/GM12878.ChIP-seq.CTCF.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001VMZ/@@download/ENCFF001VMZ.bed.gz" |
gunzip > data/genomics/chip-seq/K562.ChIP-seq.CTCF.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001XMU/@@download/ENCFF001XMU.bed.gz" |
gunzip > data/genomics/chip-seq/BJ.ChIP-seq.CTCF.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001XQU/@@download/ENCFF001XQU.bed.gz" |
gunzip > data/genomics/chip-seq/HEK293.ChIP-seq.CTCF.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001USC/@@download/ENCFF001USC.bed.gz" |
gunzip> data/genomics/chip-seq/H54.ChIP-seq.CTCF.narrowPeak
curl -O -L "https://www.encodeproject.org/
files/ENCFF001XRC/@@download/ENCFF001XRC.bed.gz" |
gunzip> data/genomics/chip-seq/HepG2.ChIP-seq.CTCF.narrowPeak

curl -s -L "http://ftp.ebi.ac.uk/pub/databases/gencode/
Gencode_human/release_18/gencode.v18.annotation.gtf.gz" |
gunzip > data/genomics/gencode.v18.annotation.gtf
[...]


请注意我们如何在将数据流解压缩的过程中使用`gunzip`来将其存储到文件系统中。

从所有这些原始数据中,我们希望生成一个类似以下的训练集模式:

1.  染色体

1.  开始

1.  结束

1.  到最近转录起始位点(TSS)的距离

1.  TF 标识(在这种情况下始终为“CTCF”)

1.  细胞系

1.  TF 结合状态(布尔值;目标变量)

这个数据集可以轻松转换为 DataFrame,以便导入机器学习库中。由于我们需要为多个细胞系生成数据,我们将为每个细胞系单独定义一个 DataFrame,并在最后将它们连接起来:

cell_lines = ["GM12878", "K562", "BJ", "HEK293", "H54", "HepG2"]
for cell in cell_lines:

For each cell line…

…generate a suitable DataFrame

Concatenate the DataFrames and carry through into MLlib, for example


我们定义一个实用函数和一个广播变量,用于生成特征:

local_prefix = "data/genomics"
import pyspark.sql.functions as fun

UDF for finding closest transcription start site

naive; exercise for reader: make this faster

def distance_to_closest(loci, query):
return min([abs(x - query) for x in loci])
distance_to_closest_udf = fun.udf(distance_to_closest)

build in-memory structure for computing distance to TSS

we are essentially implementing a broadcast join here

tss_data = ac.loadFeatures("data/genomics/gencode.v18.annotation.gtf")
tss_df = tss_data.toDF().filter(fun.col("featureType") == 'transcript')
b_tss_df = spark.sparkContext.broadcast(tss_df.groupBy('referenceName').
agg(fun.collect_list("start").alias("start_sites")))


现在,我们已经加载了定义训练示例所需的数据,我们为计算每个细胞系的数据定义“循环”的主体部分。请注意我们如何读取 ChIP-seq 和 DNase 数据的文本表示,因为数据集并不是很大,不会影响性能。

为此,我们加载 DNase 和 ChIP-seq 数据:

current_cell_line = cell_lines[0]

dnase_path = f'data/genomics/dnase/{current_cell_line}.DNase.narrowPeak'
dnase_data = ac.loadFeatures(dnase_path) 1
dnase_data.toDF().columns 2
...
['featureId', 'sampleId', 'name', 'source', 'featureType', 'referenceName',
'start', 'end', 'strand', 'phase', 'frame', 'score', 'geneId', 'transcriptId',
'exonId', 'proteinId', 'aliases', 'parentIds', 'target', 'gap', 'derivesFrom',
'notes', 'dbxrefs', 'ontologyTerms', 'circular', 'attributes']

...

chip_seq_path = f'data/genomics/chip-seq/
{current_cell_line}.ChIP-seq.CTCF.narrowPeak'
chipseq_data = ac.loadFeatures(chipseq_path) 1


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO5-1)

`FeatureDataset`

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO5-2)

Dnase DataFrame 中的列

与 `chipseq_data` 中的 `ReferenceRegion` 定义的 ChIP-seq 峰重叠的位点具有 TF 结合位点,因此标记为 `true`,而其余的位点标记为 `false`。这是通过 ADAM API 中提供的一维空间连接原语来实现的。连接功能需要一个按 `ReferenceRegion` 键入的 RDD,并将生成根据通常的连接语义(例如,内连接与外连接)具有重叠区域的元组。

dnase_with_label = dnase_data.leftOuterShuffleRegionJoin(chipseq_data)
dnase_with_label_df = dnase_with_label.toDF()
...

-RECORD 0----------------------------------------------------------------------..
_1 | {null, null, chr1.1, null, null, chr1, 713841, 714424, INDEPENDENT, null..
_2 | {null, null, null, null, null, chr1, 713945, 714492, INDEPENDENT, null, ..
-RECORD 1----------------------------------------------------------------------..
_1 | {null, null, chr1.2, null, null, chr1, 740179, 740374, INDEPENDENT, null..
_2 | {null, null, null, null, null, chr1, 740127, 740310, INDEPENDENT, null, ..
-RECORD 2----------------------------------------------------------------------..
_1 | {null, null, chr1.3, null, null, chr1, 762054, 763213, INDEPENDENT, null..
_2 | null...
only showing top 3 rows
...

dnase_with_label_df = dnase_with_label_df.
withColumn("label",
~fun.col("_2").isNull())
dnase_with_label_df.show(5)


现在我们在每个 DNase 峰上计算最终的特征集:

build final training DF

training_df = dnase_with_label_df.withColumn(
"contig", fun.col("_1").referenceName).withColumn(
"start", fun.col("_1").start).withColumn(
"end", fun.col("_1").end).withColumn(
"tf", fun.lit("CTCF")).withColumn(
"cell_line", fun.lit(current_cell_line)).drop("_1", "_2")

training_df = training_df.join(b_tss_df,
training_df.contig == b_tss_df.referenceName,
"inner") 1

training_df.withColumn("closest_tss",
fun.least(distance_to_closest_udf(fun.col("start_sites"),
fun.col("start")),
distance_to_closest_udf(fun.col("start_sites"),
fun.col("end")))) 2


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO6-1)

与先前创建的 `tss_df` 进行左连接。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_analyzing_genomics_data___span_class__keep_together__and_the_bdg_project__span__CO6-2)

获取最近的 TSS 距离。

在每次对细胞系循环的过程中计算出这个最终的 DF。最后,我们将来自每个细胞系的每个 DF 进行联合,并将这些数据缓存在内存中,以备用于训练模型:

preTrainingData = data_by_cellLine.union(...)
preTrainingData.cache()

preTrainingData.count()
preTrainingData.filter(fun.col("label") == true).count()


此时,`preTrainingData` 中的数据可以被归一化并转换为一个 DataFrame,以用于训练分类器,如 “随机森林” 中所述。请注意,应执行交叉验证,在每一折中,将一个细胞系的数据保留下来。

# 接下来的步骤

许多基因组学中的计算非常适合于 PySpark 的计算范式。当您进行即席分析时,像 ADAM 这样的项目最有价值的贡献是代表底层分析对象的 Avro 模式集(以及转换工具)。我们看到,一旦数据转换为相应的 Avro 模式,许多大规模计算变得相对容易表达和分布。

虽然在 PySpark 上执行科学研究的工具可能仍然相对不足,但确实存在一些项目可以帮助避免重复发明轮子。我们探索了 ADAM 中实现的核心功能,但该项目已经为整个 GATK 最佳实践流水线,包括插入缺失重排和去重,提供了实现。除了 ADAM 外,Broad Institute 现在也正在使用 Spark 开发重大软件项目,包括最新版本的 [GATK4](https://oreil.ly/hGR87) 和一个名为 [Hail](https://oreil.ly/V6Wpl) 的用于大规模种群遗传学计算的项目。所有这些工具都是开源的,因此,如果您开始在自己的工作中使用它们,请考虑贡献改进!


# 第十章:使用深度学习和 PySpark LSH 进行图像相似度检测

无论您是在社交媒体还是电子商务平台上遇到它们,图像都是我们数字生活中不可或缺的一部分。事实上,正是一个图像数据集——ImageNet——成为引发当前深度学习革命的关键组成部分。在 ImageNet 2012 挑战中,分类模型的显著表现是一个重要的里程碑,引起了广泛关注。因此,作为数据科学从业者,您很可能会在某个时候遇到图像数据。

在本章中,您将获得扩展深度学习工作流程的经验,特别是使用 PySpark 进行图像相似度检测的视觉任务。识别相似图像的任务对人类来说很直观,但是在计算上是一个复杂的任务。在大规模上,这变得更加困难。在本章中,我们将介绍一种用于寻找相似项的近似方法,称为局部敏感哈希(LSH),并将其应用于图像。我们将使用深度学习将图像数据转换为数值向量表示。然后,将 PySpark 的 LSH 算法应用于生成的向量,这将允许我们在给定新输入图像的情况下找到相似的图像。

从高层次来看,这个示例反映了类似于 Instagram 和 Pinterest 等照片分享应用程序用于图像相似度检测的方法之一。这有助于他们的用户理解其平台上存在的大量视觉数据。这也展示了深度学习工作流如何从 PySpark 的可伸缩性中受益。

我们将先简要介绍 PyTorch,它是一个深度学习框架。与其他主要低级深度学习库相比,PyTorch 因其相对较低的学习曲线而在近年来广受欢迎。然后,我们将下载并准备我们的数据集。用于我们任务的数据集是由斯坦福 AI 实验室在 2013 年发布的汽车数据集。PyTorch 将用于图像预处理。接下来,我们将把我们的输入图像数据转换为向量表示(图像嵌入)。然后,我们将这些嵌入导入 PySpark 并使用 LSH 算法进行转换。最后,我们将使用 LSH 转换后的数据集对新图像进行最近邻搜索,以找到相似的图像。

让我们开始介绍并设置 PyTorch。

# PyTorch

PyTorch 是一个用于构建深度学习项目的库。它强调灵活性,允许用 Python 的惯用方式表达深度学习模型。它在研究社区中找到了早期的采用者。近年来,由于其易用性,它已成为广泛应用于各种应用领域的最重要的深度学习工具之一。与 TensorFlow 一起,它是目前最流行的深度学习库之一。

PyTorch 的简单和灵活接口支持快速实验。你可以加载数据、应用变换和构建模型只需几行代码。然后,你可以灵活编写定制的训练、验证和测试循环,并轻松部署训练好的模型。它在专业环境中用于真实世界的关键工作中被广泛使用。GPU(图形处理单元)对于训练资源密集型模型的支持是使深度学习流行的重要因素。虽然我们的任务中不需要,但 PyTorch 提供了很好的 GPU 支持。

## 安装

在[PyTorch 网站](https://oreil.ly/CHkJo)上,你可以根据你的系统配置轻松获取安装说明,如图 10-1 所示。

![PyTorch 安装 CPU 支持](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1001.png)

###### 图 10-1\. PyTorch 安装,CPU 支持

执行提供的命令,并按照你的配置说明操作:

$ pip3 install torch torchvision


我们不会依赖 GPU,因此将选择 CPU 作为计算平台。如果你有一个 GPU 设置并希望使用它,请选择相应的选项获取所需的说明。本章中我们也不需要 Torchaudio,因此跳过它的安装。

# 数据准备

我们将使用[斯坦福汽车数据集](https://oreil.ly/gxo8Q)。该数据集是由 Jonathan Krause、Michael Stark、Jia Deng 和李飞飞在 ICCV 2013 年论文“用于细粒度分类的三维物体表示”中发布的。

你可以从 Kaggle 下载图片,或者使用斯坦福人工智能实验室提供的源链接。

wget http://ai.stanford.edu/~jkrause/car196/car_ims.tgz


下载完成后,解压训练和测试图像目录,并将它们放在一个名为*cars_data*的目录中:

data_directory = "cars_data"
train_images = "cars_data/cars_train/cars_train"


你可以在这里获取包含训练数据标签的 CSV 文件[here](https://oreil.ly/UoHXh)。下载它,重命名为*cars_train_data.csv*,并将其放在数据目录中。让我们看一下它:

import pandas as pd

train_df = pd.read_csv(data_directory+"/cars_train_data.csv")

train_df.head()
...

Unnamed: 0 	x1 	y1 	    x2 	    y2 	    Class 	image

0 0 39 116 569 375 14 00001.jpg
1 1 36 116 868 587 3 00002.jpg
2 2 85 109 601 381 91 00003.jpg
3 3 621 393 1484 1096 134 00004.jpg
4 4 14 36 133 99 106 00005.jpg


忽略除了`Class`和`image`之外的所有列。其他列与这个数据集来源的原始研究项目相关,不会用于我们的任务。

## 使用 PyTorch 调整图像大小

在我们进一步之前,我们需要预处理我们的图像。在机器学习中,预处理数据非常常见,因为深度学习模型(神经网络)期望输入满足特定的要求。

我们需要应用一系列预处理步骤,称为*transforms*,将输入图片转换为模型所需的正确格式。在我们的情况下,我们需要它们是 224 x 224 像素的 JPEG 格式图片,因为这是我们接下来将使用的 ResNet-18 模型的要求。我们使用 PyTorch 的 Torchvision 包在下面的代码中执行这个转换:

import os
from PIL import Image
from torchvision import transforms

needed input dimensions for the CNN

input_dim = (224,224)
input_dir_cnn = data_directory + "/images/input_images_cnn"

os.makedirs(input_dir_cnn, exist_ok = True)

transformation_for_cnn_input = transforms.Compose([transforms.Resize(input_dim)])

for image_name in os.listdir(train_images):
I = Image.open(os.path.join(train_images, image_name))
newI = transformation_for_cnn_input(I)

newI.save(os.path.join(input_dir_cnn, image_name))

newI.close()
I.close()

这里我们使用一个单一的转换,将图像调整大小以适应神经网络。但是,我们也可以使用`Compose`变换来定义一系列用于预处理图像的变换。

我们的数据集现在已准备就绪。在下一节中,我们将把我们的图像数据转换为适合与 PySpark 的 LSH 算法一起使用的向量表示形式。

# 用于图像向量表示的深度学习模型

卷积神经网络,即 CNN,是用于预测的标准神经网络架构,当输入观察数据是图像时使用。我们不会将它们用于任何预测任务,而是用于生成图像的向量表示。具体来说,我们将使用 ResNet-18 架构。

Residual Network(ResNet)是由 Shaoqing Ren、Kaiming He、Jian Sun 和 Xiangyu Zhang 在他们 2015 年的论文“Deep Residual Learning for Image Recognition”中引入的。ResNet-18 中的 18 代表神经网络架构中存在的层数。ResNet 的其他流行变体包括 34 层和 50 层。层数增加会提高性能,但也会增加计算成本。

## 图像嵌入

*图像嵌入*是图像在向量空间中的表示。基本思想是,如果给定图像接近另一张图像,它们的嵌入也将在空间维度上相似且接近。

图像中的图 10-2,由 Andrej Karpathy 发布,展示了如何在较低维度空间中表示图像。例如,您可以注意到顶部附近的车辆和左下角的鸟类空间。

![ILSVRC 2012 年图像嵌入在 2 维空间](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1002.png)

###### 图 10-2。ILSVRC 2012 年图像嵌入在 2 维空间中

我们可以通过取其倒数第二个全连接层的输出来从 ResNet-18 中获得图像嵌入,该层的维度为 512。接下来,我们创建一个类,提供一张图像,即可返回其数值向量形式的表示。

import torch
from torchvision import models

class Img2VecResnet18():
def init(self):
self.device = torch.device("cpu")
self.numberFeatures = 512
self.modelName = "resnet-18"
self.model, self.featureLayer = self.getFeatureLayer()
self.model = self.model.to(self.device)
self.model.eval()
self.toTensor = transforms.ToTensor() 1
self.normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]) 2

def getFeatureLayer(self):
    cnnModel = models.resnet18(pretrained=True)
    layer = cnnModel._modules.get('avgpool')
    self.layer_output_size = 512

    return cnnModel, layer

def getVec(self, img):
    image = self.normalize(self.toTensor(img)).unsqueeze(0).to(self.device)
    embedding = torch.zeros(1, self.numberFeatures, 1, 1)
    def copyData(m, i, o): embedding.copy_(o.data)
    h = self.featureLayer.register_forward_hook(copyData)
    self.model(image)
    h.remove()
    return embedding.numpy()[0, :, 0, 0]

![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_image_similarity_detection_with___span_class__keep_together__deep_learning__span__and_pyspark_lsh_CO1-1)

将图像转换为 PyTorch 张量格式。

![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_image_similarity_detection_with___span_class__keep_together__deep_learning__span__and_pyspark_lsh_CO1-2)

将像素值范围重新缩放到 0 到 1 之间。均值和标准差(std)的值是基于用于训练模型的数据预先计算的。归一化图像可以提高分类器的准确性。

现在我们初始化`Img2VecResnet18`类,并对所有图像应用`getVec`方法,以获得它们的图像嵌入。

import tqdm

img2vec = Img2VecResnet18()
allVectors = {}
for image in tqdm(os.listdir(input_dir_cnn)):
I = Image.open(os.path.join(input_dir_cnn, image))
vec = img2vec.getVec(I)
allVectors[image] = vec
I.close()


对于较大的数据集,您可能希望将向量输出顺序写入文件,而不是将其保留在内存中,以避免内存不足错误。这里的数据是可管理的,因此我们创建一个字典,并在下一步将其保存为 CSV 文件:

import pandas as pd

pd.DataFrame(allVectors).transpose().
to_csv(data_folder + '/input_data_vectors.csv')


由于我们是在本地工作,所以选择了 CSV 格式来保存向量输出。但是,Parquet 格式更适合这种类型的数据。您可以通过在先前的代码中用`to_parquet`替换`to_csv`来轻松保存 Parquet 格式的数据。

现在我们有了所需的图像嵌入,可以将它们导入 PySpark 中。

## 导入图像嵌入到 PySpark

启动 PySpark shell:

$ pyspark --driver-memory 4g


导入图像嵌入:

input_df = spark.read.option('inferSchema', True).
csv(data_directory + '/input_data_vectors.csv')
input_df.columns
...

['_c0',
'_c1',
'_c2',
'_c3',
'_c4',
[...]
'_c509',
'_c510',
'_c511',
'_c512']


PySpark 的 LSH 实现要求矢量列作为输入。我们可以通过使用 `VectorAssembler` 转换来将数据框中的相关列组合成一个列来创建这样一个列:

from pyspark.ml.feature import VectorAssembler

vector_columns = input_df.columns[1:]
assembler = VectorAssembler(inputCols=vector_columns, outputCol="features")

output = assembler.transform(input_df)
output = output.select('_c0', 'features')

output.show(1, vertical=True)
...

-RECORD 0------------------------
_c0 | 01994.jpg
features | [0.05640895,2.709...

...

output.printSchema()
...

root
|-- _c0: string (nullable = true)
|-- features: vector (nullable = true)


在接下来的部分,我们将使用 LSH 算法创建一种方法来从数据集中找到相似的图像。

# 使用 PySpark LSH 进行图像相似度搜索

局部敏感哈希是一种重要的哈希技术类,通常用于聚类、近似最近邻搜索和大数据集的异常值检测。局部敏感函数接受两个数据点,并决定它们是否应该成为候选对。

LSH 的一般思想是使用一组函数族(“LSH 家族”)将数据点哈希到桶中,以便数据点彼此靠近的概率很高地放置在同一个桶中,而数据点相距很远的概率很高地放置在不同的桶中。映射到相同桶中的数据点被视为候选对。

在 PySpark 中,不同的 LSH 家族被实现为单独的类(例如 `MinHash` 和 `BucketedRandomProjection`),并且每个类都提供了用于特征转换、近似相似性连接和近似最近邻的 API。

我们将使用 BucketedRandomProjection 实现的 LSH。

让我们首先创建我们的模型对象:

from pyspark.ml.feature import BucketedRandomProjectionLSH

brp = BucketedRandomProjectionLSH(inputCol="features", outputCol="hashes",
numHashTables=200, bucketLength=2.0)
model = brp.fit(output)


在 BucketedRandomProjection LSH 实现中,桶长度可以用来控制哈希桶的平均大小(从而控制桶的数量)。较大的桶长度(即较少的桶)增加了特征被哈希到同一桶的概率(增加了真正和错误的正例数量)。

现在我们使用新创建的 LSH 模型对象转换输入的 DataFrame。结果的 DataFrame 将包含一个 `hashes` 列,其中包含图像嵌入的哈希表示:

lsh_df = model.transform(output)
lsh_df.show(5)

...
+---------+--------------------+--------------------+
| _c0| features| hashes|
+---------+--------------------+--------------------+
|01994.jpg|[0.05640895,2.709...|[[0.0], [-2.0], [...|
|07758.jpg|[2.1690884,3.4647...|[[0.0], [-1.0], [...|
|05257.jpg|[0.7666548,3.7960...|[[-1.0], [-1.0], ...|
|07642.jpg|[0.86353475,2.993...|[[-1.0], [-1.0], ...|
|00850.jpg|[0.49161428,2.172...|[[-1.0], [-2.0], ...|
+---------+--------------------+--------------------+
only showing top 5 rows


有了我们的 LSH 转换后的数据集准备好了,我们将在下一部分进行测试。

## 最近邻搜索

让我们尝试使用新图像找到相似的图像。目前,我们将从输入数据集中选择一个(图 10-3):

from IPython.display import display
from PIL import Image

input_dir_cnn = data_folder + "/images/input_images_cnn"

test_image = os.listdir(input_dir_cnn)[0]
test_image = os.path.join(input_dir_cnn, test_image)
print(test_image)
display(Image.open(test_image))
...
cars_data/images/input_images_cnn/01994.jpg


![随机选择的汽车图像](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1003.png)

###### 图 10-3\. 我们数据集中随机选择的汽车图像

首先,我们需要使用我们的 `I⁠m⁠g⁠2⁠V⁠e⁠c​R⁠e⁠s⁠n⁠e⁠t⁠1⁠8` 类将输入图像转换为矢量格式:

img2vec = Img2VecResnet18()
I = Image.open(test_image)
test_vec = img2vec.getVec(I)
I.close()

print(len(test_vec))
print(test_vec)
...

512
[5.64089492e-02 2.70972490e+00 2.15519500e+00 1.43926993e-01
2.47581363e+00 1.36641121e+00 1.08204508e+00 7.62105465e-01
[...]
5.62133253e-01 4.33687061e-01 3.95899676e-02 1.47889364e+00
2.89110214e-01 6.61322474e-01 1.84713617e-01 9.42268595e-02]
...

test_vector = Vectors.dense(test_vec)


现在我们执行近似最近邻搜索:

print("Approximately searching lsh_df for 5 nearest neighbors
of input vector:")
result = model.approxNearestNeighbors(lsh_df, test_vector, 5)

result.show()
...
+---------+--------------------+--------------------+--------------------+
| _c0| features| hashes| distCol|
+---------+--------------------+--------------------+--------------------+
|01994.jpg|[0.05640895,2.709...|[[0.0], [-2.0], [...|3.691941786298668...|
|00046.jpg|[0.89430475,1.992...|[[0.0], [-2.0], [...| 10.16105522433224|
|04232.jpg|[0.71477133,2.582...|[[-1.0], [-2.0], ...| 10.255391011678762|
|05146.jpg|[0.36903867,3.410...|[[-1.0], [-2.0], ...| 10.264572173322843|
|00985.jpg|[0.530428,2.87453...|[[-1.0], [-2.0], ...| 10.474841359816633|
+---------+--------------------+--------------------+--------------------+


您可以查看图 10-4 到 10-8 的图像,看到模型已经相当正确:

for i in list(result.select('_c0').toPandas()['_c0']):
display(Image.open(os.path.join(input_dir_cnn, i)))


![结果图像 1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1003.png)

###### 图 10-4\. 结果图像 1

![结果图像 2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1005.png)

###### 图 10-5\. 结果图像 2

![结果图像 3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1006.png)

###### 图 10-6\. 结果图像 3

![结果图像 4](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1007.png)

###### 图 10-7\. 结果图像 4

![结果图像 5](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1008.png)

###### 图 10-8\. 结果图像 5

输入图像如预期一样位于列表顶部。

# 从这里开始

在本章中,我们学习了如何将 PySpark 与现代深度学习框架结合起来,以扩展图像相似性检测工作流程。

有多种方法可以改进这个实现。你可以尝试使用更好的模型或改进预处理以获得更好的嵌入质量。此外,LSH 模型可以进行调整。在实际设置中,您可能需要定期更新参考数据集,以适应系统中新加入的图像。最简单的方法是定期运行批处理作业以创建新的 LSH 模型。您可以根据需求和兴趣探索所有这些方法。


# 第十一章:使用 MLflow 管理机器学习生命周期

随着机器学习在各行各业中的重要性不断增加,并在生产环境中部署,围绕其的协作和复杂性也在增加。幸运的是,出现了一些平台和工具,可以帮助以结构化的方式管理机器学习生命周期。其中一个与 PySpark 配合良好的平台就是 MLflow。在本章中,我们将展示如何将 MLflow 与 PySpark 结合使用。在此过程中,我们将介绍您可以在数据科学工作流中采用的关键实践。

我们不打算从头开始,而是要建立在我们在 第四章 中所做的工作基础上。我们将使用 Covtype 数据集重新访问我们的决策树实现。这一次,我们将使用 MLflow 管理机器学习生命周期。

我们将首先解释涵盖机器学习生命周期的挑战和过程。然后,我们将介绍 MLflow 及其组件,以及 MLflow 对 PySpark 的支持。接下来,我们将介绍如何使用 MLflow 跟踪机器学习训练运行。然后,我们将学习如何使用 MLflow Models 管理机器学习模型。然后,我们将讨论部署我们的 PySpark 模型并对其进行实施。最后,我们将创建一个 MLflow 项目来展示如何使我们迄今为止的工作能够为合作者复现。让我们开始讨论机器学习生命周期吧。

# 机器学习生命周期

描述机器学习生命周期的方式有多种。一个简单的方法是将其分解为各种组件或步骤,如 图 11-1 所示。这些步骤不一定适用于每个项目的顺序,而生命周期通常是循环的。

+   业务项目定义与利益相关者的对齐

+   数据获取与探索

+   数据建模

+   结果的解释与沟通

+   模型实施与部署

![machine learning lifecycle](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/machine_learning_lifecycle.png)

###### 图 11-1。ML 生命周期

您能够迭代通过 ML 生命周期的速度影响您能够将工作投入实际使用的速度。例如,由于底层数据的变化,已实施的模型可能会过时。在这种情况下,您需要重新审视过去的工作并再次建立其基础。

在机器学习项目生命周期中可能出现的挑战示例包括:

缺乏可复现性

即使代码和参数已被跟踪,同一团队的数据科学家也可能无法复现彼此的结果。这可能是由于执行环境(系统配置或库依赖关系)的不同造成的。

模型标准化的缺乏

不同团队可能会使用不同的库和不同的存储机器学习模型的约定。在跨团队共享工作时,这可能会成为问题。

在进行 ML 生命周期时,试图组织您的工作可能会迅速变得令人不知所措。面对这样的挑战,有多个开源和专有平台可供选择。其中一个领先的开源平台是 MLflow,我们将在接下来的部分进行介绍。

# MLflow

MLflow 是一个用于管理端到端机器学习生命周期的开源平台。它帮助我们复现和共享实验,管理模型,并将模型部署给最终用户。除了 REST API 和 CLI,它还提供了 Python、R 和 Java/Scala 的 API。

它有四个主要组件,如图 11-2 所示:

MLflow 跟踪

此组件记录参数、度量、代码版本、模型和绘图文本等工件。

MLflow 项目

此组件为您提供一种可重用、可重现的格式,以便与其他数据科学家共享或转移到生产环境。它帮助您管理模型训练过程。

MLflow 模型

此组件使您能够将模型打包部署到各种模型服务和推断平台。它提供了一致的 API,用于加载和应用模型,无论所用于构建模型的基础库是什么。

MLflow 注册

此组件使您能够协作跟踪模型衍生、模型版本、阶段转换和中心存储中的注释。

![MLflow 组件](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1102.png)

###### 图 11-2\. MLflow 组件

让我们安装 MLflow。使用 pip 安装非常简单:

$ pip3 install mlflow


就这样!

MLflow 与许多流行的机器学习框架集成,如 Spark、TensorFlow、PyTorch 等。在接下来的几节中,我们将使用其对 Spark 的原生支持。导入与 Spark 特定的 MLflow 组件就像运行 `import mlflow.spark` 一样简单。

在下一节中,我们将介绍 MLflow 跟踪,并将其添加到我们从 第四章 的决策树代码中。

# 实验跟踪

典型的机器学习项目涉及尝试多种算法和模型来解决问题。需要跟踪相关的数据集、超参数和度量标准。通常使用临时工具(如电子表格)进行实验跟踪可能效率低下,甚至不可靠。

MLflow 跟踪是一个 API 和 UI,用于在运行机器学习代码时记录参数、代码版本、度量和工件,并在稍后可视化结果。您可以在任何环境中(例如独立脚本或笔记本)使用 MLflow 跟踪将结果记录到本地文件或服务器,然后比较多个运行。它与多个框架集成,不依赖于特定库。

MLflow 跟踪围绕“运行”的概念组织,这些运行是某段数据科学代码的执行。MLflow 跟踪提供了一个 UI,让您可以可视化、搜索和比较运行,以及下载运行的工件或元数据,以在其他工具中进行分析。它包含以下关键功能:

+   基于实验的运行列表和比较

+   根据参数或指标值搜索运行

+   可视化运行指标

+   下载运行结果

让我们在 PySpark shell 中为我们的决策树代码添加 MLflow 跟踪。假设您已经下载了[Covtype 数据集](https://oreil.ly/0xyky)并且对其熟悉。Covtype 数据集以压缩的 CSV 格式数据文件 *covtype.data.gz* 和配套的信息文件 *covtype.info* 的形式在线提供。

启动 `pyspark-shell`。如前所述,构建决策树可能会消耗大量资源。如果有足够的内存,请指定 `--driver-memory 8g` 或类似的参数。

我们首先准备数据和机器学习流水线:

from pyspark.ml import Pipeline
from pyspark.sql.functions import col
from pyspark.sql.types import DoubleType
from pyspark.ml.feature import VectorAssembler
from pyspark.ml.classification import DecisionTreeClassifier

data_without_header = spark.read.option("inferSchema", True).
option("header", False).
csv("data/covtype.data")

colnames = ["Elevation", "Aspect", "Slope",
"Horizontal_Distance_To_Hydrology",
"Vertical_Distance_To_Hydrology",
"Horizontal_Distance_To_Roadways",
"Hillshade_9am", "Hillshade_Noon",
"Hillshade_3pm", "Horizontal_Distance_To_Fire_Points"] +
[f"Wilderness_Area_{i}" for i in range(4)] +
[f"Soil_Type_{i}" for i in range(40)] +
["Cover_Type"]

data = data_without_header.toDF(*colnames).
withColumn("Cover_Type",
col("Cover_Type").
cast(DoubleType()))

(train_data, test_data) = data.randomSplit([0.9, 0.1])

input_cols = colnames[:-1]
vector_assembler = VectorAssembler(inputCols=input_cols,outputCol="featureVector")

classifier = DecisionTreeClassifier(seed = 1234,
labelCol="Cover_Type",
featuresCol="featureVector",
predictionCol="prediction")

pipeline = Pipeline(stages=[vector_assembler, classifier])


要开始使用 MLflow 进行日志记录,我们使用 `mlflow.start_run` 开始一个运行。我们将使用 `with` 子句来在块结束时自动结束运行:

import mlflow
import mlflow.spark
import pandas as pd
from pyspark.ml.evaluation import MulticlassClassificationEvaluator

with mlflow.start_run(run_name="decision-tree"):
# Log param: max_depth
mlflow.log_param("max_depth", classifier.getMaxDepth())
# Log model
pipeline_model = pipeline.fit(train_data)
mlflow.spark.log_model(pipeline_model, "model")
# Log metrics: Accuracy and F1
pred_df = pipeline_model.transform(test_data)
evaluator = MulticlassClassificationEvaluator(labelCol="Cover_Type",
predictionCol="prediction")
accuracy = evaluator.setMetricName("accuracy").evaluate(pred_df)
f1 = evaluator.setMetricName("f1").evaluate(pred_df)
mlflow.log_metrics({"accuracy": accuracy, "f1": f1})
# Log artifact: feature importance scores
tree_model = pipeline_model.stages[-1]
feature_importance_df = (pd.DataFrame(list(
zip(vector_assembler.getInputCols(),
tree_model.featureImportances)),
columns=["feature", "importance"])
.sort_values(by="importance", ascending=False))
feature_importance_df.to_csv("feature-importance.csv", index=False)
mlflow.log_artifact("feature-importance.csv")


现在我们可以通过追踪界面访问我们的实验数据。通过运行 `mlflow ui` 命令来启动它。默认情况下,它会在 5000 端口启动。您可以使用 `-p <port_name>` 选项来更改默认端口。一旦成功启动界面,请访问 [*http://localhost:5000/*](http://localhost:5000/)。您将看到一个如图 11-3 所示的界面。您可以搜索所有运行,按特定标准过滤运行,进行并列比较等。如果需要,您还可以将内容导出为 CSV 文件以进行本地分析。在 UI 中点击名为 `decision-tree` 的运行。

![MLflow UI 1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1103.png)

###### 图 11-3\. MLflow UI 1

当查看单个运行时,如图 11-4,您会注意到 MLflow 存储了所有对应的参数、指标等。您可以在自由文本中添加关于此运行的注释,以及标签。

![MLflow UI 2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1104.png)

###### 图 11-4\. MLflow UI 2

现在我们能够跟踪和重现我们的实验。现在让我们讨论如何使用 MLflow 管理我们的模型。

# 管理和提供 ML 模型

MLflow 模型是打包机器学习模型的标准格式,可以在各种下游工具中使用,例如通过 REST API 进行实时服务或在 Apache Spark 上进行批量推断。该格式定义了一种约定,使您可以保存具有不同“风味”的模型,这些风味可以被不同的库理解。

Flavors 是使 MLflow 模型强大的关键概念。它们使得可以编写能够与任何 ML 库中的模型一起工作的工具,而无需将每个工具与每个库集成。MLflow 定义了几种“标准” flavors,其内置的部署工具都支持,例如描述如何将模型作为 Python 函数运行的“Python function” flavor。但是,库也可以定义和使用其他 flavors。例如,MLflow 的`mlflow.sklearn`库允许将模型加载回来作为 scikit-learn 的`Pipeline`对象,以供了解 scikit-learn 的代码使用,或者作为通用 Python 函数,以供只需应用模型的工具使用(例如用于将模型部署到 Amazon SageMaker 的`mlflow.sagemaker`工具)。

MLflow 模型是一个包含一组文件的目录。我们之前使用`log_model` API 记录了我们的模型。这创建了一个名为*MLmodel*的文件。打开决策树运行并向下滚动到“Artifacts”部分。查看*MLmodel*文件。其内容应类似于 图 11-5 中所示。

![MLflow 模型](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1105.png)

###### 图 11-5\. MLflow 模型

此文件捕获了我们模型的元数据、签名和 flavors。模型签名定义了模型输入和输出的模式。

我们的模型文件有两种 flavors:python_function 和 spark。python_function flavor 使得 MLflow 的模型部署和服务工具能够与任何 Python 模型一起工作,无论该模型使用了哪个 ML 库进行训练。因此,任何 Python 模型都可以轻松地在各种运行时环境中投入生产。

Spark 模型 flavor 使得可以将 Spark MLlib 模型导出为 MLflow 模型。例如,可以使用记录的模型对 Spark DataFrame 进行预测:

import mlflow

run_id = "0433bb047f514e28a73109bbab767222" 1
logged_model = f'runs:/{run_id}/model' 2

Load model as a Spark UDF.

loaded_model = mlflow.spark.load_model(model_uri=logged_model)

Predict on a Spark DataFrame.

preds = loaded_model.transform(test_data)
preds.select('Cover_Type', 'rawPrediction', 'probability', 'prediction').
show(1, vertical=True)
...
-RECORD 0-----------------------------
Cover_Type | 6.0
rawPrediction | 0.0,0.0,605.0,15...
probability | 0.0,0.0,0.024462...
prediction | 3.0
only showing top 1 row


[![1 可以从相关的*MLmodel*文件在追踪 UI 中获取此 ID。![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_managing_the_machine_learning___span_class__keep_together__lifecycle_with_mlflow__span__CO1-2)

我们使用 Python f-strings 来添加相关的运行 ID。

我们还可以使用`mlflow serve`命令行工具来为特定运行 ID 对应的模型提供服务。

$ mlflow models serve --model-uri runs:/0433bb047f514e28a73109bbab767222/model
-p 7000

...

2021/11/13 12:13:49 INFO mlflow.models.cli: Selected backend for...
2021/11/13 12:13:52 INFO mlflow.utils.conda: === Creating conda ...
Collecting package metadata (repodata.json): done
Solving environment: done ...


您已成功将您的模型部署为 REST API!

我们现在可以使用此端点执行推理。让我们准备并发送一个请求到端点,看看它的工作原理。我们将使用`requests`库来完成这个操作。如果你还没有安装它,请先使用 pip 安装:

pip3 install requests


现在,我们将向模型服务器发送包含 JSON 对象的请求,其方向为 pandas-split。

import requests

host = '0.0.0.0'
port = '7001'

url = f'http://{host}:{port}/invocations'

headers = {
'Content-Type': 'application/json;',
'format': 'pandas-split';
}

http_data = '{"columns":["Elevation","Aspect","Slope",
"Horizontal_Distance_To_Hydrology",
"Vertical_Distance_To_Hydrology","Horizontal_Distance_To_Roadways",
"Hillshade_9am","Hillshade_Noon","Hillshade_3pm",
"Horizontal_Distance_To_Fire_Points",
"Wilderness_Area_0","Wilderness_Area_1","Wilderness_Area_2",
"Wilderness_Area_3","Soil_Type_0","Soil_Type_1","Soil_Type_2",
"Soil_Type_3","Soil_Type_4","Soil_Type_5","Soil_Type_6",
"Soil_Type_7","Soil_Type_8","Soil_Type_9","Soil_Type_10",
"Soil_Type_11","Soil_Type_12","Soil_Type_13",
"Soil_Type_14","Soil_Type_15","Soil_Type_16",
"Soil_Type_17","Soil_Type_18","Soil_Type_19",
"Soil_Type_20","Soil_Type_21","Soil_Type_22",
"Soil_Type_23","Soil_Type_24","Soil_Type_25",
"Soil_Type_26","Soil_Type_27","Soil_Type_28",
"Soil_Type_29","Soil_Type_30","Soil_Type_31",
"Soil_Type_32","Soil_Type_33","Soil_Type_34",
"Soil_Type_35","Soil_Type_36","Soil_Type_37",
"Soil_Type_38","Soil_Type_39","Cover_Type"],
"index":[0],
"data":[[2596,51,3,258,0,510,221,232,148,6279,1,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,5.0]]'\

r = requests.post(url=url, headers=headers, data=http_data)

print(f'Predictions: {r.text}')
...
Predictions: [2.0]


我们不仅加载了保存的模型,还将其部署为 REST API 并实时执行推理!

现在让我们学习如何为我们迄今所做的工作创建一个 MLflow 项目。

# 创建和使用 MLflow 项目

MLflow 项目是可重用和可复制的打包的标准格式。它是一个自包含的单元,捆绑了执行机器学习工作流所需的所有机器代码和依赖项,并使您能够在任何系统或环境上运行特定的模型运行。MLflow 项目包括用于运行项目的 API 和命令行工具。它还可以用于将项目链接在一起形成工作流。

每个项目实际上是一个文件目录,或者是一个包含您代码的 Git 存储库。MLflow 可以根据在此目录中放置文件的约定运行某些项目(例如,*conda.yml* 文件被视为 Conda 环境),但是您可以通过添加 MLproject 文件(一个 YAML 格式的文本文件)更详细地描述您的项目。

MLflow 目前支持以下项目环境:Conda 环境、Docker 容器环境和系统环境。默认情况下,MLflow 使用系统路径来查找并运行 Conda 二进制文件。

创建一个基本的 MLflow 项目非常简单。所需步骤在 图 11-6 中列出。

![如何构建 MLflow 项目](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/aaps_1106.png)

###### 图 11-6\. 如何构建 MLflow 项目

我们将从创建名为 *decision_tree_project* 的项目目录开始:

mkdir decision_tree_project
cd decision_tree_project


接下来,我们首先会创建一个 MLproject 文件:

name: decision_tree_project

conda_env: conda.yml

entry_points:
main:
command: "python train.py"


现在我们需要我们的 *conda.yml* 文件。我们可以从之前介绍的 MLflow UI 中获取这个文件。进入我们之前看到的决策树运行。滚动到工件部分,点击 conda YAML 文件,并将其内容复制到我们项目目录中的 *conda.yml* 中:

channels:

  • conda-forge
    dependencies:
  • python=3.6.12
  • pip
  • pip:
    • mlflow
    • pyspark==3.2.1
    • scipy==1.5.3
      name: mlflow-env

现在,我们将创建用于在执行 MLflow 项目时训练决策树模型的 Python 脚本。为此,我们将使用之前章节中的代码:

from pyspark.sql import SparkSession
from pyspark.ml import Pipeline
from pyspark.sql.functions import col
from pyspark.sql.types import DoubleType
from pyspark.ml.feature import VectorAssembler
from pyspark.ml.classification import DecisionTreeClassifier
from pyspark.ml.evaluation import MulticlassClassificationEvaluator

spark = SparkSession.builder.appName("App").getOrCreate()

def main():
data_without_header = spark.read.option("inferSchema", True).
option("header", False).
csv("../data/covtype.data") 1

colnames = ["Elevation", "Aspect", "Slope",
            "Horizontal_Distance_To_Hydrology",
            "Vertical_Distance_To_Hydrology",
            "Horizontal_Distance_To_Roadways",
            "Hillshade_9am", "Hillshade_Noon",
            "Hillshade_3pm",
            "Horizontal_Distance_To_Fire_Points"] + \
[f"Wilderness_Area_{i}" for i in range(4)] + \
[f"Soil_Type_{i}" for i in range(40)] + \
["Cover_Type"]

data = data_without_header.toDF(*colnames).\
                            withColumn("Cover_Type",
                                        col("Cover_Type").\
                                        cast(DoubleType()))

(train_data, test_data) = data.randomSplit([0.9, 0.1])

input_cols = colnames[:-1]
vector_assembler = VectorAssembler(inputCols=input_cols,
                            outputCol="featureVector")

classifier = DecisionTreeClassifier(seed = 1234,
                                    labelCol="Cover_Type",
                                    featuresCol="featureVector",
                                    predictionCol="prediction")

pipeline = Pipeline(stages=[vector_assembler, classifier])

pipeline_model = pipeline.fit(train_data)
# Log metrics: Accuracy and F1
pred_df = pipeline_model.transform(test_data)
evaluator = MulticlassClassificationEvaluator(labelCol="Cover_Type",
                                            predictionCol="prediction")
accuracy = evaluator.setMetricName("accuracy").evaluate(pred_df)
f1 = evaluator.setMetricName("f1").evaluate(pred_df)
print({"accuracy": accuracy, "f1": f1})

if name == "main":
main()


![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspk/img/#co_managing_the_machine_learning___span_class__keep_together__lifecycle_with_mlflow__span__CO2-1)

假设数据位于执行的 MLflow 项目目录的上一级目录。

由于数据量大,数据也可以包含在 MLflow 项目中。在这种情况下,我们不这样做。在这种情况下,数据可以使用 AWS S3 或 GCS 等云存储进行共享。

您可以在分享之前模拟它如何在本地与协作者协作。我们使用 `mlflow run` 命令来执行此操作。

mlflow run decision_tree_project
...
[...]


现在我们有了一个可复制的 MLflow 项目。我们可以将其上传到 GitHub 存储库,并与协作者分享,他们将能够复制我们的工作。

# 如何继续

本章介绍了 MLflow 项目,并指导您在简单项目中的实施。在 MLflow 项目本身中有很多可以探索的内容。您可以在[官方文档](https://mlflow.org)中找到更多信息。还有其他可以作为替代方案的工具。这些包括开源项目,如 Metaflow 和 Kubeflow,以及亚马逊 SageMaker 和 Databricks 平台等大型云服务提供商的专有产品。

当然,工具只是解决现实世界机器学习项目挑战的一部分。任何项目的工作人员需要定义流程。我们希望你能在本章提供的基础上构建,并为野外成功的机器学习项目做出贡献。
posted @ 2025-11-17 09:51  绝不原创的飞龙  阅读(22)  评论(0)    收藏  举报