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 页的内容,都会尽可能接近演示如何构建这些数据应用的一个部分。

本书使用约定

本书使用以下排版约定:

斜体

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

常数宽度

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

常数宽度粗体

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

常数宽度斜体

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

此元素表示提示或建议。

此元素表示一般说明。

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

使用代码示例

可以下载补充材料(代码示例、练习等)位于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.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 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 年前,以下任务简直无法完成:

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

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

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

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

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

这些能力的背后是一个开源软件生态系统,可以利用服务器集群处理海量数据。Apache Hadoop 在 2006 年的推出/发布导致了分布式计算的广泛采用。自那时以来,大数据生态系统和工具已经迅速发展。过去五年还见证了许多开源机器学习(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 和分布式/网络系统知识的情况下很难编程。

为 HPC 环境编写的工具通常无法将内存数据模型与较低级别的存储模型解耦。例如,许多工具只知道如何从 POSIX 文件系统中以单个流的形式读取数据,这使得难以使工具自然并行化或使用其他存储后端,如数据库。现代分布式计算框架提供了抽象,允许用户将一组计算机更像单个计算机一样对待——自动拆分文件并在许多计算机上分发存储,将工作划分为较小的任务并以分布式方式执行它们,并从故障中恢复。它们可以自动化处理大型数据集的许多麻烦,并且比 HPC 便宜得多。

分布式系统的一个简单思路是它们是一组独立的计算机,对最终用户而言表现为单个计算机。它们允许水平扩展。这意味着增加更多计算机而不是升级单个系统(垂直扩展)。后者相对昂贵,并且通常不足以处理大量工作负载。分布式系统非常适合扩展和可靠性,但在设计、构建和调试时也引入了复杂性。在选择此类工具之前,人们应理解这种权衡。

介绍 Apache Spark 和 PySpark

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

组件

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

Spark SQL 和 DataFrame +数据集

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

MLlib

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

结构化流处理

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

GraphX(已弃用)

GraphX 是 Apache Spark 的图形和图形并行计算库。但是,对于图形分析,推荐使用 GraphFrames 而不是 GraphX,后者并未得到很好的积极开发,并且缺乏 Python 绑定。GraphFrames是一个开源的通用图处理库,类似于 Apache Spark 的 GraphX,但使用基于 DataFrame 的 API。

aaps 0101

图 1-1. Apache Spark 组件

PySpark

PySpark 是 Spark 的 Python API。简单来说,PySpark 是基于 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、RDBMS 等 — 并在内存中处理所有这些数据。Spark 的 DataFrameReader 和 DataFrameWriter API 也可以扩展以从其他来源读取数据,如 Apache Kafka、Amazon Kinesis、Azure 存储和 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 在运行时调整物理执行计划,并跳过不在查询结果中所需的数据。这些优化解决了用户以前必须进行手动调整和优化的重要工作量。Spark 3.0 在 TPC-DS 上几乎比 Spark 2.4 快两倍,这是一个行业标准的分析处理基准测试。由于大多数 Spark 应用程序都由 SQL 引擎支持,所有高级别库,包括 MLlib 和结构化流处理,以及高级 API,包括 SQL 和 DataFrames,都受益于此。符合 ANSI SQL 标准使 SQL API 更易于使用。

Python 在数据科学生态系统中的采用率已经成为领导者。因此,Python 现在是 Spark 上最广泛使用的语言。PySpark 在 Python 包索引(PyPI)上每月下载量超过五百万次。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 避免了与 IDE 的往返。分析师能够快速实验他们的数据,越快他们就有可能做的事情。

读取-评估-打印循环(REPL)是一个计算机环境,在这里用户输入被读取和评估,然后结果被返回给用户。

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

从这里开始

Spark 填 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 中处理 DataFrame 时习惯的一些事情并不适用于 Spark,因此最好将它们视为独特的实体,并尝试以开放的心态来接近它们。

至于数据清洗,这是任何数据科学项目中的第一步,通常也是最重要的一步。许多精彩的分析因为所分析的数据存在根本的质量问题或基础性的人为瑕疵,导致分析师产生偏见或看到实际并不存在的东西而功亏一篑。因此,没有比通过一个数据清洗的练习更好的方式来介绍使用 PySpark 和 DataFrame 处理数据。

首先,我们将介绍 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 仓库 安装 PySpark。

$ pip3 install pyspark

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

$ pip3 install pyspark[sql,ml,mllib]

从 PyPI 安装跳过了运行 Scala、Java 或 R 所需的库。您可以从 Spark 项目站点 获取完整的发布版。请参阅 Spark 文档,了解如何在集群或本地环境中设置 Spark 环境的说明。

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

$ 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-memory 的相关选项在 Spark on YARN 文档 中有详细解释。

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

运行这些命令之后,你将看到 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 中输入变量名后跟一个点和制表符来查看这些方法:

 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 提供的所有方法中,我们将最常用的那些方法用于创建 DataFrame。现在我们已经设置好了 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

要为我们的记录链接数据集创建一个数据框架,我们将使用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 中查看数据框架的头部:

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实例包含列的名称、可以处理每条记录中包含的数据类型的最具体数据类型以及一个布尔字段,指示列是否可能包含 null 值,默认情况下为 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 的所有内容作为数组返回给客户端。对于非常大的 DataFrame,使用这些方法可能会导致内存不足异常。因为我们目前还不知道链接数据集有多大,所以我们暂时不做这个操作。

在接下来的几节中,我们将使用本地开发和测试以及集群计算来执行更多的数据清洗和记录链接数据的分析,但是如果您需要花点时间来享受您刚刚进入的这个令人惊叹的新世界,我们当然理解。

使用 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,表示在记录中匹配的病人。

  • 接下来的九个字段是(可能缺失的)数值(可以是双精度或整数),表示病人记录不同字段(如姓名、生日和位置)上的匹配分数。当只可能的值是匹配(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")中,或者作为Column对象,通过在count列上使用的col函数。在大多数情况下,任何一种方法都是有效的,但我们需要使用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 具有广泛的熟悉性和表达能力,适用于简单查询。它还允许您使用像 PostgreSQL 这样的数据库或像 Tableau 这样的工具通过 JDBC/ODBC 连接器查询数据。SQL 的缺点在于,它可能难以以动态、可读和可测试的方式表达复杂的多阶段分析——而在这些方面,DataFrame API 表现出色。在本书的其余部分,我们将同时使用 Spark SQL 和 DataFrame API,并留给读者来审视我们所做选择,并将我们的计算从一种接口转换到另一种接口。

我们可以逐个在 DataFrame 上应用函数来获得统计数据,例如计数和均值。然而,PySpark 提供了一种更好的方法来获取数据框的汇总统计信息,这正是我们将在下一节中介绍的内容。

快速数据框汇总统计信息

虽然有许多分析可以同样适用 SQL 或 DataFrame API 来表达,但是有一些常见的数据框操作,在 SQL 中表达起来可能会显得繁琐。其中一种特别有帮助的分析是计算数据框中所有数值列非空值的最小值、最大值、均值和标准差。在 PySpark 中,这个函数与 pandas 中的名称相同,即describe

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

summary数据框中每个变量都有一列与parsed数据框中的其他列(也称为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对象来过滤数据框,然后对结果数据框使用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 DataFrames,以查看变量的分布如何随记录是匹配还是未匹配而变化。尽管这是一个相对较小的数据集,但进行这种比较仍然有些繁琐——我们真正想要的是对match_summarymiss_summary DataFrames 进行转置,使行和列互换,这样我们可以通过变量联合转置后的 DataFrames 并分析汇总统计数据,这是大多数数据科学家所知的“数据透视”或“重塑”数据集的做法。在下一节中,我们将展示如何执行这些转换。

数据透视和重塑 DataFrames

我们可以使用 PySpark 提供的函数完全转置 DataFrames。然而,还有另一种执行此任务的方法。PySpark 允许在 Spark 和 pandas DataFrames 之间进行转换。我们将会把相关的 DataFrames 转换为 pandas DataFrames,重新整理它们,然后再转换回 Spark DataFrames。由于summarymatch_summarymiss_summary DataFrames 的规模较小,我们可以安全地这样做,因为 pandas DataFrames 存储在内存中。在接下来的章节中,对于更大的数据集,我们将依赖于 Spark 操作来进行这些转换。

由于 Apache Arrow 项目的支持,我们可以进行 pandas DataFrames 与 Spark DataFrames 之间的转换,它允许在 JVM 和 Python 进程之间进行高效的数据传输。当我们使用 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)

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

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方法获取的 summary 架构中,每个字段都被视为字符串。由于我们希望将汇总统计数据分析为数字,我们需要将值从字符串转换为双精度数:

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_summarym⁠i⁠s⁠s⁠_​s⁠u⁠m⁠m⁠a⁠r⁠y DataFrames 上重复使用:

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)

现在我们成功地转置了摘要 DataFrame,接下来我们将它们联接和比较。这将在下一节中进行。此外,我们还将选择用于构建模型的理想特征。

连接 DataFrames 和选择特征

到目前为止,我们仅使用 Spark SQL 和 DataFrame API 来过滤和聚合数据集中的记录,但我们也可以使用这些工具在 DataFrame 上执行连接操作(内连接、左连接、右连接或全连接)。虽然 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 到 1 的得分来说,差异只有 0.09。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 列的数据框架来大致了解我们简单模型的性能,并评估该分数在各种阈值下如何区分匹配和非匹配。

打分与模型评估

对于我们的评分函数,我们将汇总五个字段的值(cmp_lname_c1cmp_plzcmp_bycmp_bdcmp_bm)。我们将使用来自 pyspark.sql.functionsexpr 来执行这些操作。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|
...

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

为了帮助我们选择阈值,创建一个列联表(有时称为交叉制表交叉表),计算得分高于/低于阈值的记录数,交叉与每个类别中的记录数是否匹配。由于我们还不知道要使用哪个阈值,让我们编写一个函数,它以 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 方法,通过 Python 的 f-string 格式化语法动态确定基于 t 参数的字段 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 中的其他值(包括缺失的和不缺失的)来设计一个评分函数,成功识别每一个真正的匹配项,并且假阳性少于 100 个。

下一步该怎么办

如果这一章是您第一次使用 PySpark 进行数据准备和分析,希望您能感受到这些工具提供的强大基础。如果您已经使用 Python 和 Spark 一段时间了,希望您将本章介绍给您的朋友和同事,让他们也体验一下这种强大的力量。

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

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

推荐引擎是大规模机器学习的最受欢迎的示例之一;例如,大多数人都熟悉亚马逊的推荐引擎。它是一个共同的基础,因为推荐引擎无处不在,从社交网络到视频网站再到在线零售商。我们也可以直接观察它们的运作。我们知道计算机正在挑选在 Spotify 上播放的曲目,这与我们不一定注意到 Gmail 是否决定入站邮件是否为垃圾邮件的方式类似。

推荐引擎的输出比其他机器学习算法更直观易懂。这甚至是令人兴奋的。尽管我们认为音乐口味是个人的且难以解释,推荐系统却出奇地能够很好地识别我们未曾预料到会喜欢的曲目。对于音乐或电影等领域,推荐系统经常被部署,我们可以比较容易地推断为什么推荐的音乐与某人的听歌历史相符。并非所有的聚类或分类算法都符合这一描述。例如,支持向量机分类器是一组系数,即使是从业者也很难表达这些数字在进行预测时的含义。

以推荐引擎为中心的下三章探讨 PySpark 上的关键机器学习算法似乎很合适,特别是推荐音乐。这是一个引入 PySpark 和 MLlib 的实际应用的可访问方式,并介绍一些基本的机器学习思想,这些思想将在随后的章节中进一步发展。

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

数据设置

我们将使用由 Audioscrobbler 发布的数据集。Audioscrobbler 是Last.fm的第一个音乐推荐系统,是 2002 年成立的最早的互联网流媒体电台站点之一。Audioscrobbler 提供了一个用于“scrobbling”(记录听众歌曲播放)的开放 API。Last.fm 利用这些信息构建了一个强大的音乐推荐引擎。该系统通过第三方应用程序和网站能够向推荐引擎提供听歌数据,达到了数百万用户。

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

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

2005 年 Last.fm 分发的数据集快照可以在 在线压缩档案 中找到。下载这个档案,并在其中找到几个文件。首先,需要使数据集的文件可用。如果你使用的是远程集群,请将所有三个数据文件复制到存储中。本章将假定这些文件在 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 不够大(严格来说,太低),无法完美地表示 A。这实际上是件好事。A 只是所有可能发生的互动中的一个小样本。某种程度上,我们认为 A 是一个非常零散、因此难以解释的更简单的潜在现实的观点,它只能通过其中的一些少量因素,k 个因素,进行很好的解释。想象一幅描绘猫的拼图。最终拼图很容易描述:一只猫。然而,当你手上只有几片时,你看到的图像却很难描述。

XY^(T) 应该尽可能接近 A。毕竟,这是我们所依赖的唯一依据。它不会也不应该完全复制它。坏消息是,这不能直接为同时获得最佳的 XY 而解决。好消息是,如果已知 Y,那么解决最佳 X 就很简单,反之亦然。但事先都不知道!

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

交替最小二乘法算法

我们将使用交替最小二乘法算法从我们的数据集中计算潜在因子。 这种方法在 Netflix Prize 竞赛期间由《“Collaborative Filtering for Implicit Feedback Datasets”》和《“Large-Scale Parallel Collaborative Filtering for the 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是未知的,但可以初始化为一个由随机选择的行向量组成的矩阵。 然后,简单的线性代数给出了最佳的X解,给定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 和计数,都是int类型。将其转换为列名为“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|
+--------+--------------------+

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

raw_artist_alias将可能拼写错误或非标准的艺术家 ID 映射到艺术家规范名称的 ID。这个数据集相对较小,包含约 200,000 条目。每行包含两个 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数据框中查找这些信息:

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://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
train_data = train_data.withColumn('artist', col('artist').\
                                             cast(IntegerType())).\
                                             drop('alias')

train_data.cache()

train_data.count()
...
24296858

1

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

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

调用cache告诉 Spark,在计算后应该暂时存储这个 DataFrame,并且在集群内存中保持。这很有帮助,因为 ALS 算法是迭代的,通常需要多次访问这些数据。如果没有这一步,每次访问时 DataFrame 都可能会从原始数据中重新计算!Spark UI 中的存储选项卡将显示 DataFrame 的缓存量和内存使用量,如图 Figure 3-2 所示。这个 DataFrame 在整个集群中大约消耗了 120 MB。

aaps 0302

图 3-2. Spark UI 中的存储选项卡,显示缓存的 DataFrame 内存使用情况

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

注意,在 UI 中,“反序列化”标签在图 Figure 3-2 中实际上只与 RDD 相关,其中“序列化”表示数据存储在内存中,不是作为对象,而是作为序列化的字节。然而,像这种 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)

这将使用一些默认配置构建一个ALSModel作为model。根据您的集群不同,此操作可能需要几分钟甚至更长时间。与某些机器学习模型最终形式可能仅包含几个参数或系数不同,这种模型非常庞大。它包含模型中每个用户和产品的 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 来过滤艺术家集,并按顺序打印名称:

user_id = 2093760

existing_artist_ids = train_data.filter(train_data.user == user_id) \ ![1  .select("artist").collect() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/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://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/3.png)
...
+-------+---------------+
|     id|           name|
+-------+---------------+
|   1180|     David Gray|
|    378|  Blackalicious|
|    813|     Jurassic 5|
|1255340|The Saw Doctors|
|    942|         Xzibit|
+-------+---------------+

1

查找其用户为 2093760 的行。

2

收集艺术家 ID 数据集。

3

过滤这些艺术家。

这些艺术家看起来像是主流流行音乐和嘻哈的混合。是不是恐龙 5 的粉丝?记住,那是 2005 年。如果你好奇的话,锯医生是一个非常受爱尔兰欢迎的爱尔兰摇滚乐队。

现在,简单地为用户做出推荐虽然用这种方式计算需要一些时间。它适用于批量评分,但不适合实时使用情况:

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 曲线)”有关。上述段落中的度量标准等于这个 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 中的CrossValidatorAPI 支持这种技术。验证 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://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
    model.itemFactors.unpersist()

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

evaluations.sort(key=lambda x:x[0], reverse=True) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/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 更高,这些值在过小方面是相似的。

当然,这个过程可以针对不同的数值范围或更多的值重复进行。这是一种选择超参数的蛮力方法。然而,在拥有数 TB 内存和数百核心的集群不罕见的世界中,并且像 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://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/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 分钟,这相当于播放“Chop Suey!”和“B.Y.O.B.”等多达 33 年的时间!因为该乐队 1998 年开始制作唱片,这意味着要同时播放四到五首歌曲七年时间。这必须是垃圾信息或数据错误,是生产系统必须解决的真实世界数据问题的又一个例子。

ALS 并不是唯一可能的推荐算法,但目前它是 Spark MLlib 支持的唯一算法。然而,MLlib 也支持一种适用于非隐式数据的 ALS 变体。其使用方式相同,只是将ALS配置为setImplicitPrefs(false)。例如,当数据类似于评分而不是计数时,这种配置是合适的。从ALSModel.transform推荐方法返回的prediction列实际上是一个估计的评分。在这种情况下,简单的 RMSE(均方根误差)指标适合用于评估推荐系统。

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

在生产环境中,推荐引擎通常需要实时进行推荐,因为它们被用于像电子商务网站这样的场景,客户在浏览产品页面时经常请求推荐。预先计算和存储推荐结果是一种在规模上提供推荐的合理方式。这种方法的一个缺点是它需要为可能很快需要推荐的所有用户预先计算推荐结果,而这些用户可能是任何用户中的一部分。例如,如果 100 万用户中只有 10,000 个用户在一天内访问网站,那么每天预先计算所有 100 万用户的推荐结果将浪费 99%的努力。

我们最好根据需要即时计算推荐。虽然我们可以使用ALSModel为一个用户计算推荐,但这必然是一个分布式操作,需要几秒钟的时间,因为ALSModel实际上是一个非常庞大的分布式数据集。而其他模型则提供了更快的评分。

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

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

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

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

虽然 PySpark 的决策树实现很容易上手,但理解决策树和随机森林算法的基础是非常有帮助的。这是我们下一节要讨论的内容。

决策树和森林

决策树 是一类算法,可以自然地处理分类和数值特征。可以使用并行计算构建单棵树,并且可以同时并行构建多棵树。它们对数据中的异常值具有鲁棒性,这意味着少数极端和可能错误的数据点可能根本不会影响预测。它们可以处理不同类型和不同尺度的数据,而无需预处理或归一化。

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

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

这是一个简单的决策树,没有经过严格构建。为了详细说明,考虑另一个例子。一个机器人在一家异国情调的宠物店找了份工作。它希望在店铺开门前了解哪些动物适合孩子作为宠物。店主匆匆列出了九只适合和不适合的宠物,然后匆忙离去。机器人根据观察到的信息从表 4-1 中整理了这些动物的特征向量。

aaps 0401

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

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

名称 重量(公斤) # 腿 颜色 适合作宠物?
菲多 20.5 4 棕色
斯莱瑟先生 3.1 0 绿色
尼莫 0.2 0 棕色
邓波 1390.8 4 灰色
基蒂 12.1 4 灰色
吉姆 150.9 2 棕色
米莉 0.1 100 棕色
麦克鸽 1.0 2 灰色
斯波特 10.0 4 棕色

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

虽然给出了一个名字,但不会将其作为我们决策树模型的特征包括进去。凭名字预测的依据有限;“菲利克斯”可能是一只猫,也可能是一只有毒的塔兰图拉蜘蛛。因此,有两个数值特征(重量、腿的数量)和一个分类特征(颜色)预测一个分类目标(适合/不适合孩子作宠物)。

决策树的工作方式是基于提供的特征进行一个或多个顺序决策。首先,机器人可能会尝试将一个简单的决策树拟合到这些训练数据中,这棵树只有一个基于重量的决策,如在图 4-2 中所示。

aaps 0402

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

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

因此,可以构建第二个决策来进一步细化对重量小于 100kg 的示例的预测。选择一个可以将一些不正确的是预测改为不的特征是一个好主意。例如,有一种小的绿色动物,听起来可疑地像蛇,将被我们当前的模型分类为适合的宠物候选者。通过添加基于颜色的决策,机器人可以正确预测,如在图 4-3 中所示。

aaps 0403

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

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

决策树推广为更强大的算法,称为随机森林。随机森林结合了许多决策树,以减少过拟合的风险,并单独训练决策树。该算法通过在训练过程中引入随机性,使每棵决策树略有不同。结合预测结果降低了预测的方差,使得生成的模型更具泛化能力,并提高了在测试数据上的表现。

这已经足够介绍决策树和随机森林,我们将在 PySpark 中开始使用它们。在下一节中,我们将介绍我们将在 PySpark 中使用的数据集,并为其准备数据。

准备数据

本章使用的数据集是著名的 Covtype 数据集,可在网上获取,以压缩的 CSV 格式数据文件covtype.data.gz和配套的信息文件covtype.info

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

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

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

启动 pyspark-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。例如,一个可以是 cloudyrainyclear 的天气分类特征会变成三个数值特征,其中 cloudy1,0,0 表示,rainy0,1,0 表示,依此类推。这三个数值特征可以被视为 is_cloudyis_rainyis_clear 特征。同样,其他 40 列实际上是一个 Soil_Type 分类特征。

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

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

我们已经看到了分类特征的两种编码类型。也许,如果不编码这些特征(而且是两种方式),而是直接包含它们的值,像“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://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/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://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/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 本身也是一个转换器,因为它可以将包含特征向量的 dataframe 转换为另一个包含预测的 dataframe。

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

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 分数。在这里,准确性将用于评估分类器。

这个单一数字很好地总结了分类器输出的质量。然而,有时查看混淆矩阵也很有用。这是一个表格,其中每个可能的目标值都有一行和一列。因为有七个目标类别值,所以这是一个 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
  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

将 null 替换为 0。

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

尽管 70% 的准确率听起来还不错,但并不清楚它是优秀还是较差。使用简单方法建立一个基准,能有多好呢?就像一块坏了的时钟每天正确两次一样,对每个示例随机猜测一个分类也偶尔会得到正确答案。

我们可以通过在训练集中按照其在训练集中的比例随机选择一个类来构建这样一个随机的“分类器”。例如,如果训练集的 30%是 cover type 1,则随机分类器将 30%的时间猜测“1”。每个分类将按照其在测试集中的比例正确,如果测试集的 40%是 cover type 1,则猜测“1”将在 40%的时间内正确。因此,cover type 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
    orderBy("Cover_Type").\ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)
    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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/3.png)
...

0.37735294664034547

1

按类别计数

2

按类别顺序计数

3

在训练集和测试集中求和产品对

随机猜测达到 37%的准确率,这使得 70%的结果看起来像是一个很好的结果。但后一个结果是通过默认超参数实现的。通过探索超参数对于树构建过程实际意味着什么,我们甚至可以做得更好。这就是我们将在下一节中做的事情。

决策树超参数

在第三章中,ALS 算法公开了几个超参数,我们必须通过使用各种值的模型构建,并使用某些指标评估每个结果的质量来选择它们的值。这里的过程是相同的,尽管度量现在是多类准确度,而不是 AUC。控制树决策选择的超参数也将大不相同:最大深度、最大 bins、不纯度度量和最小信息增益。

最大深度简单地限制了决策树中的层级数。它是分类器将做出的用于分类示例的一系列链式决策的最大数量。限制这一点对于避免过度拟合训练数据是有用的,正如在宠物店示例中所示。

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

什么使得一个决策规则好?直觉上,一个好的规则会通过目标类别值有意义地区分示例。例如,一个将 Covtype 数据集划分为一方面仅包含类别 1–3,另一方面包含类别 4–7 的规则将是优秀的,因为它清楚地将一些类别与其他类别分开。而导致与整个数据集中相同混合的规则似乎并不有用。遵循这种决策的任一分支导致可能目标值分布大致相同,因此并没有真正向自信的分类取得进展。

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

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

I G ( p ) = 1 - i=1 N p i 2

如果子集仅包含一个类,则该值为 0,因为它是完全“纯净”的。当子集中有N个类时,此值大于 0,并且当类出现相同次数时最大——最大不纯度。

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

I E ( p ) = i=1 N p i log ( 1 p i ) = - i=1 N p i log ( p i )

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

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

最后,最小信息增益 是一个超参数,它对候选决策规则施加了一个最小的信息增益,或者说减少了不纯度。不能显著提高子集不纯度的规则将被拒绝。就像较低的最大深度一样,这可以帮助模型抵抗过拟合,因为仅帮助划分训练输入的决策实际上可能根本不会有助于划分未来数据。

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

调整决策树

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

首先,需要设置一个管道,将我们在前几节中执行的两个步骤封装起来——创建特征向量和使用它创建决策树模型。创建 VectorAssemblerDecisionTreeClassifier 并将这两个 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)

这将需要几分钟或更长时间,取决于您的硬件,因为它正在构建和评估许多模型。请注意,train ratio 参数设置为 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 与深度为 1 相比并不奇怪地更好。也许最佳模型仅使用 40 个箱子是令人惊讶的,但这可能是 40 是“足够”而不是“比 300 更好”的迹象。最后,没有最小信息增益比小最小信息增益更好,这可能意味着模型更容易欠拟合而不是过拟合。

您可能想知道是否可以查看每个模型在每组超参数组合下达到的准确性。超参数和评估由 getEstimatorParamMapsvalidationMetrics 提供。它们可以组合在一起,按度量值排序显示所有参数组合:

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

...
0.9138921373048084

1

best_Model是一个完整的管道。

结果都是约为 91%。情况是估计从 CV 集中开始就很好。事实上,测试集显示非常不同的结果并不常见。

现在是重新讨论过拟合问题的有趣时刻。正如之前讨论的那样,可能会构建一个深度和复杂度非常高的决策树,它非常好地或完全地拟合给定的训练示例,但由于过于密切地适应了训练数据的特殊性和噪声,因此不能泛化到其他示例。这是大多数机器学习算法常见的问题,不仅仅是决策树。

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

到目前为止,我们隐式地将所有输入特征,包括分类特征,视为数值特征。我们可以通过将分类特征视为精确的分类特征来进一步改善模型的性能吗?我们将在下一步中探讨这个问题。

重新审视分类特征

我们数据集中的分类特征被独热编码为几个二进制 0/1 值。将这些单独特征视为数值特征其实效果不错,因为任何对“数值”特征的决策规则都将选择 0 到 1 之间的阈值,而所有值都是等效的,因为所有值都是 0 或 1。

当然,这种编码方式迫使决策树算法单独考虑底层分类特征的值。因为像土壤类型这样的特征被分解成许多特征,并且决策树将特征视为独立的,所以更难以关联相关土壤类型的信息。

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

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

如何撤销 one-hot 编码呢?例如,用一个将荒野类型编码为 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://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

    with_wilderness = wilderness_assembler.transform(data).\
      drop(*wilderness_cols).\ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/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

注意 UDF 定义

2

删除 one-hot 列;不再需要

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

现在我们可以通过上面定义的函数,去除数据集中的 one-hot 编码:

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
  setInputCol("featureVector").setOutputCol("indexedVector")

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

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

1

= 40 是因为土壤有 40 个值

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

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

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

随机森林

如果你一直在跟着代码示例,你可能已经注意到,你的结果与书中的代码清单中的结果略有不同。这是因为在构建决策树时存在一定的随机性,当你决定使用哪些数据和探索哪些决策规则时,这种随机性就会起作用。

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

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

出于和“群体的智慧”通常能胜过个体预测的同样原因,它是有好处的。为了说明这一点,做个简单的测验:伦敦有多少辆黑色出租车?

不要提前看答案;先猜测。

我猜测是 10,000 辆,这比正确答案大约 19,000 辆要少很多。因为我猜低了,你更有可能猜得比我高,所以我们的平均答案将更接近实际。再次回归到平均数。办公室里进行的一次非正式调查中的平均猜测确实更接近:11,170 辆。

该效果的关键在于这些猜测是独立的,彼此不会互相影响。(你没有偷看,对吧?)如果我们都同意并使用相同的方法来猜测,那么这个练习就没有意义了,因为猜测的答案会是一样的——也就是说,可能是完全错误的答案。如果我仅仅通过提前陈述我的猜测来影响你,情况甚至会变得更糟。

我们不是只需要一棵树而是需要许多树,每棵树都产生合理但不同和独立的目标值估计,这将有助于预测的集体平均预测接近真实答案,超过任何一棵单独树的预测。建造过程中的随机性有助于创建这种独立性。这就是随机森林的关键。

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

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

尽管随机森林是一种更强大且复杂的分类技术,好消息是,在本章开发的流水线中使用它实际上几乎没有任何不同。只需在 DecisionTreeClassifier 的位置放置一个 RandomForestClassifier,然后像以前一样继续即可。实际上,没有更多的代码或 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 DataFrame 唯一的不同之处在于它缺少Cover_Type列。当我们进行预测时——尤其是关于未来的预测,波尔先生说——输出当然是未知的。

为了证明它,请尝试从测试数据输入中删除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-means 聚类算法的基础开始。接下来是对 KDD Cup 1999 数据集的介绍。然后我们将使用 PySpark 创建我们的第一个 K-means 模型。然后我们将讨论在实施 K-means 算法时确定良好k值(聚类数)的方法。接下来,我们通过实施独热编码方法来使用以前丢弃的分类特征来改进我们的模型。我们将通过熵度量来总结,并探索一些来自我们模型的结果。

K-means 聚类

异常检测的固有问题正如其名称所示,即寻找异常的问题。如果我们已经知道数据集中什么是“异常”,我们可以很容易地通过监督学习检测数据中的异常。算法将接收标记为“正常”和“异常”的输入,并学会区分这两者。然而,异常的本质是未知的未知。换句话说,已经观察和理解的异常不再是异常。

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

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

当数据集包含客户活动或交易等信息时,“相似”意味着什么?K-means 需要数据点之间的距离概念。通常使用简单的欧氏距离来测量 K-means 中数据点之间的距离是常见的,正如在本文写作时 Spark MLlib 支持的两种距离函数之一,另一种是余弦距离。欧氏距离适用于其特征全部为数值的数据点。“相似”的点是其间距离较小的点。

对于 K-means 来说,聚类仅仅是一个点:所有构成该聚类的点的中心。事实上,它们只是包含所有数值特征的特征向量,可以称为向量。然而,在这里将它们视为点可能更直观,因为它们在欧氏空间中被视为点。

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

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

识别异常网络流量

网络攻击越来越多地出现在新闻中。一些攻击试图通过大量网络流量淹没计算机以排除合法流量。但在其他情况下,攻击试图利用网络软件中的缺陷未经授权访问计算机。当计算机遭受大量流量时显而易见,但检测到攻击行为则如同在网络请求的非常大的大海中搜索针一样困难。

有些攻击行为遵循已知的模式。例如,迅速连续访问一台机器上的所有端口,这绝不是任何正常软件程序应该做的事情。然而,这是攻击者寻找可能易受攻击的计算机服务的典型第一步。

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

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

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

KDD Cup 1999 数据集

KDD Cup是由计算机协会(ACM)的一个特别兴趣小组每年组织的数据挖掘竞赛。每年,都会提出一个机器学习问题,并附带一个数据集,邀请研究人员提交详细介绍他们解决问题的最佳方案的论文。1999 年的主题是网络入侵,数据集仍然可以在 KDD 网站上找到(链接)。我们需要从网站上下载kddcupdata.data.gzkddcup.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. 攻击。

请注意,数据包含非数字特征。例如,第二列可能是tcpudpicmp,但是 K-means 聚类需要数字特征。最终的标签列也是非数字的。首先,这些将被简单地忽略。

除此之外,创建数据的 K-means 聚类遵循与第四章中看到的相同模式。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-means 在数据上拟合了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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

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

1

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

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

再次说明,您的值可能会有所不同。聚类取决于随机选择的初始质心集。

然而,这是显而易见的。随着增加更多的聚类,总是可以将数据点放置在最近的质心附近。事实上,如果选择k等于数据点的数量,平均距离将为 0,因为每个点将成为其自身的一个包含一个点的聚类!

更糟糕的是,在前述结果中,k=80 的距离比k=60 的距离更高。这不应该发生,因为更高的k总是至少能够实现与较低k一样好的聚类。问题在于,K 均值算法不一定能够为给定的k找到最优的聚类。其迭代过程可能会从一个随机起点收敛到局部最小值,这可能是良好但并非最优的。

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

对于k=80 随机选择的起始聚类集可能导致特别次优的聚类,或者在达到局部最优之前可能提前停止。

我们可以通过增加迭代次数来改善它。该算法通过setTol设置一个阈值,控制被认为是显著的聚类质心移动的最小量;较低的值意味着 K 均值算法将允许质心继续移动更长时间。通过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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
      setTol(1.0e-5).\ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)
      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

从默认值 20 增加。

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提供了数据探索和数据可视化的库。此外,Spark 还通过SparkR提供了与 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 安装 R。

SparkR 是一个类似于 R 的命令行 shell 环境。要查看可视化效果,必须在能够显示图像的 IDE 类似环境中运行这些命令。RStudio 是一个适用于 R 的 IDE(与 SparkR 兼容);它在桌面操作系统上运行,因此只有在本地实验 Spark 而不是在集群上时才能在此处使用。

如果你在本地运行 Spark,请下载 RStudio 的免费版本并安装。如果没有,则本例的大部分其余部分仍可在命令行上(例如在集群上)通过 sparkR 运行,尽管这种方式无法显示可视化。

如果通过 RStudio 运行,请启动 IDE 并配置 SPARK_HOMEJAVA_HOME,如果你的本地环境尚未设置这些路径,则指向 Spark 和 JDK 的安装目录,分别如下:

Sys.setenv(SPARK_HOME = "*`/path/to/spark`*") ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
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

当然要替换为实际路径。

请注意,如果在命令行上运行 sparkR,则无需执行这些步骤。相反,它接受类似于 --driver-memory 的命令行配置参数,就像 spark-shell 一样。

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

clusters_data <- read.df("*`/path/to/kddcup.data`*", "csv", ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
 inferSchema = "true", header = "false") colnames(clusters_data) <- c( ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)
 "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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/3.png)
 c("protocol_type", "service", "flag", "label"))) 
kmeans_model <- spark.kmeans(numeric_only, ~ ., ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/4.png)
 k = 100, maxIter = 40, initMode = "k-means||")

1

替换为 kddcup.data 的路径。

2

命名列。

3

再次丢弃非数字列。

4

~ . 表示所有列。

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

R 拥有自己丰富的分析库以及类似的数据框架概念。因此,有时将一些数据下载到 R 解释器中以使用这些本地 R 库是很有用的,这些库与 Spark 无关。

当然,R 及其库并未分发,因此将包含 4,898,431 个数据点的整个数据集导入 R 中是不可行的。不过,只导入一个样本却很容易:

clustering <- predict(kmeans_model, numeric_only) clustering_sample <- collect(sample(clustering, FALSE, 0.01)) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

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

无替换的 1%样本

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

例如,可以提取集群分配,然后显示有关分配分布的统计信息:

clusters <- clustering_sample["prediction"] ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
data <- data.matrix(within(clustering_sample, rm("prediction"))) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

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

只有聚类分配列

2

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

例如,这表明大多数点落入集群 0。虽然在 R 中可以对此数据做更多处理,但进一步的覆盖范围超出了本书的范围。

要可视化数据,需要一个名为rgl的库。仅在 RStudio 中运行此示例时才会生效。首先安装(一次)并加载该库:

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

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

此数据集有 38 个维度。必须将其投影到最多三个维度中以使用随机投影进行可视化:

random_projection <- matrix(data = rnorm(3*ncol(data)), ncol = 3) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
random_projection_norm <-
 random_projection / sqrt(rowSums(random_projection*random_projection)) 
projected_data <- data.frame(data %*% random_projection_norm) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

1

进行随机 3-D 投影并归一化。

2

投影并创建新的数据框架。

通过选择三个随机单位向量并将数据投影到它们上,将 38 维数据集创建为 3-D 数据集。这是一种简单粗糙的降维方法。当然,还有更复杂的降维算法,比如主成分分析或奇异值分解。这些算法在 R 中都可以找到,但运行时间更长。在本示例中,随机投影可以更快地实现几乎相同的可视化效果。

最后,可以在交互式 3-D 可视化中绘制集群点:

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 中的可视化展示了 3-D 空间中的数据点。许多点重叠在一起,结果稀疏且难以解释。然而,可视化的主要特征是其 L 形状。点似乎沿着两个不同的维度变化,其他维度变化较小。

这是有道理的,因为数据集中有两个特征的尺度远大于其他特征。大多数特征的值在 0 到 1 之间,而 bytes-sent 和 bytes-received 特征的值则在 0 到数万之间变化。因此,点之间的欧氏距离几乎完全由这两个特征决定。其他特征几乎像不存在一样!因此,将这些尺度差异标准化是很重要的,以便使特征在接近相等的水平上。

aaps 0501

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

特征标准化

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

n o r m a l i z e d i = feature i -μ i σ i

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

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

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

分类变量

标准化是迈出的一大步,但可以做更多来改进聚类。特别是,由于它们不是数值的原因,一些特征已经完全被排除在外。这是在抛弃宝贵信息。以某种形式将它们重新添加回来应该会产生更为明智的聚类。

早些时候,由于非数值特征无法与 MLlib 中 K-means 使用的欧氏距离函数一起使用,三个分类特征被排除在外。这与 “随机森林” 中所指出的问题相反,那里使用数值特征来表示分类值,但却希望有一个分类特征。

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

同样地,MLlib 提供了实现此转换的组件。事实上,对像protocol_type这样的字符串值特征进行独热编码实际上是一个两步过程。首先,将字符串值转换为像 0、1、2 等整数索引,使用StringIndexer。然后,这些整数索引被编码成一个向量,使用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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

df = cluster_label.\
        groupBy("cluster", "label").\
        count().orderBy("cluster") ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/3.png)

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

1

针对每个数据点预测簇。

2

计算每个簇的标签数。

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
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

查看 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中使用,以几乎实时地对新数据进行评分,并可能触发警报或审核。

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

这个模型只是一个简单的模型。例如,这个例子中使用欧几里得距离是因为这是 Spark MLlib 目前支持的唯一距离函数。在未来,可能会使用能更好地考虑特征分布和相关性的距离函数,比如马哈拉诺比斯距离

还有更复杂的聚类质量评估指标,即使在没有标签的情况下也可以应用于选择k,例如轮廓系数。这些指标倾向于评估不仅仅是一个簇内点的紧密程度,还包括点与其他簇的紧密程度。最后,可以应用不同的模型来取代简单的 K-means 聚类;例如,高斯混合模型或者DBSCAN可以捕捉数据点与簇中心之间更微妙的关系。Spark MLlib 已经实现了高斯混合模型;其他模型的实现可能会在 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 库的开箱即用方法的强大,这使得自然语言处理任务变得更加容易。

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

潜在狄利克雷分配

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

它试图将语料库提炼成一组相关的主题。每个主题捕获数据中的一个变化线索,通常对应于语料库讨论的概念之一。一个文档可以属于多个主题。您可以将 LDA 视为提供一种软聚类文档的方法。不深入数学细节,LDA 主题模型描述两个主要属性:在对特定文档进行采样时选择主题的机会,以及在选择主题时选择特定术语的机会。例如,LDA 可能会发现一个与术语“Asimov”和“robot”强相关的主题,并与“基础系列”和“科幻小说”等文档相关联。通过仅选择最重要的概念,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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
lda.setMaxIter(10)

model = lda.fit(df)

model.vocabSize()
2

model.describeTopics().show() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)
+-----+-----------+--------------------+
|topic|termIndices|         termWeights|
+-----+-----------+--------------------+
|    0|     [0, 1]|[0.53331100994293...|
|    1|     [1, 0]|0.50230220117597...|
+-----+-----------+--------------------+

[1

我们将在我们的数据框上应用 LDA,主题数(k)设为 2。

2

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

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

获取数据

维基百科提供其所有文章的数据备份。完整的备份以单个大型 XML 文件形式提供。这些可以通过下载然后放置在云存储解决方案(如 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 的导出页面生成一个较小的数据集。尝试获取来自多个具有许多页面和少量子类别的类别(例如生物学、健康和几何学)的所有页面。为了使下面的代码运行起来,请下载这个数据集到ch06-LDA/目录,并将其重命名为wikidump.xml

我们需要将维基百科的 XML 转储转换为我们可以在 PySpark 中轻松使用的格式。在本地机器上工作时,我们可以使用方便的WikiExtractor 工具来提取和清理文本。它从维基百科数据库转储中提取和清理文本,正如我们所拥有的那样。

使用 pip 安装它:

$ pip3 install wikiextractor

然后,在包含下载文件的目录中运行以下命令就很简单了:

$ wikiextractor wikidump.xml

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

$ mv text wikidump ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
$ tree wikidump
...
wikidump
└── AA
    └── wiki_00

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

<doc id="18831" url="?curid=18831" title="Mathematics">
Mathematics

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

1

重命名文本目录为 wikidump

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

Spark NLP

Spark NLP 库最初由John Snow Labs于 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 实用程序:splitexplode来分隔条目。

from pyspark.sql import functions as fun
df = raw_data.withColumn('content', fun.explode(fun.split(fun.col("_2"),
  "</doc>")))
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拆分为数组。在前面的代码中,我们根据字符串将组合的文档 XML 字符串拆分为数组。这有效地给我们提供了一个包含多个文档的数组。然后,我们使用explodesplit函数返回的数组中的每个元素创建新行。这导致相应的每个文档创建了行。

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

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

-------------------------------------------------------------------
 content | <doc id="18831" url="?curid=18831" title="Mathematics">
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注释器。

我们可以像在前面的代码中那样直接转换输入的数据框架。但是,我们将使用DocumentAssembler和其他所需的注释器作为 ML 流水线的一部分。

作为我们预处理流水线的一部分,我们将使用以下注释器:TokenizerNormalizerStopWordsCleanerStemmer

让我们从Tokenizer开始:

# Split sentence to tokens(array)
tokenizer = Tokenizer() \
  .setInputCols(["document"]) \
  .setOutputCol("token")

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

接下来是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返回单词的硬干部,目的是检索单词的有意义部分。词干提取是将单词减少到其根单词词干的过程,目的是检索有意义的部分。例如,“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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

processed_df  = nlp_model.transform(df) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

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

训练流水线。

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-IDF

首先,我们将使用 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

topic_inds = [ind.termIndices for ind in raw_topics] ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/3.png)

topics = []
for topic in topic_inds:
    _topic = []
    for ind in topic:
        _topic.append(vocab[ind])
    topics.append(_topic)

1

创建一个对我们的词汇表的引用。

2

使用describeTopics方法从 LDA 模型获取生成的主题,并将它们加载到 Python 列表中。

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

如果你还记得,主题 2 与数学相关联。输出符合我们的期望。您可以扫描数据集的更多部分,以查看其执行情况。您可以使用wherefilter命令选择特定主题,并与之前生成的主题列表进行比较,以更好地了解已创建的聚类。正如本章开头所承诺的,我们能够在不阅读文章的情况下将其聚类到不同的主题中!

接下来的步骤

在本章中,我们对维基百科语料库进行了 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 年每个月的数据,如果你有足够大的 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 格式中的单个出租车行程。对于每次行程,我们有一些关于出租车的属性(车牌号的哈希版本)以及司机的信息(hack license 的哈希版本,这是驾驶出租车的许可证称呼),以及有关行程开始和结束的时间信息,以及乘客上车和下车的经度/纬度坐标。

让我们创建一个 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_datetimedropoff_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"))

再次查看一下模式(schema):

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_datetimedropoff_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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
    count().show()
...

Count of zero dropoff, pickoff latitude and longitude records
+---------------+
| ...  |   count|
+------+--------+
| true |  285909|
| false|14490620|
+---------------+

1

对于任何记录,如果多个 OR 条件为真,则任何一个条件为真时都为真。

我们有相当多这样的记录。如果看起来一辆出租车把乘客带到了南极点,我们可以合理地认为该记录是无效的,并应该从我们的分析中排除。我们不会删除它们,而是在下一节结束时回来看看它们如何影响我们的分析。

在生产环境中,我们逐个检查这些异常,查看各个任务的日志,找出引发异常的代码行,然后调整代码以忽略或纠正无效记录。这是一个繁琐的过程,通常感觉就像我们在打地鼠:刚解决一个异常,我们又发现了下一个异常记录。

当数据科学家在处理新数据集时,常用的一种策略是在其解析代码中添加 try-except 块,以便任何无效记录可以写入日志而不会导致整个作业失败。如果整个数据集中只有少数无效记录,我们可能可以忽略它们并继续分析。

现在我们已经准备好我们的数据集,让我们开始地理空间分析。

地理空间分析

有两种主要的地理空间数据类型——矢量和栅格——每种类型都有不同的工具用于处理。在我们的情况下,我们有出租车行程记录的纬度和经度,以及以 GeoJSON 格式存储的矢量数据,该数据表示了纽约不同区域的边界。我们已经查看了纬度和经度点。让我们首先看看 GeoJSON 数据。

GeoJSON 简介

我们将使用的纽约市各区边界的数据以GeoJSON格式编写。 GeoJSON 中的核心对象称为feature,由geometry实例和一组称为properties的键值对组成。 几何体是指点、线或多边形等形状。 一组要素称为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 库来执行此任务。 GeoPandas 是一个开源项目,旨在使 Python 中的地理空间数据处理更加简单。 它扩展了 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 迭代,直到找到一个几何图形包含给定经度/纬度点的要素为止。大多数纽约市的出租车行程始发和结束在曼哈顿,因此如果表示曼哈顿的地理空间要素在序列中较早出现,我们的搜索将相对较快结束。我们可以利用每个要素的 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 中的会话化

在这种分析中,我们希望分析单个实体随着时间执行一系列事件的情况,称为会话化,通常在网站的日志中执行,以分析用户的行为。 PySpark 提供了窗口和聚合函数,可以直接用于执行此类分析。这些函数允许我们专注于业务逻辑,而不是试图实现复杂的数据操作和计算。我们将在下一节中使用它们来更好地理解数据集中出租车的使用情况。

会话化可以是发现数据见解和构建新数据产品的强大技术。例如,Google 的拼写校正引擎是建立在每天从其网站属性上发生的每个事件(搜索、点击、地图访问等)的记录构成的会话之上的。为了识别可能的拼写校正候选项,Google 处理这些会话,寻找用户输入查询但没有点击任何内容,然后几秒钟后输入稍微不同的查询,然后点击结果并且不再返回 Google 的情况。然后,它统计这种模式对于任何一对查询发生的频率。如果发生频率足够高(例如,如果我们每次看到查询“解开的统计数据”,它后面都跟着几秒钟后的查询“美国”),那么我们就假定第二个查询是第一个查询的拼写校正。

此分析利用了事件日志中所表示的人类行为模式,从而构建了一个比可以从字典中创建的任何引擎更强大的数据拼写校正引擎。该引擎可用于执行任何语言的拼写校正,并且可以纠正可能不包含在任何字典中的单词(例如,新创业公司的名称)或查询,如“解开的统计数据”,其中没有任何单词拼写错误!Google 使用类似的技术显示推荐和相关搜索,以及决定哪些查询应返回 OneBox 结果,该结果在搜索页面本身上给出查询的答案,而不需要用户点击转到不同页面。有关天气、体育比分、地址和许多其他类型查询的 OneBoxes。

到目前为止,每个实体发生的事件集合的信息分布在 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 < 60*60*4").
  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 和 UDFs 相比,它们的学习曲线会更陡峭,但效率更高。在使用地理空间和时间数据进行数据可视化方面,还有很大的发展空间。

此外,想象一下,使用相同的技术来分析出租车数据,构建一个能够根据当前交通模式和包含在这些数据中的历史最佳位置记录,推荐出租车在落客后去的最佳地点的应用程序。你还可以从需要打车的人的角度来看待这些信息:在当前时间、地点和天气数据下,我能在接下来的五分钟内从街上拦到一辆出租车的概率是多少?这种信息可以整合到诸如 Google 地图之类的应用程序中,帮助旅行者决定何时出发以及选择哪种出行方式。

第八章:估计金融风险

在投资金融市场时,是否有一种方法可以近似预期损失的量?这就是金融统计量风险价值(VaR)试图衡量的内容。VaR 是一种简单的投资风险度量,试图提供投资组合在特定时间段内可能的最大损失的合理估计。VaR 统计量依赖于三个参数:一个投资组合,一个时间段和一个概率。例如,VaR 值为1million51 million。

自 1987 年股市崩盘后不久,VaR 在金融服务组织中广泛使用。该统计量通过帮助确定策略的风险特征,在这些机构的管理中发挥着至关重要的作用。

许多估计这一统计量的最复杂方法依赖于在随机条件下市场的计算密集型模拟。这些方法背后的技术被称为蒙特卡洛模拟,涉及提出数千甚至数百万个随机市场场景,并观察它们如何影响投资组合。这些场景被称为试验。PySpark 是进行蒙特卡洛模拟的理想工具。PySpark 可以利用数千个核心运行随机试验并汇总它们的结果。作为通用数据转换引擎,它还擅长执行围绕模拟的预处理和后处理步骤。它可以将原始金融数据转换为执行模拟所需的模型参数,并支持对结果的临时分析。与使用 HPC 环境的更传统方法相比,其简单的编程模型可以大大减少开发时间。

我们还将讨论如何计算一个相关的统计量称为条件风险价值(CVaR),有时也称为预期损失,这是几年前巴塞尔银行监督委员会提出的比 VaR 更好的风险度量。CVaR 统计量与 VaR 统计量具有相同的三个参数,但考虑的是预期平均损失,而不是提供可能损失的值。例如,CVaR 为5million55 million。

在建模 VaR 的过程中,我们将介绍一些不同的概念、方法和工具包。我们将从介绍贯穿整章使用的基本金融术语开始,然后学习计算 VaR 的方法,包括蒙特卡洛模拟技术。之后,我们将使用 PySpark 和 pandas 下载和准备我们的数据集。我们将使用 2000 年代末和 2010 年代初的股市数据,包括国债价格和各种公司的股票价值等市场指标。在预处理完成后,我们将创建一个线性回归模型,以计算股票在一段时间内的价值变化。我们还将想出一种方法,在执行蒙特卡洛模拟时生成样本市场指标值。最后,我们将使用 PySpark 执行模拟,并检查我们的结果。

让我们开始定义我们将使用的基本金融术语。

术语

本章使用了金融领域特定术语的集合:

工具

可交易资产,例如债券、贷款、期权或股票投资。任何特定时间,工具都被认为具有价值,即其可售出价格。

投资组合

金融机构拥有的一系列工具。

收益

一段时间内工具或投资组合价值的变化。

损失

负回报。

指数

一种虚构的工具组合。例如,纳斯达克综合指数包括约 3000 只主要美国和国际公司的股票及类似工具。

市场因素

用作特定时间金融环境宏观方面指标的值——例如,一个指数的价值,美国的国内生产总值,或美元与欧元之间的汇率。我们通常将市场因素简称为因素

计算 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])

对于每个工具,我们将训练一个模型,为每个特征分配一个权重。为了计算试验t中工具i的回报r[it],我们使用c[i],工具i的截距项;w[ij],工具i在特征j上的回归权重;以及f[tj],试验t中特征j的随机生成值:

r it = c i + j=1 |w i | w ij * f tj

这意味着每个工具的回报被计算为市场因素特征的回报乘以它们在该工具上的权重的总和。我们可以使用历史数据(也称为进行线性回归)为每个工具拟合线性模型。如果 VaR 计算的视野是两周,则回归将历史上的每个(重叠的)两周间隔视为一个标记点。

值得一提的是,我们也可以选择更复杂的模型。例如,模型不一定是线性的:它可以是回归树或明确地结合领域特定知识。

现在我们有了计算市场因素造成的工具损失的模型,我们需要一个模拟市场因素行为的过程。一个简单的假设是每个市场因素回报都遵循正态分布。为了捕捉市场因素通常相关的事实——当纳斯达克下跌时,道琼斯也可能在遭受损失——我们可以使用具有非对角协方差矩阵的多元正态分布:

m t 𝒩 ( μ , Σ )

其中,μ是因素回报的经验均值向量,Σ是因素回报的经验协方差矩阵。

和以前一样,我们可以选择更复杂的模拟市场的方法,或者假设每个市场因素的不同类型分布,也许使用尾部更厚的分布。

获取数据

下载历史股价数据集并将其放置在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 (纳斯达克:TLT)

  • iShares 美国信用债券 ETF(NYSEArca: CRED)

  • 黄金 ETF 信托基金(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))

我们将转换Date列的类型,并在因子 DataFrame 中也修剪时间段:

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 DataFrame,尝试将线性回归模型拟合到基于因子回报的工具回报上。

确定因子权重

回顾 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

factors_pd_df_with_returns.columns = factors_pd_df_with_returns.\
                                        columns.\
                                        to_series().\
                                        str.\
                                        join('_').\
                                        reset_index()[0]  ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

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

将因子数据框从长格式转换为宽格式,以便每个周期的所有因子都在一行中

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²很可能会高估模型拟合数据的效果。布鲁舍-戈德弗雷检验是评估这些效应的标准检验方法。评估模型的一个快速方法是将时间序列分为两组,中间留出足够的数据点,使得前一组的最后点与后一组的第一点不自相关。然后在一组上训练模型,并查看其在另一组上的误差。

现在我们手头有将因子回报映射到工具回报的模型,接下来我们需要一个过程来通过生成随机因子回报来模拟市场条件。这就是我们接下来要做的。

采样

要想出一个生成随机因子回报的方法,我们需要决定一个因子回报向量上的概率分布,并从中进行采样。数据实际上取什么分布?通常可以从视觉上开始回答这类问题是有用的。

一种很好的方法来可视化连续数据上的概率分布是密度图,它将分布的定义域与其概率密度函数进行绘制。因为我们不知道控制数据的分布,所以我们没有能够在任意点给出其密度的方程,但是我们可以通过一种称为核密度估计(KDE)的技术来近似它。以一种宽松的方式,核密度估计是平滑直方图的一种方法。它在每个数据点处都会居中一个概率分布(通常是正态分布)。因此,一个两周回报样本集将产生多个正态分布,每个都有不同的均值。为了估计给定点处的概率密度,它评估所有正态分布在该点的概率密度函数,并取它们的平均值。核密度图的平滑程度取决于其带宽,即每个正态分布的标准差。

我们将使用 pandas DataFrame 的内置方法之一来计算并绘制 KDE 图。以下代码片段创建了一个因子的密度图:

samples = factors_returns.loc[factors_returns.Symbol == \
                              factors_returns.Symbol.unique()[0]]['Close']
samples.plot.kde()

图 8-1 显示了我们历史中两周期国库债券 ETF 的回报分布(概率密度函数)。

两周 20+ 年期国库债券 ETF 分布

图 8-1. 两周 20+ 年期国库债券 ETF 回报分布

图 8-2 展示了美国信用债的两周回报情况。

aaps 0802

图 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个维度,协方差矩阵是NN的,因为我们想要捕获每对维度之间的协方差。当协方差矩阵是对角线时,多元正态分布将减少到沿着每个维度独立采样,但在对角线上放置非零值有助于捕获变量之间的关系。

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)

使用按试验分区的方法(我们将使用它),我们从种子的 DataFrame 开始。我们希望每个分区中有一个不同的种子,以便每个分区生成不同的试验:

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()))

随着我们的支架完成,我们可以用它来计算一个 DataFrame,其中每个元素是单个试验的总回报:

from pyspark.sql.functions import col, explode

trials = seedsDF.withColumn("trial_return", udf_return(col("value")))
trials = trials.select('value', explode('trial_return')) ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

trials.cache()

1

将试验回报的分割数组拆分为单独的 DataFrame 行

如果你还记得,我们一直在处理所有这些数字的整个原因是为了计算 VaR。现在,trials 形成了投资组合收益的经验分布。为了计算 5% VaR,我们需要找到预计会低于 5% 时间的收益和预计会超过 5% 时间的收益。通过我们的经验分布,这就像找到比率中最差的 5% 和比率中最好的 95% 一样简单。我们可以通过把最差的 5% 的试验拉到驾驶中来实现这一点。我们的 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

图 8-3. 两周收益分布

接下来的步骤

在这个练习中提出的模型是在实际金融机构中使用的第一次粗略尝试。在构建准确的 VaR 模型时,我们忽略了一些非常重要的步骤。筛选市场因素的集合可能是模型的成败关键,金融机构在其模拟中通常会融合数百种因素。选择这些因素既需要在历史数据上运行多次实验,又需要大量的创造力。选择将市场因素映射到工具收益的预测模型也很重要。虽然我们使用了简单的线性模型,但许多计算使用非线性函数或模拟时间路径使用布朗运动。

最后,我们值得关注用于模拟因子收益的分布。科尔莫哥洛夫-斯米尔诺夫检验和卡方检验对于测试经验分布的正态性非常有用。Q-Q 图用于直观比较分布。通常,金融风险更好地通过具有比我们使用的正态分布更胖尾部的分布来反映。正态分布的混合是实现这些胖尾部的一种好方法。Markus Haas 和 Christian Pigorsch 的文章《金融经济学,胖尾分布》提供了一些其他胖尾分布的良好参考。

银行使用 PySpark 和大规模数据处理框架计算历史方法的 VaR。“使用历史数据评估风险价值模型”,由 Darryll Hendricks 编写,提供了对历史 VaR 方法的概述和性能比较。

蒙特卡洛风险模拟不仅可以用于计算单一统计量。其结果可以用于通过塑造投资决策来积极降低投资组合的风险。例如,在那些回报最差的试验中,特定的一组工具可能会反复亏损,我们可能考虑从投资组合中剔除这些工具,或者添加那些倾向于与它们相反方向移动的工具。

第九章:分析基因组数据与 BDG 项目

下一代 DNA 测序(NGS)技术的出现迅速将生命科学转变为一个数据驱动的领域。然而,充分利用这些数据却遭遇到了一个传统的计算生态系统,其构建在难以使用的低级原语上,用于分布式计算,以及一大堆半结构化的基于文本的文件格式。

本章将有两个主要目的。首先,我们介绍一套流行的序列化和文件格式(Avro 和 Parquet),它们简化了数据管理中的许多问题。这些序列化技术使我们能够将数据转换为紧凑的、机器友好的二进制表示形式。这有助于在网络间移动数据,并帮助不同编程语言之间的跨兼容性。虽然我们将在基因组数据中使用数据序列化技术,但这些概念在处理大量数据时也是有用的。

其次,我们展示如何在 PySpark 生态系统中执行典型的基因组学任务。具体来说,我们将使用 PySpark 和开源的 ADAM 库来操作大量的基因组学数据,并处理来自多个来源的数据,创建一个用于预测转录因子(TF)结合位点的数据集。为此,我们将从ENCODE 数据集中获取基因组注释。本章将作为 ADAM 项目的教程,该项目包括一组面向基因组学的 Avro 模式、基于 PySpark 的 API 以及用于大规模基因组学分析的命令行工具。除了其他应用外,ADAM 使用 PySpark 提供了基因组分析工具包(GATK)的本地分布式实现。

我们将首先讨论生物信息学领域使用的各种数据格式,相关的挑战,以及序列化格式如何帮助解决问题。接着,我们将安装 ADAM 项目,并使用一个样本数据集探索其 API。然后,我们将使用多个基因组学数据集准备一个数据集,用于预测特定类型蛋白质(CTCF 转录因子)DNA 序列中的结合位点。这些数据集将从公开可用的 ENCODE 数据集中获取。由于基因组暗示了一个一维坐标系统,许多基因组学操作具有空间性质。ADAM 项目提供了一个针对基因组学的 API,用于执行分布式空间连接。

对于那些感兴趣的人,Eric Lander 的 EdX 课程《生物学简介》是一个很好的入门。而对于生物信息学的介绍,可以参考亚瑟·莱斯克的《生物信息学简介》(牛津大学出版社)。

解耦存储与建模

生物信息学家花费了大量时间担心文件格式 —— .fasta.fastq.sam.bam.vcf.gvcf.bcf.bed.gff.gtf.narrowPeak.wig.bigWig.bigBed.ped.tped 等等。一些科学家还觉得有必要为他们的自定义工具指定自己的自定义格式。此外,许多格式规范是不完整或含糊不清的(这使得很难确保实现是一致的或符合规范),并指定了 ASCII 编码的数据。ASCII 数据在生物信息学中非常常见,但效率低下且相对难以压缩。此外,数据必须始终进行解析,需要额外的计算周期。

这是特别令人担忧的,因为所有这些文件格式本质上只存储了几种常见的对象类型:对齐的序列读取,调用的基因型,序列特征和表型。(序列特征 这个术语在基因组学中有些多重含义,但在本章中我们指的是 UCSC 基因组浏览器的轨迹元素。)像 biopython 这样的库非常流行,因为它们充斥着解析器(例如, Bio.SeqIO ),这些解析器试图将所有文件格式读取到少量常见的内存模型中(例如, Bio.SeqBio.SeqRecordBio.SeqFeature )。

我们可以使用像 Apache Avro 这样的序列化框架一举解决所有这些问题。关键在于 Avro 将数据模型(即显式模式)与底层存储文件格式以及语言的内存表示分离开来。Avro 指定了如何在进程之间传递特定类型的数据,无论是在互联网上运行的进程之间,还是尝试将数据写入特定文件格式的进程。例如,使用 Avro 的 Java 程序可以将数据写入多种与 Avro 数据模型兼容的底层文件格式。这使得每个进程都不再需要担心与多种文件格式的兼容性:进程只需要知道如何读取 Avro 数据,文件系统只需要知道如何提供 Avro 数据。

让我们以序列特征为例。我们首先使用 Avro 接口定义语言(IDL)为对象指定所需的模式:

enum Strand {
  Forward,
  Reverse,
  Independent
}

record SequenceFeature {
  string featureId;
  string featureType; ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
  string chromosome;
  long startCoord;
  long endCoord;
  Strand strand;
  double value;
  map<string> attributes;
}

1

例如,“保守性”,“蜈蚣”,“基因”

这种数据类型可以用来编码,例如,基因组中特定位置的保守水平、启动子或核糖体结合位点的存在、TF 结合位点等。可以将其视为 JSON 的二进制版本,但更受限制且具有更高的性能。根据特定的数据模式,Avro 规范确定了对象的精确二进制编码方式,以便可以在不同编程语言的进程之间轻松传输(甚至写入到不同编程语言的进程之间),通过网络或者存储到磁盘上。Avro 项目包括用于处理多种语言(包括 Java、C/C++、Python 和 Perl)中的 Avro 编码数据的模块;在此之后,语言可以自由地将对象存储在内存中,以最有利的方式。数据建模与存储格式的分离提供了另一种灵活性/抽象层次;Avro 数据可以存储为 Avro 序列化的二进制对象(Avro 容器文件)、用于快速查询的列式文件格式(Parquet 文件)或者作为文本 JSON 数据以实现最大的灵活性(最小的效率)。最后,Avro 支持模式演化,允许用户在需要时添加新字段,同时软件会优雅地处理新/旧版本的模式。

总体上,Avro 是一种高效的二进制编码,允许您指定可适应变化的数据模式,从多种编程语言处理相同的数据,并使用多种格式存储数据。决定使用 Avro 模式存储数据将使您摆脱永远使用越来越多的自定义数据格式的困境,同时提高计算性能。

在前面的例子中使用的特定SequenceFeature模型对于真实数据来说有些简单,但是 Big Data Genomics(BDG)项目已经定义了 Avro 模式来表示以下对象以及许多其他对象:

  • AlignmentRecord 用于读取。

  • Variant用于已知的基因组变异和元数据。

  • Genotype用于特定位点的已调用基因型。

  • Feature用于序列特征(基因组片段上的注释)。

实际的模式可以在bdg-formats GitHub 存储库中找到。BDG 格式可以用作广泛使用的“传统”格式(如 BAM 和 VCF)的替代,但更常用作高性能的“中间”格式。(这些 BDG 格式的最初目标是取代 BAM 和 VCF 的使用,但它们的固执普及性使得实现这一目标变得困难。)Avro 相对于自定义 ASCII 格式提供了许多性能和数据建模的优势。

在本章的其余部分,我们将使用一些 BDG 模式来完成一些典型的基因组学任务。在我们能够做到这一点之前,我们需要安装 ADAM 项目。这将是我们接下来将要做的事情。

ADAM 的设置

BDG 的核心基因组工具称为 ADAM。从一组映射的读取开始,该核心包括可以执行标记重复、基质质量分数重新校准、插入/缺失重对齐和变体调用等任务的工具。ADAM 还包含一个命令行界面,用于简化使用。与传统的 HPC 工具不同,ADAM 可以自动跨集群并行处理,无需手动分割文件或手动调度作业。

我们可以通过 pip 安装 ADAM:

pip3 install bdgenomics.adam

可以在GitHub 页面找到其他安装方法。

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 [<spark-args> --] <adam-args>

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 处理基因组数据入门

我们将从包含一些映射的 NGS 读取的.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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
  --deploy-mode client \
  --driver-memory 8G \
  --num-executors 6 \
  --executor-cores 4 \
  --executor-memory 12G \
  -- \
  transform \ ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)
  data/genomics/HG00103.mapped.ILLUMINA.bwa.GBR\ .low_coverage.20120522.bam \
  data/genomics/HG00103

1

在 YARN 上运行的示例 Spark 参数

2

ADAM 子命令本身

这应该会启动相当大量的输出到控制台,包括跟踪作业进度的 URL。

结果数据集是data/genomics/reads/HG00103/目录中所有文件的串联,其中每个part-.parquet文件是一个 PySpark 任务的输出。您还会注意到,由于列式存储,数据比初始的.bam*文件(底层为 gzipped)压缩得更有效(请参见“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'.

>>>

在某些情况下,当尝试使用 ADAM 与 PySpark 时,您可能会遇到 TypeError 错误,并提到 JavaPackage 对象未被加载的问题。这是一个已知问题,并在此处有文档记录。

在这种情况下,请尝试线程中建议的解决方案。可以通过运行以下命令来启动带有 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

readsDataDF = readsData.toDF() ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

unique_chr = readsDataDF.select('referenceName').distinct(). \ ![3](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/3.png)
              collect() ![4](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/4.png)

1

AlignmentDataset:包含所有数据的 ADAM 类型。

2

DataFrame:底层的 Spark DataFrame。

3

这将聚合所有不同的 contig 名称;这将很小。

4

这将触发计算,并将 DataFrame 中的数据返回到客户端应用程序(Shell)。

举个更临床的例子,假设我们正在测试一个个体的基因组,以检查他们是否携带任何可能导致其子女患囊性纤维化(CF)的基因变异。我们的基因检测使用下一代 DNA 测序从多个相关基因生成读取数,比如 CFTR 基因(其突变可以导致 CF)。在通过我们的基因分型流程运行数据后,我们确定 CFTR 基因似乎有一个导致其功能受损的早期终止密码子。然而,这种突变在Human Gene Mutation Database中从未报告过,也不在Sickkids CFTR database中(这是聚合 CF 基因变异的数据库)。我们想回到原始测序数据,以查看可能有害的基因型调用是否为假阳性。为此,我们需要手动分析所有映射到该变异位点的读数,比如染色体 7 上的 117149189 位置(参见图 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

图 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 结合位点。

特别是,我们将使用来自HT-SELEX的已知序列模体数据和其他来自公开可用的 ENCODE 数据集的 DNase-seq 数据来预测 CTCF TF 的结合位点。我们选择了六种不同的细胞类型,这些类型具有可用的 DNase-seq 和 CTCF ChIP-seq 数据用于训练。训练示例将是一个 DNase 敏感性(HS)峰(基因组的一个片段),TF 是否结合/未结合的二进制标签将从 ChIP-seq 数据中导出。

总结整体数据流程:主要的训练/测试样本将从 DNase-seq 数据中派生。每个开放染色质区域(基因组上的一个区间)将用于生成是否会在那里结合特定组织类型中的特定转录因子的预测。为此,我们将 ChIP-seq 数据空间连接到 DNase-seq 数据;每个重叠都是 DNase 序列对象的正标签。最后,为了提高预测准确性,我们在 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
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

流式解压缩

接下来,我们下载 CTCF TF 的 ChIP-seq 数据,也是.narrowPeak格式,以及 GTF 格式的 GENCODE 数据:

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. 染色体

  2. 起始位点

  3. 结束

  4. 距离最近的转录起始位点(TSS)

  5. TF 标识(在本例中始终为“CTCF”)

  6. 细胞系

  7. 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
dnase_data.toDF().columns ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)
...
['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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

1

FeatureDataset

2

Dnase DataFrame 中的列

chipseq_data 中的 ReferenceRegion 重叠的位点具有 TF 结合位点,因此标记为 true,而其余的位点标记为 false。这是通过 ADAM API 中提供的 1D 空间连接原语来实现的。连接功能需要一个按 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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

1

与之前创建的 tss_df 进行左连接。

2

获取最接近的 TSS 距离。

在循环遍历各个细胞系时,每次通过后计算这个最终的 DF。最后,我们将每个细胞系的每个 DF 合并并将这些数据缓存在内存中,以便为训练模型做准备。

preTrainingData = data_by_cellLine.union(...)
preTrainingData.cache()

preTrainingData.count()
preTrainingData.filter(fun.col("label") == true).count()

此时,preTrainingData 中的数据可以被归一化并转换为一个 DataFrame,用于训练分类器,如 “Random Forests” 中所述。请注意,应执行交叉验证,在每个折叠中,您应保留一个细胞系的数据。

接下来该去哪里

许多基因组学中的计算都很适合于 PySpark 的计算范式。当您进行即席分析时,像 ADAM 这样的项目最有价值的贡献是提供了表示底层分析对象的 Avro 模式集合(以及转换工具)。我们看到一旦数据转换为相应的 Avro 模式,许多大规模计算变得相对容易表达和分布。

尽管在 PySpark 上进行科学研究的工具可能仍然相对匮乏,但确实存在一些项目,可以帮助避免重复造轮子。我们探索了 ADAM 中实现的核心功能,但该项目已经为整个 GATK 最佳实践管道(包括插入缺失重整和去重复)提供了实现。除了 ADAM 外,Broad Institute 现在还在使用 Spark 开发主要软件项目,包括 GATK4 的最新版本和一个名为 Hail 的用于大规模人群遗传学计算的项目。所有这些工具都是开源的,因此如果您开始在自己的工作中使用它们,请考虑贡献改进!

第十章:深度学习与 PySpark LSH 的图像相似性检测

无论您在社交媒体还是电子商务店铺上遇到它们,图像对于我们的数字生活至关重要。事实上,正是一个图像数据集——ImageNet——引发了当前深度学习革命的关键组成部分。在 ImageNet 2012 挑战中,分类模型的显著表现是重要的里程碑,并引起了广泛关注。因此,作为数据科学从业者,您很可能会在某个时刻遇到图像数据。

本章中,你将通过使用 PySpark 来扩展深度学习工作流程,针对视觉任务——即图像相似性检测,获得经验。识别相似图像对于人类来说是直观的,但对计算机来说是一项复杂的任务。在大规模情况下,这变得更加困难。在本章中,我们将介绍一种用于查找相似项的近似方法,称为局部敏感哈希(LSH),并将其应用于图像。我们将使用深度学习将图像数据转换为数值向量表示。PySpark 的 LSH 算法将应用于生成的向量,这将使我们能够在给定新输入图像时找到相似图像。

从高层次来看,这个例子反映了像 Instagram 和 Pinterest 等照片分享应用程序用于图像相似性检测的方法之一。这有助于他们的用户理解其平台上存在的大量视觉数据。这也展示了一个深度学习工作流程如何从 PySpark 的可扩展性中受益。

首先,我们将简要介绍 PyTorch,一个深度学习框架。近年来,它因其相对于其他主要低级深度学习库更易学习的曲线而备受关注。然后,我们将下载和准备我们的数据集。用于我们任务的数据集是由斯坦福人工智能实验室于 2013 年发布的 Cars 数据集。PyTorch 将用于图像预处理。接下来,我们将把输入图像数据转换为向量表示(图像嵌入)。然后,我们将导入结果嵌入到 PySpark 中,并使用 LSH 算法进行转换。最后,我们将采用新图像,并使用我们的 LSH 转换数据集进行最近邻搜索,以找到相似的图像。

让我们从介绍和设置 PyTorch 开始。

PyTorch

PyTorch 是一个用于构建深度学习项目的库。它强调灵活性,并允许使用 Python 来表达深度学习模型的习惯用语。它早期被研究社区早期采用。最近,由于其易用性,它已成为广泛应用的主要深度学习工具之一。与 TensorFlow 一起,它是目前最流行的深度学习库之一。

PyTorch 的简单灵活接口支持快速实验。您可以加载数据,应用转换并用几行代码构建模型。然后,您可以灵活编写定制的训练、验证和测试循环,并轻松部署训练模型。它始终在专业环境中用于实际的关键任务。能够利用 GPU(图形处理单元)训练资源密集型模型是使深度学习流行的重要因素。PyTorch 提供出色的 GPU 支持,尽管我们不需要在我们的任务中使用它。

安装

PyTorch 网站上,您可以根据系统配置轻松获取安装说明,如图 10-1 所示。

PyTorch 安装 CPU 支持

图 10-1. PyTorch 安装,CPU 支持

执行提供的命令并按照您的配置说明操作:

$ pip3 install torch torchvision

我们不会依赖 GPU,因此将选择 CPU 作为计算平台。如果您有要使用的 GPU 设置,请根据需要选择选项以获取所需的说明。我们在本章中也不需要 Torchaudio,因此跳过其安装。

准备数据

我们将使用斯坦福汽车数据集。它是由 Jonathan Krause、Michael Stark、Jia Deng 和 Li Fei-Fei 在 ICCV 2013 年论文“3D 对象表示用于细粒度分类”中发布的。

您可以从 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。下载它,将其重命名为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

忽略除了Classimage之外的所有列。其他列与此数据集衍生自的原始研究项目相关,不会在我们的任务中使用。

使用 PyTorch 调整图像大小

在我们进一步之前,我们需要预处理我们的图像。在机器学习中,预处理数据非常普遍,因为深度学习模型(神经网络)希望输入满足某些要求。

我们需要应用一系列预处理步骤,称为转换,将输入图像转换为模型所需的正确格式。在我们的案例中,我们需要它们是 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 架构。

残差网络(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 图像嵌入在二维空间中的表示

图 10-2. ILSVRC 2012 图像嵌入在二维空间中的表示

我们可以通过取其倒数第二个全连接层的输出来从 ResNet-18 获取图像的嵌入。接下来,我们创建一个类,该类可以在提供图像的情况下返回其数值向量形式的表示。

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
        self.normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406],
                                              std=[0.229, 0.224, 0.225]) ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

    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

将图像转换为 PyTorch 张量格式。

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 家族由不同的类实现(例如MinHashBucketedRandomProjection),并且在每个类中提供了用于特征转换、近似相似性连接和近似最近邻的 API。

我们将使用 LSH 的 BucketedRandomProjection 实现。

让我们首先创建我们的模型对象:

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

从数据集中随机选择的汽车图像

图 10-3。从我们的数据集中随机选择的汽车图像

首先,我们需要使用我们的Img2VecResnet18类将输入图像转换为向量格式:

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 中的图像(链接:#result-image1 至 #result-image5),看看模型已经有了一些正确的结果:

for i in list(result.select('_c0').toPandas()['_c0']):
    display(Image.open(os.path.join(input_dir_cnn, i)))

结果图像 1

图 10-4。结果图像 1

结果图像 2

图 10-5。结果图像 2

结果图像 3

图 10-6。结果图像 3

结果图像 4

图 10-7。结果图像 4

结果图像 5

图 10-8. 结果图像 5

输入图像位于列表顶部,正如人们所预期的那样。

下一步怎么走

在本章中,我们学习了如何将 PySpark 与现代深度学习框架结合起来,以扩展图像相似性检测工作流程。

有多种方法可以改进这个实现。你可以尝试使用更好的模型或者改进预处理以获得更好的嵌入质量。此外,LSH 模型也可以进行调整。在实际环境中,你可能需要定期更新参考数据集,以适应系统中新图像的到来。最简单的方法是定期运行批处理作业,创建新的 LSH 模型。你可以根据自己的需求和兴趣来探索所有这些方法。

第十一章:使用 MLflow 管理机器学习生命周期

随着机器学习在各行业的重要性日益突出并在生产环境中部署,围绕它的协作和复杂性水平也相应增加。幸运的是,已经出现了平台和工具,以有组织的方式管理机器学习生命周期。一个与 PySpark 兼容的这类平台是 MLflow。在本章中,我们将展示如何使用 MLflow 与 PySpark。在此过程中,我们将介绍您可以在数据科学工作流中引入的关键实践。

与其从头开始,我们将在第四章所做的工作基础上进行构建。我们将使用 Covtype 数据集重新审视我们的决策树实现。这一次,我们将使用 MLflow 来管理机器学习生命周期。

我们将首先解释围绕机器学习生命周期的挑战和过程。然后我们将介绍 MLflow 及其组件,以及 MLflow 对 PySpark 的支持。接下来我们将介绍如何使用 MLflow 跟踪机器学习训练运行。然后我们将学习如何使用 MLflow Models 管理机器学习模型。然后我们将讨论我们的 PySpark 模型的部署并对其进行实现。我们将通过创建一个 MLflow 项目来结束本章。这将展示如何使我们迄今为止的工作对合作者可重现。让我们开始讨论机器学习生命周期。

机器学习生命周期

描述机器学习生命周期的方法有多种。一个简单的方法是将其分解为不同的组件或步骤,如图 11-1 所示。这些步骤对于每个项目来说不一定是顺序的,并且生命周期往往是循环的。

  • 业务项目定义和利益相关者的对齐

  • 数据获取和探索

  • 数据建模

  • 结果的解释和沟通

  • 模型实施和部署

机器学习生命周期

图 11-1。ML 生命周期

你能够迭代机器学习生命周期的速度影响你能够将工作投入实际应用的速度。例如,由于底层数据的变化,实施的模型可能会过时。在这种情况下,你需要重新审视过去的工作并再次构建。

机器学习项目生命周期中可能出现的挑战示例包括:

缺乏可重现性

即使代码和参数已被跟踪,同一团队的数据科学家可能无法复现彼此的结果。这可能是由于执行环境(系统配置或库依赖项)不同造成的。

模型标准化的缺乏

不同的团队可能使用不同的库和存储机器学习模型的约定,这在团队间共享工作时可能会成为问题。

在进行 ML 生命周期的结构化工作时可能会快速变得无法控制。针对这类挑战,多个开源和专有平台可供选择。其中一个领先的开源平台是 MLflow,在接下来的部分中我们将进行介绍。

MLflow

MLflow 是一个管理端到端机器学习生命周期的开源平台。它帮助我们复现和共享实验,管理模型并为最终用户部署模型。除了 REST API 和 CLI 外,它还提供了 Python、R 和 Java/Scala 的 API。

如 图 11-2 所示,它有四个主要组件:

MLflow Tracking

此组件记录参数、指标、代码版本、模型以及如图表和文本等工件。

MLflow 项目

此组件为您提供可重复使用的、可重现的格式,以与其他数据科学家共享或传输到生产中。它帮助您管理模型训练过程。

MLflow 模型

此组件使您能够将模型打包部署到各种模型服务和推断平台。它提供了一个一致的 API,用于加载和应用模型,无论使用哪种底层库构建模型。

MLflow 注册表

此组件使您能够在中心存储中协作跟踪模型衍生、模型版本、阶段转换和注释。

MLflow 组件

图 11-2. MLflow 组件

让我们安装 MLflow。使用 pip 安装非常简单:

$ pip3 install mlflow

就是这样!

MLflow 与许多流行的机器学习框架集成,如 Spark、TensorFlow、PyTorch 等。接下来的几节中,我们将使用它对 Spark 的本地支持。导入特定于 Spark 的 MLflow 组件就像运行 import mlflow.spark 一样简单。

在下一节中,我们将介绍 MLflow Tracking 并将其添加到我们的决策树代码中 第四章。

实验跟踪

典型的机器学习项目涉及尝试多种算法和模型以解决问题。需要跟踪相关的数据集、超参数和指标。通常使用临时工具(如电子表格)进行实验跟踪可能效率低下,甚至不可靠。

MLflow Tracking 是一个 API 和 UI,用于在运行机器学习代码时记录参数、代码版本、指标和工件,并在稍后可视化结果。您可以在任何环境中使用 MLflow Tracking(例如独立脚本或笔记本)将结果记录到本地文件或服务器,然后比较多次运行。它与多个框架集成且与库无关。

MLflow 跟踪围绕“运行”这一概念组织,这些运行是某段数据科学代码的执行。MLflow 跟踪提供了一个界面,让您可以可视化,搜索和比较运行,以及下载运行的工件或元数据以在其他工具中进行分析。它包含以下关键功能:

  • 基于实验的运行列表和比较

  • 根据参数或指标值搜索运行

  • 可视化运行指标

  • 下载运行结果

让我们在 PySpark shell 中的决策树代码中添加 MLflow 跟踪。假设您已下载了Covtype 数据集并对其熟悉。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/。您将看到一个如图 11-3 所示的界面。您可以搜索所有运行,过滤符合特定条件的运行,将运行进行比较等。如果您愿意,还可以将内容导出为 CSV 文件以进行本地分析。点击界面中名为decision-tree的运行。

MLflow UI 1

图 11-3. MLflow UI 1

在查看单个运行时,如图 11-4 所示,您会注意到 MLflow 存储了所有相应的参数,指标等。您可以在其中添加关于此运行的自由文本注释,以及标签。

MLflow UI 2

图 11-4. MLflow UI 2

现在我们能够跟踪和重现我们的实验。现在让我们讨论使用 MLflow 管理我们的模型。

管理和提供机器学习模型

MLflow 模型是打包机器学习模型的标准格式,可以在各种下游工具中使用,例如通过 REST API 进行实时服务或在 Apache Spark 上进行批量推断。该格式定义了一种约定,可让您以不同的“口味”保存模型,这些口味可以被不同的库理解。

Flavor 是使 MLflow 模型强大的关键概念。它使得可以编写可以与任何 ML 库中的模型一起工作的工具,而无需将每个工具与每个库集成。MLflow 定义了几种“标准” flavor,所有其内置部署工具都支持,如描述如何将模型作为 Python 函数运行的“Python function” flavor。然而,库也可以定义和使用其他 flavors。例如,MLflow 的mlflow.sklearn库允许将模型加载回作为 scikit-learn 的Pipeline对象,在意识到 scikit-learn 的代码中使用,或者作为通用 Python 函数在仅需要应用模型的工具中使用(例如用于将模型部署到 Amazon SageMaker 的mlflow.sagemaker工具)。

MLflow 模型是一个包含一组文件的目录。我们之前使用log_modelAPI 记录了我们的模型。这创建了一个名为 MLmodel 的文件。打开决策树运行并向下滚动到“Artifacts”部分。查看 MLmodel 文件。它的内容应类似于图 11-5 所示。

MLflow 模型

图 11-5. MLflow 模型

该文件捕获了我们模型的元数据、签名和 flavors。模型签名定义了模型输入和输出的模式。

我们的模型文件有两种 flavor:python_function 和 spark。python_function flavor 使得 MLflow 的模型部署和服务工具可以处理任何 Python 模型,而不管该模型是用哪个 ML 库训练的。因此,任何 Python 模型都可以轻松地在各种运行时环境中投入生产。

Spark 模型风格支持将 Spark MLlib 模型导出为 MLflow 模型。例如,可以使用记录的模型在 Spark DataFrame 上进行预测:

import mlflow

run_id = "0433bb047f514e28a73109bbab767222" ![1](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)
logged_model = f'runs:/{run_id}/model' ![2](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/2.png)

# 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

我们使用 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 Projects 是一个可重复使用和可复制打包的标准格式。它是一个自包含单元,捆绑了执行机器学习工作流所需的所有机器代码和依赖项,并使你能够在任何系统或环境上生成特定模型运行。MLflow Projects 包括一个 API 和命令行工具来运行项目。它还可以用于将项目链接到工作流中。

每个项目只是一个文件目录,或者是一个包含你的代码的 Git 仓库。MLflow 可以根据在该目录中放置文件的约定来运行某些项目(例如,conda.yml文件被视为一个 Conda 环境),但是你可以通过添加 MLproject 文件来更详细地描述你的项目,该文件是一个格式为 YAML 的文本文件。

MLflow 当前支持以下项目环境:Conda 环境、Docker 容器环境和系统环境。默认情况下,MLflow 使用系统路径来查找和运行 Conda 二进制文件。

创建一个基本的 MLflow 项目很简单。所需的步骤列在图 11-6 中。

如何构建一个 MLflow 项目

图 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 中获取这个。进入我们之前看到的 decision-tree 运行。向下滚动到 Artifacts,点击 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

现在我们将创建 Python 脚本,用于在执行 MLflow 项目时训练决策树模型。为此,我们将使用前面一节中的代码:

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](https://gitee.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/adv-anls-pyspark/img/1.png)

    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

假设数据位于执行的 MLflow 项目目录的上一级目录。

数据也可以包含在 MLflow 项目中。在这种情况下,我们没有这样做是因为数据太大了。在这种情况下,可以使用 AWS S3 或 GCS 等云存储来共享数据。

在分享之前,你也可以在本地模拟协作者如何工作,我们使用mlflow run命令来实现这一点。

mlflow run decision_tree_project
...
[...]
{'accuracy': 0.6988990605087336, 'f1': 0.6805617730220171}

现在我们有一个可复制的 MLflow 项目。我们可以将其上传到 GitHub 仓库,并与协作者分享,对方能够复现我们的工作。

从这里开始

本章介绍了 MLflow 项目,并指导您如何在简单项目中实施它。 MLflow 项目本身有很多可以探索的内容。 您可以在官方文档中找到更多信息。 还有其他工具可供选择。 这些包括开源项目,如 Metaflow 和 Kubeflow,以及亚马逊 SageMaker 和 Databricks 平台等大型云提供商的专有产品。

当然,工具只是应对现实世界中机器学习项目挑战的一部分解决方案。 进程需要由参与任何项目的人员定义。 我们希望您能在本章提供的基础上建立,并为野外成功的机器学习项目做出贡献。

posted @   绝不原创的飞龙  阅读(214)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
点击右上角即可分享
微信分享提示