PySpark-秘籍-全-

PySpark 秘籍(全)

原文:zh.annas-archive.org/md5/226400CAE1A4CC3FBFCCD639AAB45F06

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Apache Spark 是一个开源框架,用于高效的集群计算,具有强大的数据并行性和容错性接口。本书提供了有效和节省时间的配方,利用 Python 的力量并将其应用于 Spark 生态系统。

您将首先了解 Apache Spark 的架构,并了解如何为 Spark 设置 Python 环境。然后,您将熟悉 PySpark 中可用的模块,并开始轻松使用它们。除此之外,您还将了解如何使用 RDDs 和 DataFrames 抽象数据,并了解 PySpark 的流处理能力。然后,您将继续使用 ML 和 MLlib 来解决与 PySpark 的机器学习能力相关的任何问题,并使用 GraphFrames 解决图处理问题。最后,您将探索如何使用 spark-submit 命令将应用程序部署到云中。

本书结束时,您将能够使用 Apache Spark 的 Python API 解决与构建数据密集型应用程序相关的任何问题。

本书的读者对象

如果您是一名 Python 开发人员,并且希望通过实践掌握 Apache Spark 2.x 生态系统的最佳使用方法,那么本书适合您。对 Python 的深入理解(以及对 Spark 的一些熟悉)将帮助您充分利用本书。

本书涵盖的内容

第一章,安装和配置 Spark,向我们展示了如何安装和配置 Spark,可以作为本地实例、多节点集群或虚拟环境。

第二章,使用 RDDs 抽象数据,介绍了如何使用 Apache Spark 的弹性分布式数据集(RDDs)。

第三章,使用 DataFrames 抽象数据,探讨了当前的基本数据结构 DataFrames。

第四章,为建模准备数据,介绍了如何清理数据并为建模做准备。

第五章,使用 MLlib 进行机器学习,介绍了如何使用 PySpark 的 MLlib 模块构建机器学习模型。

第六章,ML 模块的机器学习,介绍了 PySpark 当前支持的机器学习模块 ML 模块。

第七章,使用 PySpark 进行结构化流处理,介绍了如何在 PySpark 中使用 Apache Spark 结构化流处理。

第八章,GraphFrames - 使用 PySpark 进行图论,展示了如何使用 GraphFrames 处理 Apache Spark。

为了充分利用本书

您需要以下内容才能顺利完成各章内容:

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册,直接将文件发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/PySpark-Cookbook。如果代码有更新,将在现有的 GitHub 存储库中进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/PySparkCookbook_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"接下来,我们调用三个函数:printHeadercheckJavacheckPython。"

代码块设置如下:

if [ "${_check_R_req}" = "true" ]; then
 checkR
fi

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

 if [ "$_machine" = "Mac" ]; then
    curl -O $_spark_source
 elif [ "$_machine" = "Linux"]; then
    wget $_spark_source

任何命令行输入或输出均按以下格式编写:

tar -xvf sbt-1.0.4.tgz
sudo mv sbt-1.0.4/ /opt/scala/

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"转到文件 | 导入应用程序;单击路径选择旁边的按钮。"

警告或重要说明会以这种方式出现。

技巧和窍门会以这种方式出现。

章节

在本书中,您会经常看到几个标题(准备工作如何做...工作原理...还有更多...另请参阅)。

为了清晰地说明如何完成食谱,使用以下各节:

准备工作

本节告诉您食谱中可以期待什么,并描述如何设置食谱所需的任何软件或任何初步设置。

如何做...

本节包含了遵循食谱所需的步骤。

工作原理...

本节通常包括对前一节发生的事情的详细解释。

还有更多...

本节包括有关食谱的其他信息,以使您对食谱更加了解。

另请参阅

本节提供了有关食谱的其他有用信息的链接。

第一章:安装和配置 Spark

在本章中,我们将介绍如何安装和配置 Spark,无论是作为本地实例、多节点集群还是虚拟环境。您将学习以下示例:

  • 安装 Spark 要求

  • 从源代码安装 Spark

  • 从二进制文件安装 Spark

  • 配置 Spark 的本地实例

  • 配置 Spark 的多节点实例

  • 安装 Jupyter

  • 在 Jupyter 中配置会话

  • 使用 Cloudera Spark 镜像

介绍

我们不能在 Spark(或 PySpark)的书籍中开始之前,不先说明 Spark 是什么。Spark 是一个强大、灵活、开源的数据处理和查询引擎。它非常易于使用,并提供了解决各种问题的手段,从处理非结构化、半结构化或结构化数据,到流处理,再到机器学习。有来自 250 多个组织的 1000 多名贡献者(更不用说全球 3000 多名 Spark Meetup 社区成员),Spark 现在是 Apache 软件基金会组合中最大的开源项目之一。

Spark 的起源可以追溯到 2012 年,当时它首次发布;Matei Zacharia 在加州大学伯克利分校开发了 Spark 处理引擎的最初版本,作为他的博士论文的一部分。从那时起,Spark 变得非常流行,其流行程度源于许多原因:

  • 它是快速的:据估计,Spark 在纯内存工作时比 Hadoop 快 100 倍,在读取或写入数据到磁盘时大约快 10 倍。

  • 它是灵活的:您可以从多种编程语言中利用 Spark 的强大功能;Spark 原生支持 Scala、Java、Python 和 R 的接口。

  • 它是可扩展的:由于 Spark 是一个开源软件包,您可以通过引入自己的类或扩展现有类来轻松扩展它。

  • 它是强大的:许多机器学习算法已经在 Spark 中实现,因此您无需向堆栈添加更多工具——大多数数据工程和数据科学任务可以在单个环境中完成。

  • 它是熟悉的:习惯于使用 Python 的pandas、R 的data.framesdata.tables的数据科学家和数据工程师应该有一个更加温和的学习曲线(尽管这些数据类型之间存在差异)。此外,如果您了解 SQL,也可以在 Spark 中使用它来整理数据!

  • 它是可扩展的:Spark 可以在您的机器上本地运行(带有此类解决方案的所有限制)。但是,相同的代码可以在成千上万台机器的集群上部署,几乎不需要进行任何更改。

在本书的其余部分,我们将假设您正在类 Unix 环境中工作,如 Linux(在本书中,我们将使用 Ubuntu Server 16.04 LTS)或 macOS(运行 macOS High Sierra);所有提供的代码都在这两个环境中进行了测试。对于本章(以及其他一些章节),还需要互联网连接,因为我们将从互联网上下载一堆二进制文件和源文件。

我们不会专注于在 Windows 环境中安装 Spark,因为这并不是 Spark 开发人员真正支持的。但是,如果您有兴趣尝试,可以按照您在网上找到的一些说明,例如从以下链接:bit.ly/2Ar75ld

了解如何使用命令行以及如何在系统上设置一些环境变量是有用的,但并非真正必需——我们将指导您完成这些步骤。

安装 Spark 要求

在安装和使用 Spark 之前,您的机器需要具备一些环境。在这个示例中,我们将专注于准备您的机器以安装 Spark。

准备工作

要执行这个示例,您需要一个 bash 终端和一个互联网连接。

另外,在开始任何工作之前,您应该克隆本书的 GitHub 存储库。存储库包含了本书中所有示例的代码(以笔记本的形式)和所有所需的数据。要克隆存储库,请转到bit.ly/2ArlBck,单击“克隆或下载”按钮,并复制显示的 URL,方法是单击旁边的图标:

接下来,转到您的终端并发出以下命令:

git clone git@github.com:drabastomek/PySparkCookbook.git

如果您的git环境设置正确,整个 GitHub 存储库应该克隆到您的磁盘上。不需要其他先决条件。

操作步骤...

安装 PySpark 只需满足两个主要要求:Java 和 Python。此外,如果您想要使用这些语言,还可以安装 Scala 和 R,我们还将检查 Maven,我们将用它来编译 Spark 源代码。

为此,我们将使用checkRequirements.sh脚本来检查所有要求:该脚本位于 GitHub 存储库的Chapter01文件夹中。

以下代码块显示了在Chapter01/checkRequirements.sh文件中找到的脚本的高级部分。请注意,出于简洁起见,此处省略了部分代码:

#!/bin/bash

# Shell script for checking the dependencies 
#
# PySpark Cookbook
# Author: Tomasz Drabas, Denny Lee
# Version: 0.1
# Date: 12/2/2017

_java_required=1.8
_python_required=3.4
_r_required=3.1
_scala_required=2.11
_mvn_required=3.3.9

# parse command line arguments
_args_len="$#"
...

printHeader
checkJava
checkPython

if [ "${_check_R_req}" = "true" ]; then
 checkR
fi

if [ "${_check_Scala_req}" = "true" ]; then
 checkScala
fi

if [ "${_check_Maven_req}" = "true" ]; then
 checkMaven
fi

工作原理...

首先,我们将指定所有所需的软件包及其所需的最低版本;从前面的代码中可以看出,Spark 2.3.1 需要 Java 1.8+和 Python 3.4 或更高版本(我们将始终检查这两个环境)。此外,如果您想要使用 R 或 Scala,这两个软件包的最低要求分别为 3.1 和 2.11。如前所述,Maven 将用于编译 Spark 源代码,为此,Spark 至少需要 Maven 的 3.3.9 版本。

您可以在此处检查 Spark 的要求:spark.apache.org/docs/latest/index.html

您可以在此处检查构建 Spark 的要求:spark.apache.org/docs/latest/building-spark.html

接下来,我们解析命令行参数:

if [ "$_args_len" -ge 0 ]; then
  while [[ "$#" -gt 0 ]]
  do
   key="$1"
   case $key in
    -m|--Maven)
    _check_Maven_req="true"
    shift # past argument
    ;;
    -r|--R)
    _check_R_req="true"
    shift # past argument
    ;;
    -s|--Scala)
    _check_Scala_req="true"
    shift # past argument
    ;;
    *)
    shift # past argument
   esac
  done
fi

作为用户,您可以指定是否要额外检查 R、Scala 和 Maven 的依赖关系。要这样做,请从命令行运行以下代码(以下代码将检查所有这些):

./checkRequirements.sh -s -m -r

以下也是一个完全有效的用法:

./checkRequirements.sh --Scala --Maven --R

接下来,我们调用三个函数:printHeadercheckJavacheckPythonprintHeader函数只是脚本陈述其功能的一种简单方式,这并不是很有趣,所以我们将在这里跳过它;但它相当容易理解,所以您可以自行查看checkRequirements.sh脚本的相关部分。

接下来,我们将检查 Java 是否已安装。首先,我们只是在终端打印,说明我们正在对 Java 进行检查(这在我们所有的功能中都很常见,所以我们只在这里提一下):

function checkJava() {
 echo
 echo "##########################"
 echo
 echo "Checking Java"
 echo

接下来,我们将检查 Java 环境是否已安装在您的计算机上:

if type -p java; then
 echo "Java executable found in PATH"
 _java=java
elif [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
 echo "Found Java executable in JAVA_HOME"
 _java="$JAVA_HOME/bin/java"
else
 echo "No Java found. Install Java version $_java_required or higher first or specify JAVA_HOME variable that will point to your Java binaries."
 exit
fi

首先,我们使用type命令检查java命令是否可用;type -p命令返回java二进制文件的位置(如果存在)。这也意味着包含 Java 二进制文件的bin文件夹已添加到PATH中。

如果您确定已安装了二进制文件(无论是 Java、Python、R、Scala 还是 Maven),您可以跳转到本教程中的更新路径部分,了解如何让计算机知道这些二进制文件的位置。

如果这失败了,我们将回退到检查JAVA_HOME环境变量是否已设置,如果设置了,我们将尝试查看它是否包含所需的java二进制文件:[[ -x "$JAVA_HOME/bin/java" ]]。如果这失败,程序将打印找不到 Java 环境的消息,并退出(而不检查其他所需的软件包,如 Python)。

然而,如果找到了 Java 二进制文件,我们可以检查其版本:

_java_version=$("$_java" -version 2>&1 | awk -F '"' '/version/ {print $2}')
echo "Java version: $_java_version (min.: $_java_required)"

if [[ "$_java_version" < "$_java_required" ]]; then
 echo "Java version required is $_java_required. Install the required version first."
 exit
fi
 echo

我们首先在终端中执行java -version命令,这通常会产生类似以下截图的输出:

然后我们将先前的输出管道传输到awk,以在引号'"'字符处拆分(使用-F开关)行(并且只使用输出的第一行,因为我们将行过滤为包含/version/的行),并将第二个($2)元素作为我们机器上安装的 Java 二进制文件的版本。我们将把它存储在_java_version变量中,并使用echo命令将其打印到屏幕上。

如果您不知道awk是什么或如何使用它,我们建议阅读 Packt 的这本书:bit.ly/2BtTcBV

最后,我们检查我们刚刚获得的_java_version是否低于_java_required。如果这是真的,我们将停止执行,而是告诉您安装所需版本的 Java。

checkPythoncheckRcheckScalacheckMaven函数中实现的逻辑方式非常相似。唯一的区别在于我们调用的二进制文件以及我们检查版本的方式:

  • 对于 Python,我们运行"$_python" --version 2>&1 | awk -F ' ' '{print $2}',因为检查 Python 版本(对于 Anaconda 发行版)会将以下内容打印到屏幕上:Python 3.5.2 :: Anaconda 2.4.1 (x86_64)

  • 对于 R,我们使用"$_r" --version 2>&1 | awk -F ' ' '/R version/ {print $3}',因为检查 R 的版本会在屏幕上写入(很多);我们只使用以R version开头的行:R version 3.4.2 (2017-09-28) -- "Short Summer"

  • 对于 Scala,我们使用"$_scala" -version 2>&1 | awk -F ' ' '{print $5}',因为检查 Scala 的版本会打印以下内容:Scala 代码运行器版本 2.11.8 -- 版权所有 2002-2016,LAMP/EPFL

  • 对于 Maven,我们检查"$_mvn" --version 2>&1 | awk -F ' ' '/Apache Maven/ {print $3}',因为当要求其版本时,Maven 会打印以下内容(等等!):Apache Maven 3.5.2 (138edd61fd100ec658bfa2d307c43b76940a5d7d; 2017-10-18T00:58:13-07:00)

如果您想了解更多,现在应该能够轻松阅读其他函数。

还有更多...

如果您的任何依赖项尚未安装,您需要在继续下一个配方之前安装它们。本书的范围超出了逐步指导您完成所有这些安装过程的范围,但是以下是一些有用的链接,以指导您如何执行此操作。

安装 Java

安装 Java 非常简单。

在 macOS 上,转到www.java.com/en/download/mac_download.jsp并下载适合您系统的版本。下载后,请按照说明在您的机器上安装它。如果您需要更详细的说明,请查看此链接:bit.ly/2idEozX

在 Linux 上,检查以下链接bit.ly/2jGwuz1获取 Linux Java 安装说明。

安装 Python

我们一直在使用(并强烈推荐)Anaconda 版本的 Python,因为它包含了安装程序中包含的最常用的软件包。它还内置了conda软件包管理工具,使安装其他软件包变得轻而易举。

您可以从www.continuum.io/downloads下载 Anaconda;选择适合 Spark 要求的版本。有关 macOS 安装说明,您可以访问bit.ly/2zZPuUf,有关 Linux 安装手册,请访问bit.ly/2ASLUvg

安装 R

R 通过Comprehensive R Archive Network (CRAN)分发。macOS 版本可以从这里下载,cran.r-project.org/bin/macosx/,而 Linux 版本可以从这里下载:cran.r-project.org/bin/linux/

下载适合你的机器版本,并按照屏幕上的安装说明进行安装。对于 macOS 版本,你可以选择仅安装 R 核心包而不安装 GUI 和其他内容,因为 Spark 不需要这些。

安装 Scala

安装 Scala 甚至更简单。

前往bit.ly/2Am757R并下载sbt-*.*.*.tgz存档(在撰写本书时,最新版本是sbt-1.0.4.tgz)。接下来,在你的终端中,导航到你刚下载 Scala 的文件夹,并输入以下命令:

tar -xvf sbt-1.0.4.tgz
sudo mv sbt-1.0.4/ /opt/scala/

就是这样。现在,你可以跳到本教程中的更新 PATH部分来更新你的PATH

安装 Maven

Maven 的安装与 Scala 的安装非常相似。前往maven.apache.org/download.cgi并下载apache-maven-*.*.*-bin.tar.gz存档。在撰写本书时,最新版本是 3.5.2。与 Scala 类似,打开终端,导航到刚下载存档的文件夹,并键入:

tar -xvf apache-maven-3.5.2-bin.tar.gz
sudo mv apache-maven-3.5.2-bin/ /opt/apache-maven/

这就是你需要做的关于安装 Maven 的事情。查看下一小节,了解如何更新你的PATH

更新 PATH

类 Unix 操作系统(包括 Windows)使用PATH的概念来搜索二进制文件(或在 Windows 的情况下是可执行文件)。PATH只是一个由冒号字符':'分隔的文件夹列表,告诉操作系统在哪里查找二进制文件。

要将某些内容添加到你的PATH(并使其成为永久更改),你需要编辑.bash_profile(macOS)或.bashrc(Linux)文件;这些文件位于你的用户根文件夹中。因此,要将 Scala 和 Maven 二进制文件添加到 PATH,你可以执行以下操作(在 macOS 上):

cp ~/.bash_profile ~/.bash_profile_old   # make a copy just in case
echo export SCALA_HOME=/opt/scala >> ~/.bash_profile
echo export MAVEN_HOME=/opt/apache-maven >> ~/.bash_profile
echo PATH=$SCALA_HOME/bin:$MAVEN_HOME/bin:$PATH >> ~/.bash_profile

在 Linux 上,等价的代码如下:

cp ~/.bashrc ~/.bashrc_old   # make a copy just in case
echo export SCALA_HOME=/opt/scala >> ~/.bashrc
echo export MAVEN_HOME=/opt/apache-maven >> ~/.bashrc
echo PATH=$SCALA_HOME/bin:$MAVEN_HOME/bin:$PATH >> ~/.bashrc

上述命令只是使用重定向运算符>>将内容追加到.bash_profile.bashrc文件的末尾。

执行上述命令后,重新启动你的终端,并:

echo $PATH

现在应该包括 Scala 和 Maven 二进制文件的路径。

从源代码安装 Spark

Spark 以两种方式分发:作为预编译的二进制文件或作为源代码,让你可以选择是否需要支持 Hive 等。在这个教程中,我们将专注于后者。

准备工作

要执行这个教程,你需要一个 bash 终端和一个互联网连接。此外,为了完成这个教程,你必须已经检查和/或安装了我们在上一个教程中提到的所有必需的环境。此外,你需要有管理员权限(通过sudo命令),这将是将编译后的二进制文件移动到目标文件夹所必需的。

如果你不是机器上的管理员,可以使用-ns(或--nosudo)参数调用脚本。目标文件夹将切换到你的主目录,并在其中创建一个spark文件夹。默认情况下,二进制文件将移动到/opt/spark文件夹,这就是为什么你需要管理员权限的原因。

不需要其他先决条件。

如何做...

我们将执行五个主要步骤来从源代码安装 Spark(检查代码的突出部分):

  1. 从 Spark 的网站下载源代码

  2. 解压缩存档

  3. 构建

  4. 移动到最终目的地

  5. 创建必要的环境变量

我们的代码框架如下(参见Chapter01/installFromSource.sh文件):

#!/bin/bash
# Shell script for installing Spark from sources
#
# PySpark Cookbook
# Author: Tomasz Drabas, Denny Lee
# Version: 0.1
# Date: 12/2/2017
_spark_source="http://mirrors.ocf.berkeley.edu/apache/spark/spark-2.3.1/spark-2.3.1.tgz"
_spark_archive=$( echo "$_spark_source" | awk -F '/' '{print $NF}' )
_spark_dir=$( echo "${_spark_archive%.*}" )
_spark_destination="/opt/spark"
...
checkOS
printHeader
downloadThePackage
unpack
build
moveTheBinaries
setSparkEnvironmentVariables
cleanUp

它是如何工作的...

首先,我们指定 Spark 源代码的位置。_spark_archive包含存档的名称;我们使用awk_spark_source中提取最后一个元素(在这里,由$NF标志指定)。_spark_dir包含我们的存档将解压缩到的目录的名称;在我们当前的情况下,这将是spark-2.3.1。最后,我们指定我们将要移动二进制文件的目标文件夹:它要么是/opt/spark(默认值),要么是您的主目录,如果在调用./installFromSource.sh脚本时使用了-ns(或--nosudo)开关。

接下来,我们检查我们正在使用的操作系统名称:

function checkOS(){
 _uname_out="$(uname -s)"
 case "$_uname_out" in
   Linux*) _machine="Linux";;
   Darwin*) _machine="Mac";;
   *) _machine="UNKNOWN:${_uname_out}"
 esac
 if [ "$_machine" = "UNKNOWN:${_uname_out}" ]; then
   echo "Machine $_machine. Stopping."
   exit
 fi
}

首先,我们使用uname命令获取操作系统的简短名称;-s开关返回操作系统名称的缩写版本。如前所述,我们只关注两个操作系统:macOS 和 Linux,因此,如果您尝试在 Windows 或任何其他系统上运行此脚本,它将停止。代码的这一部分是必要的,以正确设置_machine标志:macOS 和 Linux 使用不同的方法来下载 Spark 源代码和不同的 bash 配置文件来设置环境变量。

接下来,我们打印出标题(我们将跳过此部分的代码,但欢迎您检查Chapter01/installFromSource.sh脚本)。在此之后,我们下载必要的源代码:

function downloadThePackage() {
 ...
 if [ -d _temp ]; then
    sudo rm -rf _temp
 fi
 mkdir _temp 
 cd _temp
 if [ "$_machine" = "Mac" ]; then
    curl -O $_spark_source
 elif [ "$_machine" = "Linux"]; then
    wget $_spark_source
 else
    echo "System: $_machine not supported."
    exit
 fi
}

首先,我们检查_temp文件夹是否存在,如果存在,则删除它。接下来,我们重新创建一个空的_temp文件夹,并将源代码下载到其中;在 macOS 上,我们使用curl方法,而在 Linux 上,我们使用wget来下载源代码。

你注意到我们的代码中的省略号'...'字符了吗?每当我们使用这样的字符时,我们省略了一些不太相关或纯粹信息性的代码部分。但是,它们仍然存在于 GitHub 存储库中检查的源代码中。

一旦源代码落在我们的机器上,我们就使用tar工具解压它们,tar -xf $_spark_archive。这发生在unpack函数内部。

最后,我们可以开始将源代码构建成二进制文件:

function build(){
 ...
 cd "$_spark_dir"
 ./dev/make-distribution.sh --name pyspark-cookbook -Phadoop-2.7 -Phive -Phive-thriftserver -Pyarn
}

我们使用make-distribution.sh脚本(与 Spark 一起分发)来创建我们自己的 Spark 分发,名为pyspark-cookbook。上一个命令将为 Hadoop 2.7 构建 Spark 分发,并支持 Hive。我们还可以在 YARN 上部署它。在幕后,make-distribution.sh脚本正在使用 Maven 来编译源代码。

编译完成后,我们需要将二进制文件移动到_spark_destination文件夹:

function moveTheBinaries() {
 ...
 if [ -d "$_spark_destination" ]; then 
    sudo rm -rf "$_spark_destination"
 fi
 cd ..
 sudo mv $_spark_dir/ $_spark_destination/
}

首先,我们检查目标文件夹中是否存在该文件夹,如果存在,我们将其删除。接下来,我们简单地将$_spark_dir文件夹移动到其新位置。

这是当您在调用installFromSource.sh脚本时没有使用-ns(或--nosudo)标志时,您将需要输入密码的时候。

最后一步之一是向您的 bash 配置文件添加新的环境变量:

function setSparkEnvironmentVariables() {
 ...
 if [ "$_machine" = "Mac" ]; then
    _bash=~/.bash_profile
 else
    _bash=~/.bashrc
 fi
 _today=$( date +%Y-%m-%d )
 # make a copy just in case 
 if ! [ -f "$_bash.spark_copy" ]; then
        cp "$_bash" "$_bash.spark_copy"
 fi
 echo >> $_bash 
 echo "###################################################" >> $_bash
 echo "# SPARK environment variables" >> $_bash
 echo "#" >> $_bash
 echo "# Script: installFromSource.sh" >> $_bash
 echo "# Added on: $_today" >>$_bash
 echo >> $_bash
 echo "export SPARK_HOME=$_spark_destination" >> $_bash
 echo "export PYSPARK_SUBMIT_ARGS=\"--master local[4]\"" >> $_bash
 echo "export PYSPARK_PYTHON=$(type -p python)" >> $_bash
 echo "export PYSPARK_DRIVER_PYTHON=jupyter" >> $_bash
 echo "export PYSPARK_DRIVER_PYTHON_OPTS=\"notebook --NotebookApp.open_browser=False --NotebookApp.port=6661\"" >> $_bash

 echo "export PATH=$SPARK_HOME/bin:\$PATH" >> $_bash
}

首先,我们检查我们所在的操作系统,并选择适当的 bash 配置文件。我们还获取当前日期(_today变量),以便在我们的 bash 配置文件中包含该信息,并创建其安全副本(以防万一,如果尚不存在)。接下来,我们开始向 bash 配置文件追加新行:

  • 我们首先将SPARK_HOME变量设置为_spark_destination;这要么是/opt/spark,要么是~/spark的位置。

  • 在调用pyspark时,PYSPARK_SUBMIT_ARGS变量用于指示 Spark 使用您 CPU 的四个核心;将其更改为--master local[*]将使用所有可用的核心。

  • 我们指定PYSPARK_PYTHON变量,以便在机器上存在多个 Python 安装时,pyspark将使用我们在第一个配方中检查的那个。

  • PYSPARK_DRIVER_PYTHON设置为jupyter将启动 Jupyter 会话(而不是 PySpark 交互式 shell)。

  • PYSPARK_DRIVER_PYTHON_OPS指示 Jupyter:

  • 开始一个笔记本

  • 不要默认打开浏览器:使用--NotebookApp.open_browser=False标志

  • 将默认端口(8888)更改为6661(因为我们非常喜欢出于安全原因而不使用默认设置)

最后,我们将SPARK_HOME中的bin文件夹添加到PATH中。

最后一步是在完成后进行cleanUp;我们只需删除_temp文件夹及其中的所有内容。

现在我们已经安装了 Spark,让我们测试一下是否一切正常。首先,为了使所有环境变量在终端会话中可访问,我们需要刷新bash会话:您可以关闭并重新打开终端,或者在 macOS 上执行以下命令:

source ~/.bash_profile

在 Linux 上,执行以下命令:

source ~/.bashrc

接下来,您应该能够执行以下操作:

pyspark --version

如果一切顺利,您应该看到类似于以下截图的响应:

还有更多...

不使用 Spark 的make-distribution.sh脚本,您可以直接使用 Maven 编译源代码。例如,如果您想构建 Spark 的默认版本,只需在_spark_dir文件夹中键入:

./build/mvn clean package

这将默认为 Hadoop 2.6。如果您的 Hadoop 版本为 2.7.2 并且已部署在 YARN 上,则可以执行以下操作:

./build/mvn -Pyarn -Phadoop-2.7 -Dhadoop.version=2.7.2 -DskipTests clean package

您还可以使用 Scala 构建 Spark:

./build/sbt package

另请参阅

从二进制文件安装 Spark

从预编译的二进制文件安装 Spark 甚至比从源代码进行相同操作更容易。在本教程中,我们将向您展示如何通过从网上下载二进制文件或使用pip来实现这一点。

准备工作

要执行此教程,您需要一个 bash 终端和互联网连接。此外,为了完成此教程,您需要已经检查和/或安装了我们在安装 Spark 要求教程中介绍的所有必需环境。此外,您需要具有管理权限(通过sudo命令),因为这将是将编译后的二进制文件移动到目标文件夹所必需的。

如果您不是计算机上的管理员,可以使用-ns(或--nosudo)参数调用脚本。目标文件夹将切换到您的主目录,并在其中创建一个spark文件夹;默认情况下,二进制文件将移动到/opt/spark文件夹,因此您需要管理权限。

不需要其他先决条件。

如何做...

要从二进制文件安装,我们只需要四个步骤(请参阅以下源代码),因为我们不需要编译源代码:

  1. 从 Spark 的网站下载预编译的二进制文件。

  2. 解压缩存档。

  3. 移动到最终目的地。

  4. 创建必要的环境变量。

我们的代码框架如下(请参阅Chapter01/installFromBinary.sh文件):

#!/bin/bash
# Shell script for installing Spark from binaries

#
# PySpark Cookbook
# Author: Tomasz Drabas, Denny Lee
# Version: 0.1
# Date: 12/2/2017
_spark_binary="http://mirrors.ocf.berkeley.edu/apache/spark/spark-2.3.1/spark-2.3.1-bin-hadoop2.7.tgz"
_spark_archive=$( echo "$_spark_binary" | awk -F '/' '{print $NF}' )
_spark_dir=$( echo "${_spark_archive%.*}" )
_spark_destination="/opt/spark"
...
checkOS
printHeader
downloadThePackage
unpack
moveTheBinaries
setSparkEnvironmentVariables
cleanUp

工作原理...

代码与上一个教程完全相同,因此我们不会在此重复;唯一的主要区别是在此脚本中我们没有build阶段,并且_spark_source变量不同。

与上一个教程一样,我们首先指定 Spark 源代码的位置,即_spark_source_spark_archive包含存档的名称;我们使用awk来提取最后一个元素。_spark_dir包含我们的存档将解压缩到的目录的名称;在我们当前的情况下,这将是spark-2.3.1。最后,我们指定我们将移动二进制文件的目标文件夹:它将是/opt/spark(默认)或者如果您在调用./installFromBinary.sh脚本时使用了-ns(或--nosudo)开关,则是您的主目录。

接下来,我们检查操作系统名称。根据您是在 Linux 还是 macOS 环境中工作,我们将使用不同的工具从互联网下载存档(检查downloadThePackage函数)。此外,在设置环境变量时,我们将输出到不同的 bash 配置文件:macOS 上的.bash_profile和 Linux 上的.bashrc(检查setEnvironmentVariables函数)。

在进行操作系统检查后,我们下载软件包:在 macOS 上,我们使用curl,在 Linux 上,我们使用wget工具来实现这个目标。软件包下载完成后,我们使用tar工具解压缩,然后将其移动到目标文件夹。如果您具有sudo权限(没有-ns--nosudo参数),则二进制文件将移动到/opt/spark文件夹;否则,它们将放在~/spark文件夹中。

最后,我们将环境变量添加到适当的 bash 配置文件中:查看前一个教程以了解添加的内容及原因。同时,按照前一个教程的步骤测试您的环境是否正常工作。

还有更多...

如今,在您的计算机上安装 PySpark 的方法更加简单,即使用 pip。

pip是 Python 的软件包管理器。如果您从python.org安装了 Python 2.7.9 或 Python 3.4,则pip已经存在于您的计算机上(我们推荐的 Python 发行版 Anaconda 也是如此)。如果您没有pip,可以从这里轻松安装它:pip.pypa.io/en/stable/installing/

要通过pip安装 PySpark,只需在终端中输入以下命令:

pip install pyspark

或者,如果您使用 Python 3.4+,也可以尝试:

pip3 install pyspark

您应该在终端中看到以下屏幕:

配置本地 Spark 实例

实际上,配置本地 Spark 实例并不需要做太多事情。Spark 的美妙之处在于,您只需要按照之前的两种方法之一(从源代码或二进制文件安装),就可以开始使用它。但是,在本教程中,我们将为您介绍最有用的SparkSession配置选项。

准备工作

为了按照本教程,需要一个可用的 Spark 环境。这意味着您必须已经完成了前三个教程,并成功安装和测试了您的环境,或者已经设置了一个可用的 Spark 环境。

不需要其他先决条件。

如何做...

要配置您的会话,在低于 2.0 版本的 Spark 版本中,通常需要创建一个SparkConf对象,将所有选项设置为正确的值,然后构建SparkContext(如果要使用DataFrames,则为SqlContext,如果要访问 Hive 表,则为HiveContext)。从 Spark 2.0 开始,您只需要创建一个SparkSession,就像以下代码片段中一样:

spark = SparkSession.builder \
    .master("local[2]") \
    .appName("Your-app-name") \
    .config("spark.some.config.option", "some-value") \
    .getOrCreate() 

工作原理...

要创建一个SparkSession,我们将使用Builder类(通过SparkSession类的.builder属性访问)。您可以在这里指定SparkSession的一些基本属性:

  • .master(...)允许您指定驱动节点(在我们之前的示例中,我们将使用两个核心运行本地会话)

  • .appName(...)允许您为您的应用程序指定友好的名称

  • .config(...)方法允许您进一步完善会话的行为;最重要的SparkSession参数列表在下表中概述

  • .getOrCreate()方法返回一个新的SparkSession(如果尚未创建),或者返回指向已经存在的SparkSession的指针

以下表格提供了本地 Spark 实例的最有用的配置参数示例列表:

如果您在具有多个工作节点的集群环境中工作,这些参数也适用。在下一个教程中,我们将解释如何设置和管理部署在 YARN 上的多节点 Spark 集群。

参数 功能 默认
spark.app.name 指定应用程序的友好名称 (无)
spark.driver.cores 驱动节点要使用的核心数。这仅适用于集群模式下的应用程序部署(参见下面的spark.submit.deployMode参数)。 1
spark.driver.memory 指定驱动程序进程的内存量。如果在客户端模式下使用spark-submit,您应该在命令行中使用--driver-memory开关来指定这个参数,而不是在配置会话时使用这个参数,因为 JVM 在这一点上已经启动了。 1g
spark.executor.cores 每个执行器要使用的核心数。在本地运行时设置此参数允许您使用机器上所有可用的核心。 YARN 部署中为 1,在独立和 Mesos 部署中为工作节点上的所有可用核心
spark.executor.memory 指定每个执行器进程的内存量。 1g
spark.submit.pyFiles 以逗号分隔的.zip.egg.py文件列表。这些文件将被添加到PYTHONPATH中,以便 Python 应用程序可以访问它们。 (无)
spark.submit.deployMode Spark 驱动程序程序的部署模式。指定'client'将在本地(可以是驱动节点)启动驱动程序程序,而指定'cluster'将利用远程集群上的一个节点。 (无)
spark.pyspark.python 驱动程序和所有执行器应该使用的 Python 二进制文件。 (无)

还有一些环境变量可以让您进一步微调您的 Spark 环境。具体来说,我们正在谈论PYSPARK_DRIVER_PYTHONPYSPARK_DRIVER_PYTHON_OPTS变量。我们已经在从源代码安装 Spark教程中介绍过这些内容。

参见

配置 Spark 的多节点实例

设置一个多节点 Spark 集群需要做更多的准备工作。在这个教程中,我们将逐步介绍一个脚本,该脚本将帮助您完成此过程;该脚本需要在驱动节点和所有执行器上运行以设置环境。

准备工作

在这个教程中,我们只关注 Linux 环境(我们使用的是 Ubuntu Server 16.04 LTS)。在您继续进行下一步之前,需要满足以下先决条件:

  • 干净安装 Linux 发行版;在我们的情况下,我们在我们的三台 Dell R710 机器上都安装了 Ubuntu Server 16.04 LTS。

  • 每台机器都需要连接到互联网,并且可以从本地机器访问。您需要机器的 IP 和主机名;在 Linux 上,您可以通过发出ifconfig命令并阅读inet addr来检查 IP。要检查您的主机名,请在cat/etc/hostname处输入。

  • 在每台服务器上,我们添加了一个名为hadoop的用户组。在此之后,我们创建了一个名为hduser的用户,并将其添加到hadoop组中。还要确保hduser具有sudo权限。如果您不知道如何做到这一点,请查看本教程的参见部分。

  • 确保您已经添加了通过 SSH 访问服务器的能力。如果无法做到这一点,请在每台服务器上运行sudo apt-get install openssh-server openssh-client来安装必要的环境。

  • 如果你想要读写 Hadoop 和 Hive,你需要在你的集群上安装和配置这两个环境。查看Hadoop 安装和配置Hive

如果你已经设置好了这两个环境,我们脚本中的一些步骤将变得多余。然而,我们将按照以下方式呈现所有步骤,假设你只需要 Spark 环境。

不需要其他先决条件。

为了自动部署集群设置中的 Spark 环境,你还需要:

  1. 创建一个hosts.txt文件。列表中的每个条目都是一个服务器的 IP 地址,后面跟着两个空格和一个主机名。不要删除driver:executors:。还要注意,我们的集群只允许一个 driver(一些集群支持冗余 driver)。这个文件的内容示例如下:
driver:
192.168.17.160  pathfinder
executors:
192.168.17.161  discovery1
192.168.17.162  discovery2
  1. 在你的本地机器上,将 IP 地址和主机名添加到你的/etc/hosts文件中,这样你就可以通过主机名而不是 IP 地址访问服务器(我们再次假设你正在运行类 Unix 系统,如 macOS 或 Linux)。例如,以下命令将在我们的/etc/hosts文件中添加pathfindersudo echo 192.168.1.160  pathfinder >> /etc/hosts。对你的服务器上的所有机器都重复这个步骤。

  2. hosts.txt文件复制到你集群中的每台机器上;我们假设文件将放在hduser的根文件夹中。你可以使用scp hosts.txt hduser@<your-server-name>:~命令轻松实现这一点,其中<your-server-name>是机器的主机名。

  3. 从你的本地机器运行installOnRemote.sh脚本(参见Chapter01/installOnRemote.sh文件),执行以下操作:ssh -tq hduser@<your-server-name> "echo $(base64 -i installOnRemote.sh) | base64 -d | sudo bash"。我们将在下一节详细介绍installOnRemote.sh脚本中的这些步骤。

  4. 按照屏幕上的提示完成安装和配置步骤。对于你集群中的每台机器都要重复第 4 步。

如何做...

本节的installOnRemote.sh脚本可以在 GitHub 存储库的Chapter01文件夹中找到:bit.ly/2ArlBck。脚本的一些部分与我们在之前的步骤中概述的部分非常相似,因此我们将跳过这些部分;你可以参考之前的步骤获取更多信息(特别是安装 Spark 要求从二进制文件安装 Spark的步骤)。

脚本的顶层结构如下:

#!/bin/bash
# Shell script for installing Spark from binaries
# on remote servers
#
# PySpark Cookbook
# Author: Tomasz Drabas, Denny Lee
# Version: 0.1
# Date: 12/9/2017
_spark_binary="http://mirrors.ocf.berkeley.edu/apache/spark/spark-2.3.1/spark-2.3.1-bin-hadoop2.7.tgz"
_spark_archive=$( echo "$_spark_binary" | awk -F '/' '{print $NF}' )
_spark_dir=$( echo "${_spark_archive%.*}" )
_spark_destination="/opt/spark"
_java_destination="/usr/lib/jvm/java-8-oracle"

_python_binary="https://repo.continuum.io/archive/Anaconda3-5.0.1-Linux-x86_64.sh"

_python_archive=$( echo "$_python_binary" | awk -F '/' '{print $NF}' )
_python_destination="/opt/python"
_machine=$(cat /etc/hostname)
_today=$( date +%Y-%m-%d )
_current_dir=$(pwd) # store current working directory
...
printHeader
readIPs
checkJava
installScala
installPython
updateHosts
configureSSH
downloadThePackage
unpack
moveTheBinaries
setSparkEnvironmentVariables
updateSparkConfig
cleanUp

我们已经用粗体字突出显示了与本节相关的脚本部分。

工作原理...

与之前的步骤一样,我们首先要指定从哪里下载 Spark 二进制文件,并创建我们稍后要使用的所有相关全局变量。

接下来,我们读取hosts.txt文件:

function readIPs() {
 input="./hosts.txt"
 driver=0
 executors=0
 _executors=""

 IFS=''
 while read line
 do
 if [[ "$master" = "1" ]]; then
    _driverNode="$line"
    driver=0
 fi
 if [[ "$slaves" = "1" ]]; then
   _executors=$_executors"$line\n"
 fi
 if [[ "$line" = "driver:" ]]; then
    driver=1
    executors=0
 fi
 if [[ "$line" = "executors:" ]]; then
    executors=1
    driver=0
 fi
 if [[ -z "${line}" ]]; then
     continue
 fi
 done < "$input"
}

我们将文件路径存储在input变量中。driverexecutors变量是我们用来跳过输入文件中的"driver:""executors:"行的标志。_executors空字符串将存储执行者的列表,这些列表由换行符"\n"分隔。

IFS代表内部字段分隔符。每当bash从文件中读取一行时,它将根据该字符进行分割。在这里,我们将其设置为空字符'',以便保留 IP 地址和主机名之间的双空格。

接下来,我们逐行读取文件。让我们看看循环内部的逻辑是如何工作的;我们将有点乱序开始,以便逻辑更容易理解:

  • 如果我们刚刚读取的line等于"driver:"if [[ "$line" = "driver:" ]];条件),我们将driver标志设置为1,这样当下一行被读取时,我们将其存储为_driverNode(这是在if [[ "$driver" = "1" ]];条件内完成的)。在该条件内,我们还将executors标志重置为0。后者是为了防止您首先启动执行程序,然后在hosts.txt中启动单个驱动程序。一旦读取了包含驱动程序节点信息的line,我们将driver标志重置为0

  • 另一方面,如果我们刚刚读取的line等于"executors:"if [[ "$line" = "executors:" ]];条件),我们将executors标志设置为1(并将driver标志重置为0)。这确保下一行将被附加到_executors字符串中,并用"\n"换行字符分隔(这是在if [[ "$executors" = "1" ]];条件内完成的)。请注意,我们不将executor标志设置为0,因为我们允许有多个执行程序。

  • 如果我们遇到空行(在 bash 中可以通过if [[ -z "${line}" ]];条件来检查),我们将跳过它。

您可能会注意到我们使用"<"重定向管道来读取数据(这里由输入变量表示)。

您可以在这里阅读更多关于重定向管道的信息:www.tldp.org/LDP/abs/html/io-redirection.html

由于 Spark 需要 Java 和 Scala 才能工作,接下来我们必须检查 Java 是否已安装,并安装 Scala(因为通常情况下 Java 可能已安装而 Scala 可能没有)。这是通过以下函数实现的:

function checkJava() {
 if type -p java; then
    echo "Java executable found in PATH"
    _java=java
 elif [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then
    echo "Found Java executable in JAVA_HOME"
    _java="$JAVA_HOME/bin/java"
 else
    echo "No Java found. Install Java version $_java_required or higher first or specify JAVA_HOME     variable that will point to your Java binaries."
    installJava
 fi
}
function installJava() {
 sudo apt-get install python-software-properties
 sudo add-apt-repository ppa:webupd8team/java
 sudo apt-get update
 sudo apt-get install oracle-java8-installer
}
function installScala() {
 sudo apt-get install scala
}

function installPython() {
 curl -O "$_python_binary"
 chmod 0755 ./"$_python_archive"
 sudo bash ./"$_python_archive" -b -u -p "$_python_destination"
}

这里的逻辑与我们在安装 Spark 要求配方中呈现的内容并没有太大的不同。checkJava函数中唯一显著的区别是,如果我们在PATH变量或JAVA_HOME文件夹中找不到 Java,我们不会退出,而是运行installJava

安装 Java 有很多种方法;我们在本书的早些时候已经向您介绍了其中一种方法——请查看安装 Spark 要求配方中的安装 Java部分。在这里,我们使用了内置的apt-get工具。

apt-get工具是在 Linux 机器上安装软件包的方便、快速和高效的实用程序。APT代表高级包装工具

首先,我们安装python-software-properties。这套工具提供了对使用的apt存储库的抽象。它使得易于管理发行版以及独立软件供应商的软件源。我们需要这个工具,因为在下一行我们要添加add-apt-repository;我们添加一个新的存储库,因为我们需要 Oracle Java 发行版。sudo apt-get update命令刷新存储库的内容,并在我们当前的情况下获取ppa:webupd8team/java中所有可用的软件包。最后,我们安装 Java 软件包:只需按照屏幕上的提示操作。我们将以同样的方式安装 Scala。

软件包应该安装的默认位置是/usr/lib/jvm/java-8-oracle。如果不是这种情况,或者您想要将其安装在不同的文件夹中,您将不得不修改脚本中的_java_destination变量以反映新的目的地。

使用这个工具的优势在于:如果机器上已经安装了 Java 和 Scala 环境,使用apt-get将要么跳过安装(如果环境与服务器上可用的环境保持最新),要么要求您更新到最新版本。

我们还将安装 Python 的 Anaconda 发行版(如之前多次提到的,因为我们强烈推荐这个发行版)。为了实现这个目标,我们必须首先下载Anaconda3-5.0.1-Linux-x86_64.sh脚本,然后按照屏幕上的提示进行操作。脚本的-b参数不会更新.bashrc文件(我们稍后会做),-u开关将更新 Python 环境(如果/usr/local/python已经存在),-p将强制安装到该文件夹。

在完成所需的安装步骤后,我们现在将更新远程机器上的/etc/hosts文件:

function updateHosts() {
 _hostsFile="/etc/hosts"
 # make a copy (if one already doesn't exist)
 if ! [ -f "/etc/hosts.old" ]; then
    sudo cp "$_hostsFile" /etc/hosts.old
 fi
 t="###################################################\n"
 t=$t"#\n"
 t=$t"# IPs of the Spark cluster machines\n"
 t=$t"#\n"
 t=$t"# Script: installOnRemote.sh\n"
 t=$t"# Added on: $_today\n"
 t=$t"#\n"
 t=$t"$_driverNode\n"
 t=$t"$_executors\n"
 sudo printf "$t" >> $_hostsFile
}

这是一个简单的函数,首先创建/etc/hosts文件的副本,然后将我们集群中机器的 IP 和主机名附加到其中。请注意,/etc/hosts文件所需的格式与我们使用的hosts.txt文件相同:每行一个机器的 IP 地址,后面跟着两个空格,然后是主机名。

我们为了可读性的目的使用两个空格——一个空格分隔 IP 和主机名也可以。

另外,请注意我们这里不使用echo命令,而是使用printf;这样做的原因是printf命令打印出字符串的格式化版本,正确处理换行符"\n"

接下来,我们配置无密码 SSH 会话(请查看下面的另请参阅子节)以帮助驱动节点和执行器之间的通信。

function configureSSH() {
    # check if driver node
    IFS=" "
    read -ra temp <<< "$_driverNode"
    _driver_machine=( ${temp[1]} )
    _all_machines="$_driver_machine\n"

    if [ "$_driver_machine" = "$_machine" ]; then
        # generate key pairs (passwordless)
        sudo -u hduser rm -f ~/.ssh/id_rsa
        sudo -u hduser ssh-keygen -t rsa -P "" -f ~/.ssh/id_rsa

        IFS="\n"
        read -ra temp <<< "$_executors"
        for executor in ${temp[@]}; do 
            # skip if empty line
            if [[ -z "${executor}" ]]; then
                continue
            fi

            # split on space
            IFS=" "
            read -ra temp_inner <<< "$executor"
            echo
            echo "Trying to connect to ${temp_inner[1]}"

            cat ~/.ssh/id_rsa.pub | ssh "hduser"@"${temp_inner[1]}" 'mkdir -p .ssh && cat >> .ssh/authorized_keys'

            _all_machines=$_all_machines"${temp_inner[1]}\n"
        done
    fi

    echo "Finishing up the SSH configuration"
}

在这个函数中,我们首先检查我们是否在驱动节点上,如hosts.txt文件中定义的那样,因为我们只需要在驱动节点上执行这些任务。read -ra temp <<< "$_driverNode"命令读取_driverNode(在我们的例子中,它是192.168.1.160  pathfinder),并在空格字符处拆分它(记住IFS代表什么?)。-a开关指示read方法将拆分的_driverNode字符串存储在temp数组中,-r参数确保反斜杠不起转义字符的作用。我们将驱动程序的名称存储在_driver_machine变量中,并将其附加到_all_machines字符串中(我们稍后会用到)。

如果我们在驱动机器上执行此脚本,我们首先必须删除旧的 SSH 密钥(使用rm函数和-f强制开关),然后创建一个新的。sudo -u hduser开关允许我们以hduser的身份执行这些操作(而不是root用户)。

当我们从本地机器提交脚本运行时,我们在远程机器上以 root 身份开始一个 SSH 会话。您很快就会看到这是如何做的,所以现在就相信我们的话吧。

我们将使用ssh-keygen方法创建 SSH 密钥对。-t开关允许我们选择加密算法(我们使用 RSA 加密),-P开关确定要使用的密码(我们希望无密码,所以选择""),-f参数指定存储密钥的文件名。

接下来,我们循环遍历所有的执行器:我们需要将~/.ssh/id_rsa.pub的内容附加到它们的~/.ssh/authorized_keys文件中。我们在"\n"字符处拆分_executors,并循环遍历所有的执行器。为了将id_rsa.pub文件的内容传递给执行器,我们使用cat工具打印id_rsa.pub文件的内容,然后将其传递给ssh工具。我们传递给ssh的第一个参数是我们要连接的用户名和主机名。接下来,我们传递我们要在远程机器上执行的命令。首先,我们尝试创建.ssh文件夹(如果不存在)。然后将id_rsa.pub文件输出到.ssh/authorized_keys

在集群上配置 SSH 会话后,我们下载 Spark 二进制文件,解压它们,并将它们移动到_spark_destination

我们已经在从源代码安装 Spark从二进制文件安装 Spark部分中概述了这些步骤,因此建议您查看它们。

最后,我们需要设置两个 Spark 配置文件:spark-env.shslaves文件。

function updateSparkConfig() {
    cd $_spark_destination/conf

    sudo -u hduser cp spark-env.sh.template spark-env.sh
    echo "export JAVA_HOME=$_java_destination" >> spark-env.sh
    echo "export SPARK_WORKER_CORES=12" >> spark-env.sh

    sudo -u hduser cp slaves.template slaves
    printf "$_all_machines" >> slaves
}

我们需要将JAVA_HOME变量附加到spark-env.sh中,以便 Spark 可以找到必要的库。我们还必须指定每个 worker 的核心数为12;这个目标是通过设置SPARK_WORKER_CORES变量来实现的。

您可能需要根据自己的需求调整SPARK_WORKER_CORES的值。查看此电子表格以获取帮助:c2fo.io/img/apache-spark-config-cheatsheet/C2FO-Spark-Config-Cheatsheet.xlsx(也可以从这里获取:c2fo.io/c2fo/spark/aws/emr/2016/07/06/apache-spark-config-cheatsheet/)。

接下来,我们需要将集群中所有机器的主机名输出到slaves文件中。

为了在远程机器上执行脚本,并且由于我们需要以提升的模式运行它(使用sudo作为root),我们需要在发送脚本之前对脚本进行加密。如下是如何完成这个过程的示例(从 macOS 到远程 Linux):

ssh -tq hduser@pathfinder "echo $(base64 -i installOnRemote.sh) | base64 -d | sudo bash"

或者从 Linux 到远程 Linux:

ssh -tq hduser@pathfinder "echo $(base64 -w0 installOnRemote.sh) | base64 -d | sudo bash"

在将其推送到远程之前,上述脚本使用base64加密工具对installOnRemote.sh脚本进行加密。一旦在远程上,我们再次使用base64来解密脚本(使用-d开关)并以root(通过sudo)运行它。请注意,为了运行这种类型的脚本,我们还向ssh工具传递了-tq开关;-t选项强制分配伪终端,以便我们可以在远程机器上执行任意基于屏幕的脚本,-q选项使所有消息都安静,除了我们的脚本消息。

假设一切顺利,一旦脚本在所有机器上执行完毕,Spark 就已经成功安装并配置在您的集群上。但是,在您可以使用 Spark 之前,您需要关闭与驱动程序的连接并再次 SSH 到它,或者输入:

source ~/.bashrc

这样新创建的环境变量才能可用,并且您的PATH被更新。

要启动您的集群,可以输入:

start-all.sh

集群中的所有机器应该开始运行并被 Spark 识别。

为了检查一切是否正常启动,输入:

jps

它应该返回类似于以下内容(在我们的情况下,我们的集群中有三台机器):

40334 Master
41297 Worker
41058 Worker

另请参阅

以下是一些有用的链接列表,可能会帮助您完成这个配方:

安装 Jupyter

Jupyter 提供了一种方便地与您的 Spark 环境合作的方式。在这个配方中,我们将指导您如何在本地机器上安装 Jupyter。

准备就绪

我们需要一个可用的 Spark 安装。这意味着您将按照第一个、第二或第三个配方中概述的步骤进行操作。此外,还需要一个可用的 Python 环境。

不需要其他先决条件。

如何做到...

如果您的机器上没有安装pip,您需要在继续之前安装它。

  1. 要做到这一点,请打开终端并键入(在 macOS 上):
curl -O https://bootstrap.pypa.io/get-pip.py

或者在 Linux 上:

wget https://bootstrap.pypa.io/get-pip.py
  1. 接下来,键入(适用于两种操作系统):
python get-pip.py

这将在您的计算机上安装pip

  1. 您现在只需使用以下命令安装 Jupyter:
pip install jupyter

它是如何工作的...

pip是一个用于在PyPIPython 软件包索引上安装 Python 软件包的管理工具。 该服务托管了各种 Python 软件包,并且是分发 Python 软件包的最简单和最快速的方式。

但是,调用pip install不仅会在 PyPI 上搜索软件包:此外,还会扫描 VCS 项目 URL、本地项目目录和本地或远程源存档。

Jupyter 是最受欢迎的交互式 shell 之一,支持在各种环境中开发代码:Python 不是唯一受支持的环境。

直接来自jupyter.org

“Jupyter Notebook 是一个开源的 Web 应用程序,允许您创建和共享包含实时代码、方程式、可视化和叙述文本的文档。用途包括:数据清理和转换、数值模拟、统计建模、数据可视化、机器学习等等。”

安装 Jupyter 的另一种方法,如果您正在使用 Anaconda Python 发行版,则是使用其软件包管理工具conda。 这是方法:

conda install jupyter

请注意,pip install在 Anaconda 中也可以工作。

还有更多...

现在您的计算机上有了 Jupyter,并且假设您已经按照从源代码安装 Spark从二进制文件安装 Spark教程的步骤进行操作,您应该能够开始使用 Jupyter 与 PySpark 进行交互。

为了提醒您,作为安装 Spark 脚本的一部分,我们已将两个环境变量附加到 bash 配置文件中:PYSPARK_DRIVER_PYTHONPYSPARK_DRIVER_PYTHON_OPTS。 使用这两个环境变量,我们将前者设置为使用jupyter,将后者设置为启动notebook服务。

如果您现在打开终端并键入:

pyspark

当您打开浏览器并导航到http://localhost:6661时,您应该看到一个与以下屏幕截图中的窗口并没有太大不同:

另请参阅

在 Jupyter 中配置会话

在 Jupyter 中工作非常棒,因为它允许您以交互方式开发代码,并与同事记录和共享笔记本。 但是,使用本地 Spark 实例运行 Jupyter 的问题在于SparkSession会自动创建,并且在笔记本运行时,您无法在该会话的配置中进行太多更改。

在这个教程中,我们将学习如何安装 Livy,这是一个与 Spark 交互的 REST 服务,以及sparkmagic,这是一个允许我们以交互方式配置会话的软件包:

来源:http://bit.ly/2iO3EwC

准备就绪

我们假设您已经通过二进制文件安装了 Spark,或者按照我们在之前的教程中向您展示的那样编译了源代码。 换句话说,到目前为止,您应该已经拥有一个可用的 Spark 环境。 您还需要 Jupyter:如果没有,请按照上一个教程中的步骤安装它。

不需要其他先决条件。

如何做...

要安装 Livy 和sparkmagic,我们已经创建了一个脚本,将自动执行此操作,您只需进行最少的交互。 您可以在Chapter01/installLivy.sh文件夹中找到它。 到目前为止,您应该已经熟悉我们将在此处使用的大多数功能,因此我们将仅关注不同的功能(在以下代码中以粗体显示)。 这是脚本结构的高级视图:

#!/bin/bash

# Shell script for installing Spark from binaries 
#
# PySpark Cookbook
# Author: Tomasz Drabas, Denny Lee
# Version: 0.1
# Date: 12/2/2017

_livy_binary="http://mirrors.ocf.berkeley.edu/apache/incubator/livy/0.4.0-incubating/livy-0.4.0-incubating-bin.zip"
_livy_archive=$( echo "$_livy_binary" | awk -F '/' '{print $NF}' )
_livy_dir=$( echo "${_livy_archive%.*}" )
_livy_destination="/opt/livy"
_hadoop_destination="/opt/hadoop"
...
checkOS
printHeader
createTempDir
downloadThePackage $( echo "${_livy_binary}" )
unpack $( echo "${_livy_archive}" )
moveTheBinaries $( echo "${_livy_dir}" ) $( echo "${_livy_destination}" ) 
# create log directory inside the folder
mkdir -p "$_livy_destination/logs"

checkHadoop
installJupyterKernels
setSparkEnvironmentVariables
cleanUp

它是如何工作的...

与我们迄今为止介绍的所有其他脚本一样,我们将首先设置一些全局变量。

如果您不知道这些是什么意思,请查看从源代码安装 Spark教程。

Livy 需要一些来自 Hadoop 的配置文件。因此,在此脚本的一部分中,我们允许您安装 Hadoop,如果您的计算机上没有安装 Hadoop。这就是为什么我们现在允许您向downloadThePackageunpackmoveTheBinaries函数传递参数。

对函数的更改相当容易理解,因此出于空间考虑,我们将不在此处粘贴代码。不过,您可以随时查看installLivy.sh脚本的相关部分。

安装 Livy 实际上是下载软件包,解压缩并将其移动到最终目的地(在我们的情况下,这是/opt/livy)。

检查 Hadoop 是否已安装是我们要做的下一件事。要在本地会话中运行 Livy,我们需要两个环境变量:SPARK_HOMEHADOOP_CONF_DIRSPARK_HOME肯定已设置,但如果您没有安装 Hadoop,您很可能不会设置后一个环境变量:

function checkHadoop() {
    if type -p hadoop; then
        echo "Hadoop executable found in PATH"
        _hadoop=hadoop
    elif [[ -n "$HADOOP_HOME" ]] && [[ -x "$HADOOP_HOME/bin/hadoop" ]]; then
        echo "Found Hadoop executable in HADOOP_HOME"
        _hadoop="$HADOOP_HOME/bin/hadoop"
    else
        echo "No Hadoop found. You should install Hadoop first. You can still continue but some functionality might not be available. "
        echo 
        echo -n "Do you want to install the latest version of Hadoop? [y/n]: "
        read _install_hadoop

        case "$_install_hadoop" in
            y*) installHadoop ;;
            n*) echo "Will not install Hadoop" ;;
            *)  echo "Will not install Hadoop" ;;
        esac
    fi
}

function installHadoop() {
    _hadoop_binary="http://mirrors.ocf.berkeley.edu/apache/hadoop/common/hadoop-2.9.0/hadoop-2.9.0.tar.gz"
    _hadoop_archive=$( echo "$_hadoop_binary" | awk -F '/' '{print $NF}' )
    _hadoop_dir=$( echo "${_hadoop_archive%.*}" )
    _hadoop_dir=$( echo "${_hadoop_dir%.*}" )

    downloadThePackage $( echo "${_hadoop_binary}" )

    unpack $( echo "${_hadoop_archive}" )
    moveTheBinaries $( echo "${_hadoop_dir}" ) $( echo "${_hadoop_destination}" )
}

checkHadoop函数首先检查PATH上是否存在hadoop二进制文件;如果没有,它将检查HADOOP_HOME变量是否已设置,如果设置了,它将检查$HADOOP_HOME/bin文件夹中是否可以找到hadoop二进制文件。如果两次尝试都失败,脚本将询问您是否要安装 Hadoop 的最新版本;默认答案是n,但如果您回答y,安装将开始。

安装完成后,我们将开始安装 Jupyter 笔记本的其他内核。

内核是一种软件,它将来自前端笔记本的命令转换为后端环境(如 Python)的命令。有关可用 Jupyter 内核的列表,请查看以下链接:github.com/jupyter/jupyter/wiki/Jupyter-kernels。以下是如何自己开发内核的一些说明:jupyter-client.readthedocs.io/en/latest/kernels.html

以下是处理内核安装的函数:

function installJupyterKernels() {
    # install the library 
    pip install sparkmagic
    echo

    # ipywidgets should work properly
    jupyter nbextension enable --py --sys-prefix widgetsnbextension 
    echo

    # install kernels
    # get the location of sparkmagic
    _sparkmagic_location=$(pip show sparkmagic | awk -F ':' '/Location/ {print $2}') 

    _temp_dir=$(pwd) # store current working directory

    cd $_sparkmagic_location # move to the sparkmagic folder
    jupyter-kernelspec install sparkmagic/kernels/sparkkernel
    jupyter-kernelspec install sparkmagic/kernels/pysparkkernel
    jupyter-kernelspec install sparkmagic/kernels/pyspark3kernel

    echo

    # enable the ability to change clusters programmatically
    jupyter serverextension enable --py sparkmagic
    echo

    # install autowizwidget
    pip install autovizwidget

    cd $_temp_dir
}

首先,我们为 Python 安装sparkmagic软件包。直接引用自github.com/jupyter-incubator/sparkmagic

“Sparkmagic 是一组工具,用于通过 Livy(Spark REST 服务器)在 Jupyter 笔记本中与远程 Spark 集群进行交互。Sparkmagic 项目包括一组魔术方法,用于以多种语言交互地运行 Spark 代码,以及一些内核,您可以使用这些内核将 Jupyter 转换为集成的 Spark 环境。”

以下命令启用 Jupyter 笔记本中的 Javascript 扩展,以便ipywidgets可以正常工作;如果您使用的是 Python 的 Anaconda 发行版,此软件包将自动安装。

接下来,我们安装内核。我们需要切换到sparkmagic安装的文件夹。pip show <package>命令显示有关安装软件包的所有相关信息;从输出中,我们只使用awk提取Location

安装内核时,我们使用jupyter-kernelspec install <kernel>命令。例如,该命令将为 Spark 的 Scala API 安装sparkmagic内核:

jupyter-kernelspec install sparkmagic/kernels/sparkkernel 

安装所有内核后,我们启用 Jupyter 使用sparkmagic,以便我们可以以编程方式更改集群。最后,我们将安装autovizwidget,这是一个用于pandas 数据框的自动可视化库。

这结束了 Livy 和sparkmagic的安装部分。

还有更多...

既然一切就绪,让我们看看这能做什么。

首先启动 Jupyter(请注意,我们不使用pyspark命令):

jupyter notebook

如果要添加新的笔记本,现在应该能够看到以下选项:

如果单击 PySpark,它将打开一个笔记本并连接到一个内核。

有许多可用的魔术与笔记本互动;键入%%help以列出所有魔术。以下是最重要的魔术列表:

魔术 示例 说明
info %%info 从 Livy 输出会话信息。
cleanup %%cleanup -f 删除当前 Livy 端点上运行的所有会话。-f开关强制清理。
delete %%delete -f -s 0 删除由-s开关指定的会话;-f开关强制删除。
configure %%configure -f``{"executorMemory": "1000M", "executorCores": 4} 可能是最有用的魔术。允许您配置会话。查看bit.ly/2kSKlXr获取可用配置参数的完整列表。
sql %%sql -o tables -q``SHOW TABLES 对当前的SparkSession执行 SQL 查询。
local %%local``a=1 笔记本单元格中带有此魔术的所有代码将在 Python 环境中本地执行。

一旦您配置了会话,您将从 Livy 那里得到有关当前正在运行的活动会话的信息:

让我们尝试使用以下代码创建一个简单的数据框架:

from pyspark.sql.types import *

# Generate our data 
ListRDD = sc.parallelize([
    (123, 'Skye', 19, 'brown'), 
    (223, 'Rachel', 22, 'green'), 
    (333, 'Albert', 23, 'blue')
])

# The schema is encoded using StructType 
schema = StructType([
    StructField("id", LongType(), True), 
    StructField("name", StringType(), True),
    StructField("age", LongType(), True),
    StructField("eyeColor", StringType(), True)
])

# Apply the schema to the RDD and create DataFrame
drivers = spark.createDataFrame(ListRDD, schema)

# Creates a temporary view using the data frame
drivers.createOrReplaceTempView("drivers")

在笔记本内的单元格中执行上述代码后,才会创建SparkSession

如果您执行%%sql魔术,您将得到以下内容:

另请参阅

使用 Cloudera Spark 镜像

Cloudera 是一家成立于 2008 年的公司,由 Google,Yahoo!,Oracle 和 Facebook 的前员工创立。当 Apache Hadoop 刚刚推出时,它就是开源技术的早期采用者;事实上,Hadoop 的作者本人随后不久就加入了该公司。如今,Cloudera 销售来自 Apache Software Foundation 的广泛的开源产品许可证,并提供咨询服务。

在本教程中,我们将查看 Cloudera 的免费虚拟镜像,以便学习如何使用该公司支持的最新技术。

准备工作

要完成本教程,您需要安装 Oracle 的免费虚拟化工具 VirtualBox。

以下是安装 VirtualBox 的说明:

在 Windows 上:www.htpcbeginner.com/install-virtualbox-on-windows/

在 Linux 上:www.packtpub.com/books/content/installing-virtualbox-linux 在 Mac 上:www.youtube.com/watch?v=lEvM-No4eQo

要运行 VM,您需要:

  • 64 位主机;Windows 10,macOS 和大多数 Linux 发行版都是 64 位系统

  • 至少需要 4GB 的 RAM 专用于 VM,因此需要至少 8GB 的 RAM 系统

无需其他先决条件。

操作步骤...

首先,要下载 Cloudera QuickStart VM:

  1. 访问www.cloudera.com/downloads/quickstart_vms/5-12.html

  2. 从右侧的下拉菜单中选择 VirtualBox 作为您的平台,然后单击立即获取按钮。

  3. 将显示一个注册窗口;根据需要填写并按照屏幕上的说明操作:

请注意,这是一个超过 6GB 的下载,所以可能需要一些时间。

  1. 下载后,打开 VirtualBox。

  2. 转到文件|导入虚拟机,单击路径选择旁边的按钮,并找到.ovf文件(它应该有一个.vmdk文件,适用于您刚下载的版本)。

在 macOS 上,图像在下载后会自动解压缩。在 Windows 和 Linux 上,您可能需要先解压缩存档文件。

您应该看到一个类似于这样的进度条:

导入后,您应该看到一个像这样的窗口:

  1. 如果现在点击“启动”,您应该会看到一个新窗口弹出,Cloudera VM(构建在 CentOS 上)应该开始启动。完成后,您的屏幕上应该会出现一个类似下面的窗口:

它是如何工作的...

实际上,没有太多需要配置的:Cloudera QuickStart VM 已经包含了您启动所需的一切。事实上,对于 Windows 用户来说,这比安装所有必要的环境要简单得多。然而,在撰写本书时,它只配备了 Spark 1.6.0:

然而,通过按照我们在本书中提出的从源代码安装 Spark从二进制文件安装 Spark的方法,您可以升级到 Spark 2.3.1。

第二章:使用 RDDs 抽象数据

在本章中,我们将介绍如何使用 Apache Spark 的弹性分布式数据集。您将学习以下示例:

  • 创建 RDDs

  • 从文件中读取数据

  • RDD 转换概述

  • RDD 操作概述

  • 使用 RDDs 的陷阱

介绍

弹性分布式数据集RDDs)是分布在 Apache Spark 集群中的不可变 JVM 对象的集合。请注意,如果您是 Apache Spark 的新手,您可能希望最初跳过本章,因为 Spark DataFrames/Datasets 在开发上更容易,并且通常具有更快的性能。有关 Spark DataFrames 的更多信息,请参阅下一章。

RDD 是 Apache Spark 最基本的数据集类型;对 Spark DataFrame 的任何操作最终都会被转换为对 RDD 的高度优化的转换和操作的执行(请参阅第三章中关于数据帧的抽象的段落,介绍部分)。

RDD 中的数据根据键分成块,然后分散到所有执行节点上。RDDs 具有很高的弹性,即相同的数据块被复制到多个执行节点上,因此即使一个执行节点失败,另一个仍然可以处理数据。这使您可以通过利用多个节点的能力快速对数据集执行功能计算。RDDs 保留了应用于每个块的所有执行步骤的日志。这加速了计算,并且如果出现问题,RDDs 仍然可以恢复由于执行器错误而丢失的数据部分。

在分布式环境中丢失节点是很常见的(例如,由于连接问题、硬件问题),数据的分发和复制可以防止数据丢失,而数据谱系允许系统快速恢复。

创建 RDDs

对于这个示例,我们将通过在 PySpark 中生成数据来开始创建 RDD。要在 Apache Spark 中创建 RDDs,您需要首先按照上一章中所示安装 Spark。您可以使用 PySpark shell 和/或 Jupyter 笔记本来运行这些代码示例。

准备工作

我们需要一个已安装的 Spark。这意味着您已经按照上一章中概述的步骤进行了操作。作为提醒,要为本地 Spark 集群启动 PySpark shell,您可以运行以下命令:

./bin/pyspark --master local[n]

其中n是核心数。

如何做...

要快速创建 RDD,请通过 bash 终端在您的机器上运行 PySpark,或者您可以在 Jupyter 笔记本中运行相同的查询。在 PySpark 中创建 RDD 有两种方法:您可以使用parallelize()方法-一个集合(一些元素的列表或数组)或引用一个文件(或文件),可以是本地的,也可以是通过外部来源,如后续的示例中所述。

myRDD) using the sc.parallelize() method:
myRDD = sc.parallelize([('Mike', 19), ('June', 18), ('Rachel',16), ('Rob', 18), ('Scott', 17)])

要查看 RDD 中的内容,您可以运行以下代码片段:

myRDD.take(5)

输出如下:

Out[10]: [('Mike', 19), ('June', 18), ('Rachel',16), ('Rob', 18), ('Scott', 17)]

工作原理...

sc.parallelize() and take().

Spark 上下文并行化方法

在创建 RDD 时,实际上发生了很多操作。让我们从 RDD 的创建开始,分解这段代码:

myRDD = sc.parallelize( 
 [('Mike', 19), ('June', 18), ('Rachel',16), ('Rob', 18), ('Scott', 17)]
)

首先关注sc.parallelize()方法中的语句,我们首先创建了一个 Python 列表(即[A, B, ..., E]),由数组列表组成(即('Mike', 19), ('June', 19), ..., ('Scott', 17))。sc.parallelize()方法是 SparkContext 的parallelize方法,用于创建并行化集合。这允许 Spark 将数据分布在多个节点上,而不是依赖单个节点来处理数据:

现在我们已经创建了myRDD作为并行化集合,Spark 可以并行操作这些数据。一旦创建,分布式数据集(distData)可以并行操作。例如,我们可以调用myRDD.reduceByKey(add)来对列表的按键进行求和;我们在本章的后续部分中有 RDD 操作的示例。

.take(...) 方法

现在您已经创建了您的 RDD(myRDD),我们将使用take()方法将值返回到控制台(或笔记本单元格)。我们现在将执行一个 RDD 操作(有关此操作的更多信息,请参见后续示例),take()。请注意,PySpark 中的一种常见方法是使用collect(),它将从 Spark 工作节点将所有值返回到驱动程序。在处理大量数据时会有性能影响,因为这意味着大量数据从 Spark 工作节点传输到驱动程序。对于小量数据(例如本示例),这是完全可以的,但是,习惯上,您应该几乎总是使用take(n)方法;它返回 RDD 的前n个元素而不是整个数据集。这是一种更有效的方法,因为它首先扫描一个分区,并使用这些统计信息来确定返回结果所需的分区数。

从文件中读取数据

在本示例中,我们将通过在 PySpark 中读取本地文件来创建一个 RDD。要在 Apache Spark 中创建 RDDs,您需要首先按照上一章中的说明安装 Spark。您可以使用 PySpark shell 和/或 Jupyter 笔记本来运行这些代码示例。请注意,虽然本示例特定于读取本地文件,但类似的语法也适用于 Hadoop、AWS S3、Azure WASBs 和/或 Google Cloud Storage。

存储类型 示例
本地文件 sc.textFile('/local folder/filename.csv')
Hadoop HDFS sc.textFile('hdfs://folder/filename.csv')
AWS S3 (docs.aws.amazon.com/emr/latest/ReleaseGuide/emr-spark-configure.html) sc.textFile('s3://bucket/folder/filename.csv')
Azure WASBs (docs.microsoft.com/en-us/azure/hdinsight/hdinsight-hadoop-use-blob-storage) sc.textFile('wasb://bucket/folder/filename.csv')
Google Cloud Storage (cloud.google.com/dataproc/docs/concepts/connectors/cloud-storage#other_sparkhadoop_clusters) sc.textFile('gs://bucket/folder/filename.csv')
Databricks DBFS (docs.databricks.com/user-guide/dbfs-databricks-file-system.html) sc.textFile('dbfs://folder/filename.csv')

准备就绪

在这个示例中,我们将读取一个制表符分隔(或逗号分隔)的文件,所以请确保您有一个文本(或 CSV)文件可用。为了您的方便,您可以从github.com/drabastomek/learningPySpark/tree/master/Chapter03/flight-data下载airport-codes-na.txtdeparturedelays.csv文件。确保您的本地 Spark 集群可以访问此文件(例如,~/data/flights/airport-codes-na.txt)。

如何做...

通过 bash 终端启动 PySpark shell 后(或者您可以在 Jupyter 笔记本中运行相同的查询),执行以下查询:

myRDD = (
    sc
    .textFile(
        '~/data/flights/airport-codes-na.txt'
        , minPartitions=4
        , use_unicode=True
    ).map(lambda element: element.split("\t"))
)

如果您正在运行 Databricks,同样的文件已经包含在/databricks-datasets文件夹中;命令是:

myRDD = sc.textFile('/databricks-datasets/flights/airport-codes-na.txt').map(lambda element: element.split("\t"))

运行查询时:

myRDD.take(5)

结果输出为:

Out[22]:  [[u'City', u'State', u'Country', u'IATA'], [u'Abbotsford', u'BC', u'Canada', u'YXX'], [u'Aberdeen', u'SD', u'USA', u'ABR'], [u'Abilene', u'TX', u'USA', u'ABI'], [u'Akron', u'OH', u'USA', u'CAK']]

深入一点,让我们确定这个 RDD 中的行数。请注意,有关 RDD 操作(如count())的更多信息包含在后续的示例中:

myRDD.count()

# Output
# Out[37]: 527

另外,让我们找出支持此 RDD 的分区数:

myRDD.getNumPartitions()

# Output
# Out[33]: 4

工作原理...

take can be broken down into its two components: sc.textFile() and map().

.textFile(...)方法

要读取文件,我们使用 SparkContext 的textFile()方法通过这个命令:

(
    sc
    .textFile(
        '~/data/flights/airport-codes-na.txt'
        , minPartitions=4
        , use_unicode=True
    )
)

只有第一个参数是必需的,它指示文本文件的位置为~/data/flights/airport-codes-na.txt。还有两个可选参数:

  • minPartitions:指示组成 RDD 的最小分区数。Spark 引擎通常可以根据文件大小确定最佳分区数,但出于性能原因,您可能希望更改分区数,因此可以指定最小数量。

  • use_unicode:如果处理 Unicode 数据,请使用此参数。

请注意,如果您执行此语句而没有后续的map()函数,生成的 RDD 将不引用制表符分隔符——基本上是一个字符串列表:

myRDD = sc.textFile('~/data/flights/airport-codes-na.txt')
myRDD.take(5)

# Out[35]:  [u'City\tState\tCountry\tIATA', u'Abbotsford\tBC\tCanada\tYXX', u'Aberdeen\tSD\tUSA\tABR', u'Abilene\tTX\tUSA\tABI', u'Akron\tOH\tUSA\tCAK']

.map(...)方法

为了理解 RDD 中的制表符,我们将使用.map(...)函数将数据从字符串列表转换为列表列表:

myRDD = (
    sc
    .textFile('~/data/flights/airport-codes-na.txt')
    .map(lambda element: element.split("\t")) )

此映射转换的关键组件是:

  • lambda:一个匿名函数(即,没有名称定义的函数),由一个单一表达式组成

  • split:我们使用 PySpark 的 split 函数(在pyspark.sql.functions中)来围绕正则表达式模式分割字符串;在这种情况下,我们的分隔符是制表符(即\t

sc.textFile()map()函数放在一起,可以让我们读取文本文件,并按制表符分割,生成由并行化列表集合组成的 RDD:

Out[22]:  [[u'City', u'State', u'Country', u'IATA'], [u'Abbotsford', u'BC', u'Canada', u'YXX'], [u'Aberdeen', u'SD', u'USA', u'ABR'], [u'Abilene', u'TX', u'USA', u'ABI'], [u'Akron', u'OH', u'USA', u'CAK']]

分区和性能

在这个示例中,如果我们在没有为这个数据集指定minPartitions的情况下运行sc.textFile(),我们只会有两个分区:

myRDD = (
    sc
    .textFile('/databricks-datasets/flights/airport-codes-na.txt')
    .map(lambda element: element.split("\t"))
)

myRDD.getNumPartitions()

# Output
Out[2]: 2

但是请注意,如果指定了minPartitions标志,那么您将获得指定的四个分区(或更多):

myRDD = (
    sc
    .textFile(
        '/databricks-datasets/flights/airport-codes-na.txt'
        , minPartitions=4
    ).map(lambda element: element.split("\t"))
)

myRDD.getNumPartitions()

# Output
Out[6]: 4

对于 RDD 的分区的一个关键方面是,分区越多,并行性越高。潜在地,有更多的分区将提高您的查询性能。在这部分示例中,让我们使用一个稍大一点的文件,departuredelays.csv

# Read the `departuredelays.csv` file and count number of rows
myRDD = (
    sc
    .textFile('/data/flights/departuredelays.csv')
    .map(lambda element: element.split(","))
)

myRDD.count()

# Output Duration: 3.33s
Out[17]: 1391579

# Get the number of partitions
myRDD.getNumPartitions()

# Output:
Out[20]: 2

如前面的代码片段所述,默认情况下,Spark 将创建两个分区,并且在我的小集群上花费 3.33 秒(在出发延误 CSV 文件中计算 139 万行)。

执行相同的命令,但同时指定minPartitions(在这种情况下,为八个分区),您会注意到count()方法在 2.96 秒内完成(而不是使用八个分区的 3.33 秒)。请注意,这些值可能根据您的机器配置而有所不同,但关键是修改分区的数量可能会由于并行化而导致更快的性能。查看以下代码:

# Read the `departuredelays.csv` file and count number of rows
myRDD = (
    sc
    .textFile('/data/flights/departuredelays.csv', minPartitions=8)
    .map(lambda element: element.split(","))
)

myRDD.count()

# Output Duration: 2.96s
Out[17]: 1391579

# Get the number of partitions
myRDD.getNumPartitions()

# Output:
Out[20]: 8

RDD 转换概述

如前面的部分所述,RDD 中可以使用两种类型的操作来塑造数据:转换和操作。转换,顾名思义,一个 RDD 转换为另一个。换句话说,它接受一个现有的 RDD,并将其转换为一个或多个输出 RDD。在前面的示例中,我们使用了map()函数,这是一个通过制表符分割数据的转换的示例。

转换是懒惰的(不像操作)。它们只有在 RDD 上调用操作时才会执行。例如,调用count()函数是一个操作;有关操作的更多信息,请参阅下一节。

准备工作

这个食谱将阅读一个制表符分隔(或逗号分隔)的文件,请确保您有一个文本(或 CSV)文件可用。 为了您的方便,您可以从github.com/drabastomek/learningPySpark/tree/master/Chapter03/flight-data下载airport-codes-na.txtdeparturedelays.csv文件。确保您的本地 Spark 集群可以访问此文件(例如,~/data/flights/airport-codes-na.txt)。

如果您正在运行 Databricks,同样的文件已经包含在/databricks-datasets文件夹中;命令是

myRDD = sc.textFile('/databricks-datasets/flights/airport-codes-na.txt').map(lambda line: line.split("\t"))

下一节中的许多转换将使用 RDDs airportsflights;让我们使用以下代码片段设置它们:

# Setup the RDD: airports
airports = (
    sc
    .textFile('~/data/flights/airport-codes-na.txt')
    .map(lambda element: element.split("\t"))
)

airports.take(5)

# Output
Out[11]:  
[[u'City', u'State', u'Country', u'IATA'], 
 [u'Abbotsford', u'BC', u'Canada', u'YXX'], 
 [u'Aberdeen', u'SD', u'USA', u'ABR'], 
 [u'Abilene', u'TX', u'USA', u'ABI'], 
 [u'Akron', u'OH', u'USA', u'CAK']]

# Setup the RDD: flights
flights = (
    sc
    .textFile('/databricks-datasets/flights/departuredelays.csv')
    .map(lambda element: element.split(","))
)

flights.take(5)

# Output
[[u'date', u'delay', u'distance', u'origin', u'destination'],  
 [u'01011245', u'6', u'602', u'ABE', u'ATL'],  
 [u'01020600', u'-8', u'369', u'ABE', u'DTW'],  
 [u'01021245', u'-2', u'602', u'ABE', u'ATL'],  
 [u'01020605', u'-4', u'602', u'ABE', u'ATL']]

如何做...

在本节中,我们列出了常见的 Apache Spark RDD 转换和代码片段。更完整的列表可以在spark.apache.org/docs/latest/rdd-programming-guide.html#transformationsspark.apache.org/docs/latest/api/python/pyspark.html#pyspark.RDDtraining.databricks.com/visualapi.pdf找到。

转换包括以下常见任务:

  • 从文本文件中删除标题行:zipWithIndex()

  • 从 RDD 中选择列:map()

  • 运行WHERE(过滤器)子句:filter()

  • 获取不同的值:distinct()

  • 获取分区的数量:getNumPartitions()

  • 确定分区的大小(即每个分区中的元素数量):mapPartitionsWithIndex()

.map(...)转换

map(f)转换通过将每个元素传递给函数f来返回一个新的 RDD。

查看以下代码片段:

# Use map() to extract out the first two columns
airports.map(lambda c: (c[0], c[1])).take(5)

这将产生以下输出:

# Output
[(u'City', u'State'),  
 (u'Abbotsford', u'BC'),  
 (u'Aberdeen', u'SD'),

 (u'Abilene', u'TX'),  
 (u'Akron', u'OH')]

.filter(...)转换

filter(f)转换根据f函数返回 true 的选择元素返回一个新的 RDD。因此,查看以下代码片段:

# User filter() to filter where second column == "WA"
(
    airports
    .map(lambda c: (c[0], c[1]))
    .filter(lambda c: c[1] == "WA")
    .take(5)
)

这将产生以下输出:

# Output
[(u'Bellingham', u'WA'),
 (u'Moses Lake', u'WA'),  
 (u'Pasco', u'WA'),  
 (u'Pullman', u'WA'),  
 (u'Seattle', u'WA')]

.flatMap(...)转换

flatMap(f)转换类似于 map,但新的 RDD 会展平所有元素(即一系列事件)。让我们看一下以下片段:

# Filter only second column == "WA", 
# select first two columns within the RDD,
# and flatten out all values
(
    airports
    .filter(lambda c: c[1] == "WA")
    .map(lambda c: (c[0], c[1]))
    .flatMap(lambda x: x)
    .take(10)
)

上述代码将产生以下输出:

# Output
[u'Bellingham',  
 u'WA',  
 u'Moses Lake',  
 u'WA',  
 u'Pasco',  
 u'WA',  
 u'Pullman',  
 u'WA',  
 u'Seattle',  
 u'WA']

.distinct()转换

distinct()转换返回包含源 RDD 的不同元素的新 RDD。因此,查看以下代码片段:

# Provide the distinct elements for the 
# third column of airports representing
# countries
(
    airports
    .map(lambda c: c[2])
    .distinct()
    .take(5)
)

这将返回以下输出:

# Output
[u'Canada', u'USA', u'Country']    

.sample(...)转换

sample(withReplacement, fraction, seed)转换根据随机种子从数据中抽取一部分数据,可以选择是否有放回(withReplacement参数)。

查看以下代码片段:

# Provide a sample based on 0.001% the
# flights RDD data specific to the fourth
# column (origin city of flight)
# without replacement (False) using random
# seed of 123 
(
    flights
    .map(lambda c: c[3])
    .sample(False, 0.001, 123)
    .take(5)
)

我们可以期待以下结果:

# Output
[u'ABQ', u'AEX', u'AGS', u'ANC', u'ATL'] 

.join(...)转换

join(RDD')转换在调用 RDD (key, val_left)和 RDD (key, val_right)时返回一个(key, (val_left, val_right))的 RDD。左外连接、右外连接和完全外连接都是支持的。

查看以下代码片段:

# Flights data
#  e.g. (u'JFK', u'01010900')
flt = flights.map(lambda c: (c[3], c[0]))

# Airports data
# e.g. (u'JFK', u'NY')
air = airports.map(lambda c: (c[3], c[1]))

# Execute inner join between RDDs
flt.join(air).take(5)

这将给出以下结果:

# Output
[(u'JFK', (u'01010900', u'NY')),  
 (u'JFK', (u'01011200', u'NY')),  
 (u'JFK', (u'01011900', u'NY')),  
 (u'JFK', (u'01011700', u'NY')),  
 (u'JFK', (u'01010800', u'NY'))]

.repartition(...)转换

repartition(n)转换通过随机重分区和均匀分布数据来将 RDD 重新分区为n个分区。正如前面的食谱中所述,这可以通过同时运行更多并行线程来提高性能。以下是一个精确执行此操作的代码片段:

# The flights RDD originally generated has 2 partitions 
flights.getNumPartitions()

# Output
2 

# Let's re-partition this to 8 so we can have 8 
# partitions
flights2 = flights.repartition(8)

# Checking the number of partitions for the flights2 RDD
flights2.getNumPartitions()

# Output
8

.zipWithIndex()转换

zipWithIndex()转换会将 RDD 附加(或 ZIP)到元素索引上。当想要删除文件的标题行(第一行)时,这非常方便。

查看以下代码片段:

# View each row within RDD + the index 
# i.e. output is in form ([row], idx)
ac = airports.map(lambda c: (c[0], c[3]))
ac.zipWithIndex().take(5)

这将生成这个结果:

# Output
[((u'City', u'IATA'), 0),  
 ((u'Abbotsford', u'YXX'), 1),  
 ((u'Aberdeen', u'ABR'), 2),  
 ((u'Abilene', u'ABI'), 3),  
 ((u'Akron', u'CAK'), 4)]

要从数据中删除标题,您可以使用以下代码:

# Using zipWithIndex to skip header row
# - filter out row 0
# - extract only row info
(
    ac
    .zipWithIndex()
    .filter(lambda (row, idx): idx > 0)
    .map(lambda (row, idx): row)
    .take(5)
)

前面的代码将跳过标题,如下所示:

# Output
[(u'Abbotsford', u'YXX'),  
 (u'Aberdeen', u'ABR'),  
 (u'Abilene', u'ABI'),  
 (u'Akron', u'CAK'),  
 (u'Alamosa', u'ALS')]

.reduceByKey(...) 转换

reduceByKey(f) 转换使用f按键减少 RDD 的元素。f函数应该是可交换和可结合的,这样它才能在并行计算中正确计算。

看下面的代码片段:

# Determine delays by originating city
# - remove header row via zipWithIndex() 
#   and map() 
(
    flights
    .zipWithIndex()
    .filter(lambda (row, idx): idx > 0)
    .map(lambda (row, idx): row)
    .map(lambda c: (c[3], int(c[1])))
    .reduceByKey(lambda x, y: x + y)
    .take(5)
)

这将生成以下输出:

# Output
[(u'JFK', 387929),  
 (u'MIA', 169373),  
 (u'LIH', -646),  
 (u'LIT', 34489),  
 (u'RDM', 3445)]

.sortByKey(...) 转换

sortByKey(asc) 转换按key(key, value) RDD 进行排序,并以升序或降序返回一个 RDD。看下面的代码片段:

# Takes the origin code and delays, remove header
# runs a group by origin code via reduceByKey()
# sorting by the key (origin code)
(
    flights
    .zipWithIndex()
    .filter(lambda (row, idx): idx > 0)
    .map(lambda (row, idx): row)
    .map(lambda c: (c[3], int(c[1])))
    .reduceByKey(lambda x, y: x + y)
    .sortByKey()
    .take(50)
)

这将产生以下输出:

# Output
[(u'ABE', 5113),  
 (u'ABI', 5128),  
 (u'ABQ', 64422),  
 (u'ABY', 1554),  
 (u'ACT', 392),
 ...]

.union(...) 转换

union(RDD) 转换返回一个新的 RDD,该 RDD 是源 RDD 和参数 RDD 的并集。看下面的代码片段:

# Create `a` RDD of Washington airports
a = (
    airports
    .zipWithIndex()
    .filter(lambda (row, idx): idx > 0)
    .map(lambda (row, idx): row)
    .filter(lambda c: c[1] == "WA")
)

# Create `b` RDD of British Columbia airports
b = (
    airports
    .zipWithIndex()
    .filter(lambda (row, idx): idx > 0)
    .map(lambda (row, idx): row)
    .filter(lambda c: c[1] == "BC")
)

# Union WA and BC airports
a.union(b).collect()

这将生成以下输出:

# Output
[[u'Bellingham', u'WA', u'USA', u'BLI'],
 [u'Moses Lake', u'WA', u'USA', u'MWH'],
 [u'Pasco', u'WA', u'USA', u'PSC'],
 [u'Pullman', u'WA', u'USA', u'PUW'],
 [u'Seattle', u'WA', u'USA', u'SEA'],
...
 [u'Vancouver', u'BC', u'Canada', u'YVR'],
 [u'Victoria', u'BC', u'Canada', u'YYJ'], 
 [u'Williams Lake', u'BC', u'Canada', u'YWL']]

.mapPartitionsWithIndex(...) 转换

mapPartitionsWithIndex(f) 类似于 map,但在每个分区上单独运行f函数,并提供分区的索引。它有助于确定分区内的数据倾斜(请查看以下代码片段):

# Source: https://stackoverflow.com/a/38957067/1100699
def partitionElementCount(idx, iterator):
  count = 0
  for _ in iterator:
    count += 1
  return idx, count

# Use mapPartitionsWithIndex to determine 
flights.mapPartitionsWithIndex(partitionElementCount).collect()

前面的代码将产生以下结果:

# Output
[0,  
 174293,  
 1,  
 174020,  
 2,  
 173849,  
 3,  
 174006,  
 4,  
 173864,  
 5,  
 174308,  
 6,  
 173620,  
 7,  
 173618]

工作原理...

回想一下,转换会将现有的 RDD 转换为一个或多个输出 RDD。它也是一个懒惰的过程,直到执行一个动作才会启动。在下面的连接示例中,动作是take()函数:

# Flights data
#  e.g. (u'JFK', u'01010900')
flt = flights.map(lambda c: (c[3], c[0]))

# Airports data
# e.g. (u'JFK', u'NY')
air = airports.map(lambda c: (c[3], c[1]))

# Execute inner join between RDDs
flt.join(air).take(5)

# Output
[(u'JFK', (u'01010900', u'NY')),  
 (u'JFK', (u'01011200', u'NY')),  
 (u'JFK', (u'01011900', u'NY')),  
 (u'JFK', (u'01011700', u'NY')),  
 (u'JFK', (u'01010800', u'NY'))]

为了更好地理解运行此连接时发生了什么,让我们回顾一下 Spark UI。每个 Spark 会话都会启动一个基于 Web 的 UI,默认情况下在端口4040上,例如http://localhost:4040。它包括以下信息:

  • 调度器阶段和任务列表

  • RDD 大小和内存使用情况摘要

  • 环境信息

  • 有关正在运行的执行器的信息

有关更多信息,请参阅 Apache Spark 监控文档页面spark.apache.org/docs/latest/monitoring.html

要深入了解 Spark 内部工作,一个很好的视频是 Patrick Wendell 的Apache Spark 调优和调试视频,可在www.youtube.com/watch?v=kkOG_aJ9KjQ上找到。

如下 DAG 可视化所示,连接语句和两个前面的map转换都有一个作业(作业 24),创建了两个阶段(第 32 阶段和第 33 阶段):

作业 24 的详细信息

让我们深入了解这两个阶段:

第 32 阶段的详细信息

为了更好地理解第一阶段(第 32 阶段)中执行的任务,我们可以深入了解阶段的 DAG 可视化以及事件时间线:

  • 两个textFile调用是为了提取两个不同的文件(departuredelays.csvairport-codes-na.txt

  • 一旦map函数完成,为了支持join,Spark 执行UnionRDDPairwiseRDD来执行连接背后的基本操作作为union任务的一部分

在下一个阶段,partitionBymapPartitions任务在通过take()函数提供输出之前重新洗牌和重新映射分区:

第 33 阶段的详细信息

请注意,如果您执行相同的语句而没有take()函数(或其他动作),只有转换操作将被执行,而在 Spark UI 中没有显示懒惰处理。

例如,如果您执行以下代码片段,请注意输出是指向 Python RDD 的指针:

# Same join statement as above but no action operation such as take()
flt = flights.map(lambda c: (c[3], c[0]))
air = airports.map(lambda c: (c[3], c[1]))
flt.join(air)

# Output
Out[32]: PythonRDD[101] at RDD at PythonRDD.scala:50

RDD 动作概述

如前面的部分所述,Apache Spark RDD 操作有两种类型:转换和动作。动作在数据集上运行计算后将一个值返回给驱动程序,通常在工作节点上。在前面的示例中,take()count() RDD 操作是动作的示例。

准备就绪

这个示例将读取一个制表符分隔(或逗号分隔)的文件,请确保您有一个文本(或 CSV)文件可用。为了您的方便,您可以从bit.ly/2nroHbh下载airport-codes-na.txtdeparturedelays.csv文件。确保您的本地 Spark 集群可以访问此文件(~/data/flights/airport-codes-na.txt)。

如果您正在运行 Databricks,则相同的文件已经包含在/databricks-datasets文件夹中;命令是

myRDD = sc.textFile('/databricks-datasets/flights/airport-codes-na.txt').map(lambda line: line.split("\t"))

下一节中的许多转换将使用 RDDs airportsflights;让我们通过以下代码片段来设置它们:

# Setup the RDD: airports
airports = (
    sc
    .textFile('~/data/flights/airport-codes-na.txt')
    .map(lambda element: element.split("\t"))
)

airports.take(5)

# Output
Out[11]:  
[[u'City', u'State', u'Country', u'IATA'], 
 [u'Abbotsford', u'BC', u'Canada', u'YXX'], 
 [u'Aberdeen', u'SD', u'USA', u'ABR'], 
 [u'Abilene', u'TX', u'USA', u'ABI'], 
 [u'Akron', u'OH', u'USA', u'CAK']]

# Setup the RDD: flights
flights = (
    sc
    .textFile('~/data/flights/departuredelays.csv', minPartitions=8)
    .map(lambda line: line.split(","))
)

flights.take(5)

# Output
[[u'date', u'delay', u'distance', u'origin', u'destination'],  
 [u'01011245', u'6', u'602', u'ABE', u'ATL'],  
 [u'01020600', u'-8', u'369', u'ABE', u'DTW'],  
 [u'01021245', u'-2', u'602', u'ABE', u'ATL'],  
 [u'01020605', u'-4', u'602', u'ABE', u'ATL']]

如何做...

以下列表概述了常见的 Apache Spark RDD 转换和代码片段。更完整的列表可以在 Apache Spark 文档的 RDD 编程指南 | 转换中找到,网址为spark.apache.org/docs/latest/rdd-programming-guide.html#transformations,PySpark RDD API 网址为spark.apache.org/docs/latest/api/python/pyspark.html#pyspark.RDD,以及 Essential Core and Intermediate Spark Operations 网址为training.databricks.com/visualapi.pdf

.take(...) 操作

我们已经讨论过这个问题,但为了完整起见,take(*n*)操作将返回一个包含 RDD 的前n个元素的数组。看一下以下代码:

# Print to console the first 3 elements of
# the airports RDD
airports.take(3)

这将生成以下输出:

# Output
[[u'City', u'State', u'Country', u'IATA'], 
 [u'Abbotsford', u'BC', u'Canada', u'YXX'], 
 [u'Aberdeen', u'SD', u'USA', u'ABR']]

.collect() 操作

我们还警告您不要使用此操作;collect()将所有元素从工作节点返回到驱动程序。因此,看一下以下代码:

# Return all airports elements
# filtered by WA state
airports.filter(lambda c: c[1] == "WA").collect()

这将生成以下输出:

# Output
[[u'Bellingham', u'WA', u'USA', u'BLI'],  [u'Moses Lake', u'WA', u'USA', u'MWH'],  [u'Pasco', u'WA', u'USA', u'PSC'],  [u'Pullman', u'WA', u'USA', u'PUW'],  [u'Seattle', u'WA', u'USA', u'SEA'],  [u'Spokane', u'WA', u'USA', u'GEG'],  [u'Walla Walla', u'WA', u'USA', u'ALW'],  [u'Wenatchee', u'WA', u'USA', u'EAT'],  [u'Yakima', u'WA', u'USA', u'YKM']]

.reduce(...) 操作

reduce(f) 操作通过f聚合 RDD 的元素。f函数应该是可交换和可结合的,以便可以正确并行计算。看一下以下代码:

# Calculate the total delays of flights
# between SEA (origin) and SFO (dest),
# convert delays column to int 
# and summarize
flights\
 .filter(lambda c: c[3] == 'SEA' and c[4] == 'SFO')\
 .map(lambda c: int(c[1]))\
 .reduce(lambda x, y: x + y)

这将产生以下结果:

# Output
22293

然而,我们需要在这里做出重要的说明。使用reduce()时,缩减器函数需要是可结合和可交换的;也就是说,元素和操作数的顺序变化不会改变结果。

结合规则:(6 + 3) + 4 = 6 + (3 + 4) 交换规则: 6 + 3 + 4 = 4 + 3 + 6

如果忽略上述规则,可能会出现错误。

例如,看一下以下 RDD(只有一个分区!):

data_reduce = sc.parallelize([1, 2, .5, .1, 5, .2], 1)

将数据减少到将当前结果除以后续结果,我们期望得到一个值为 10:

works = data_reduce.reduce(lambda x, y: x / y)

将数据分区为三个分区将产生不正确的结果:

data_reduce = sc.parallelize([1, 2, .5, .1, 5, .2], 3) data_reduce.reduce(lambda x, y: x / y)

它将产生0.004

.count() 操作

count()操作返回 RDD 中元素的数量。请参阅以下代码:

(
    flights
    .zipWithIndex()
    .filter(lambda (row, idx): idx > 0)
    .map(lambda (row, idx): row)
    .count()
)

这将产生以下结果:

# Output
1391578

.saveAsTextFile(...) 操作

saveAsTextFile()操作将 RDD 保存到文本文件中;请注意,每个分区都是一个单独的文件。请参阅以下代码片段:

# Saves airports as a text file
#   Note, each partition has their own file

# saveAsTextFile
airports.saveAsTextFile("/tmp/denny/airports")

这实际上将保存以下文件:

# Review file structure
# Note that `airports` is a folder with two
# files (part-zzzzz) as the airports RDD is 
# comprised of two partitions.
/tmp/denny/airports/_SUCCESS
/tmp/denny/airports/part-00000
/tmp/denny/airports/part-00001

工作原理...

请记住,操作在对数据集进行计算后将值返回给驱动程序,通常在工作节点上。一些 Spark 操作的示例包括count()take();在本节中,我们将重点关注reduceByKey()

# Determine delays by originating city
# - remove header row via zipWithIndex() 
#   and map() 
flights.zipWithIndex()\
  .filter(lambda (row, idx): idx > 0)\
  .map(lambda (row, idx): row)\
  .map(lambda c: (c[3], int(c[1])))\
  .reduceByKey(lambda x, y: x + y)\
  .take(5)

# Output
[(u'JFK', 387929),  
 (u'MIA', 169373),  
 (u'LIH', -646),  
 (u'LIT', 34489),  
 (u'RDM', 3445)]

为了更好地理解运行此连接时发生了什么,让我们来看一下 Spark UI。每个 Spark 会话都会启动一个基于 Web 的 UI,默认情况下在端口4040上,例如http://localhost:4040。它包括以下信息:

  • 调度器阶段和任务列表

  • RDD 大小和内存使用情况摘要

  • 环境信息

  • 有关正在运行的执行器的信息

有关更多信息,请参阅 Apache Spark 监控文档页面spark.apache.org/docs/latest/monitoring.html

要深入了解 Spark 内部工作,可以观看 Patrick Wendell 的调整和调试 Apache Spark视频,网址为www.youtube.com/watch?v=kkOG_aJ9KjQ

reduceByKey() action is called; note that Job 14 represents only the reduceByKey() of part the DAG. A previous job had executed and returned the results based on the zipWithIndex() transformation, which is not included in Job 14:

进一步深入研究构成每个阶段的任务,注意到大部分工作是在Stage 18中完成的。注意到有八个并行任务最终处理数据,从文件(/tmp/data/departuredelays.csv)中提取数据到并行执行reduceByKey()

第 18 阶段的详细信息

以下是一些重要的要点:

  • Spark 的reduceByKey(f)假设f函数是可交换和可结合的,以便可以正确地并行计算。如 Spark UI 中所示,所有八个任务都在并行处理数据提取(sc.textFile)和reduceByKey(),提供更快的性能。

  • 如本教程的Getting ready部分所述,我们执行了sc.textFile($fileLocation, minPartitions=8)..。这迫使 RDD 有八个分区(至少有八个分区),这意味着会有八个任务并行执行:

现在您已经执行了reduceByKey(),我们将运行take(5),这将执行另一个阶段,将来自工作节点的八个分区洗牌到单个驱动节点;这样,数据就可以被收集起来在控制台中查看。

使用 RDD 的缺陷

使用 RDD 的关键问题是可能需要很长时间才能掌握。运行诸如 map、reduce 和 shuffle 等功能操作符的灵活性使您能够对数据执行各种各样的转换。但是,这种强大的功能也伴随着巨大的责任,有可能编写效率低下的代码,比如使用GroupByKey;更多信息可以在databricks.gitbooks.io/databricks-spark-knowledge-base/content/best_practices/prefer_reducebykey_over_groupbykey.html中找到。

通常情况下,与 Spark DataFrames 相比,使用 RDDs 通常会有较慢的性能,如下图所示:

来源:在 Apache Spark 中引入数据框架进行大规模数据科学,网址为 https://databricks.com/blog/2015/02/17/introducing-dataframes-in-spark-for-large-scale-data-science.html

还要注意的是,使用 Apache Spark 2.0+,数据集具有功能操作符(给您类似于 RDD 的灵活性),同时还利用了 catalyst 优化器,提供更快的性能。有关数据集的更多信息将在下一章中讨论。

RDD 之所以慢——特别是在 PySpark 的上下文中——是因为每当使用 RDDs 执行 PySpark 程序时,执行作业可能会产生很大的开销。如下图所示,在 PySpark 驱动程序中,Spark Context使用Py4j启动一个使用JavaSparkContext的 JVM。任何 RDD 转换最初都会在 Java 中映射到PythonRDD对象。

一旦这些任务被推送到 Spark worker(s),PythonRDD对象就会启动 Python subprocesses,使用管道发送代码和数据以在 Python 中进行处理:

虽然这种方法允许 PySpark 将数据处理分布到多个 Python subprocesses上的多个工作节点,但正如您所看到的,Python 和 JVM 之间存在大量的上下文切换和通信开销。

关于 PySpark 性能的一个很好的资源是 Holden Karau 的Improving PySpark Performance: Spark Performance Beyond the JVM,网址为bit.ly/2bx89bn

当使用 Python UDF 时,这一点更加明显,因为性能明显较慢,因为所有数据都需要在使用 Python UDF 之前传输到驱动程序。请注意,向量化 UDF 是作为 Spark 2.3 的一部分引入的,并将改进 PySpark UDF 的性能。有关更多信息,请参阅databricks.com/blog/2017/10/30/introducing-vectorized-udfs-for-pyspark.html上的Introducing Vectorized UDFs for PySpark

准备工作

与以前的部分一样,让我们利用flights数据集并针对该数据集创建一个 RDD 和一个 DataFrame:

## Create flights RDD
flights = sc.textFile('/databricks-datasets/flights/departuredelays.csv')\
  .map(lambda line: line.split(","))\
  .zipWithIndex()\
  .filter(lambda (row, idx): idx > 0)\
  .map(lambda (row, idx): row)

# Create flightsDF DataFrame
flightsDF = spark.read\
  .options(header='true', inferSchema='true')
  .csv('~/data/flights/departuredelays.csv')
flightsDF.createOrReplaceTempView("flightsDF")

如何做...

在本节中,我们将运行相同的group by语句——一个是通过使用reduceByKey()的 RDD,另一个是通过使用 Spark SQL GROUP BY的 DataFrame。对于这个查询,我们将按出发城市对延迟时间进行求和,并根据出发城市进行排序:

# RDD: Sum delays, group by and order by originating city
flights.map(lambda c: (c[3], int(c[1]))).reduceByKey(lambda x, y: x + y).sortByKey().take(50)

# Output (truncated)
# Duration: 11.08 seconds
[(u'ABE', 5113),  
 (u'ABI', 5128),  
 (u'ABQ', 64422),  
 (u'ABY', 1554),  
 (u'ACT', 392),
 ... ]

对于这种特定配置,提取列,执行reduceByKey()对数据进行汇总,执行sortByKey()对其进行排序,然后将值返回到驱动程序共需 11.08 秒:

# RDD: Sum delays, group by and order by originating city
spark.sql("select origin, sum(delay) as TotalDelay from flightsDF group by origin order by origin").show(50)

# Output (truncated)
# Duration: 4.76s
+------+----------+ 
|origin|TotalDelay| 
+------+----------+ 
| ABE  |      5113| 
| ABI  |      5128|
| ABQ  |     64422| 
| ABY  |      1554| 
| ACT  |       392|
...
+------+----------+ 

Spark DataFrames 有许多优点,包括但不限于以下内容:

  • 您可以执行 Spark SQL 语句(不仅仅是通过 Spark DataFrame API)

  • 与位置相比,您的数据有一个关联的模式,因此您可以指定列名

  • 在这种配置和示例中,查询完成时间为 4.76 秒,而 RDD 完成时间为 11.08 秒

在最初加载数据以增加分区数时,通过在sc.textFile()中指定minPartitions来改进 RDD 查询是不可能的:

flights = sc.textFile('/databricks-datasets/flights/departuredelays.csv', minPartitions=8), ...

flights = sc.textFile('/databricks-datasets/flights/departuredelays.csv', minPartitions=8), ...

对于这种配置,相同的查询返回时间为 6.63 秒。虽然这种方法更快,但仍然比 DataFrames 慢;一般来说,DataFrames 在默认配置下更快。

它是如何工作的...

为了更好地了解以前的 RDD 和 DataFrame 的性能,让我们返回到 Spark UI。首先,当我们运行flights RDD 查询时,将执行三个单独的作业,如在以下截图中在 Databricks Community Edition 中可以看到的那样:

每个作业都会生成自己的一组阶段,最初读取文本(或 CSV)文件,执行reduceByKey(),并执行sortByKey()函数:

还有两个额外的作业来完成sortByKey()的执行:

可以看到,通过直接使用 RDD,可能会产生大量的开销,生成多个作业和阶段来完成单个查询。

对于 Spark DataFrames,在这个查询中,它更简单,它由一个包含两个阶段的单个作业组成。请注意,Spark UI 有许多特定于 DataFrame 的任务,如WholeStageCodegenExchange,它们显著改进了 Spark 数据集和 DataFrame 查询的性能。有关 Spark SQL 引擎催化剂优化器的更多信息可以在下一章中找到:

第三章:使用 DataFrame 抽象数据

在本章中,您将学习以下示例:

  • 创建 DataFrame

  • 访问底层 RDD

  • 性能优化

  • 使用反射推断模式

  • 以编程方式指定模式

  • 创建临时表

  • 使用 SQL 与 DataFrame 交互

  • DataFrame 转换概述

  • DataFrame 操作概述

介绍

在本章中,我们将探索当前的基本数据结构——DataFrame。DataFrame 利用了钨项目和 Catalyst Optimizer 的发展。这两个改进使 PySpark 的性能与 Scala 或 Java 的性能相媲美。

Project tungsten 是针对 Spark 引擎的一系列改进,旨在将其执行过程更接近于裸金属。主要成果包括:

  • 在运行时生成代码:这旨在利用现代编译器中实现的优化

  • 利用内存层次结构:算法和数据结构利用内存层次结构进行快速执行

  • 直接内存管理:消除了与 Java 垃圾收集和 JVM 对象创建和管理相关的开销

  • 低级编程:通过将立即数据加载到 CPU 寄存器中加快内存访问

  • 虚拟函数调度消除:这消除了多个 CPU 调用的必要性

查看 Databricks 的博客以获取更多信息:www.databricks.com/blog/2015/04/28/project-tungsten-bringing-spark-closer-to-bare-metal.html

Catalyst Optimizer 位于 Spark SQL 的核心,并驱动对数据和 DataFrame 执行的 SQL 查询。该过程始于向引擎发出查询。首先优化执行的逻辑计划。基于优化的逻辑计划,派生多个物理计划并通过成本优化器推送。然后选择最具成本效益的计划,并将其转换(使用作为钨项目的一部分实施的代码生成优化)为优化的基于 RDD 的执行代码。

创建 DataFrame

Spark DataFrame 是在集群中分布的不可变数据集合。DataFrame 中的数据组织成命名列,可以与关系数据库中的表进行比较。

在这个示例中,我们将学习如何创建 Spark DataFrame。

准备工作

要执行此示例,您需要一个可用的 Spark 2.3 环境。如果没有,请返回第一章,安装和配置 Spark,并按照那里找到的示例进行操作。

您在本章中需要的所有代码都可以在我们为本书设置的 GitHub 存储库中找到:bit.ly/2ArlBck;转到第三章并打开3. 使用 DataFrame 抽象数据.ipynb笔记本。

没有其他要求。

如何做...

有许多创建 DataFrame 的方法,但最简单的方法是创建一个 RDD 并将其转换为 DataFrame:

sample_data = sc.parallelize([
      (1, 'MacBook Pro', 2015, '15"', '16GB', '512GB SSD'
        , 13.75, 9.48, 0.61, 4.02)
    , (2, 'MacBook', 2016, '12"', '8GB', '256GB SSD'
        , 11.04, 7.74, 0.52, 2.03)
    , (3, 'MacBook Air', 2016, '13.3"', '8GB', '128GB SSD'
        , 12.8, 8.94, 0.68, 2.96)
    , (4, 'iMac', 2017, '27"', '64GB', '1TB SSD'
        , 25.6, 8.0, 20.3, 20.8)
])

sample_data_df = spark.createDataFrame(
    sample_data
    , [
        'Id'
        , 'Model'
        , 'Year'
        , 'ScreenSize'
        , 'RAM'
        , 'HDD'
        , 'W'
        , 'D'
        , 'H'
        , 'Weight'
    ]
)

它是如何工作的...

如果您已经阅读了上一章,您可能已经知道如何创建 RDD。在这个示例中,我们只需调用sc.parallelize(...)方法。

我们的示例数据集只包含了一些相对较新的苹果电脑的记录。然而,与所有 RDD 一样,很难弄清楚元组的每个元素代表什么,因为 RDD 是无模式的结构。

因此,当使用SparkSession.createDataFrame(...)方法时,我们将列名列表作为第二个参数传递;第一个参数是我们希望转换为 DataFrame 的 RDD。

现在,如果我们使用sample_data.take(1)来查看sample_data RDD 的内容,我们将检索到第一条记录:

要比较 DataFrame 的内容,我们可以运行sample_data_df.take(1)来获取以下内容:

现在您可以看到,DataFrame 是Row(...)对象的集合。Row(...)对象由命名的数据组成,与 RDD 不同。

如果前面的Row(...)对象对您来说看起来类似于字典,那么您是正确的。任何Row(...)对象都可以使用.asDict(...)方法转换为字典。有关更多信息,请查看spark.apache.org/docs/latest/api/python/pyspark.sql.html#pyspark.sql.Row

然而,如果我们要查看sample_data_df DataFrame 中的数据,使用.show(...)方法,我们会看到以下内容:

由于 DataFrames 具有模式,让我们使用.printSchema()方法查看我们的sample_data_df的模式:

正如您所看到的,我们 DataFrame 中的列具有与原始sample_data RDD 的数据类型匹配的数据类型。

尽管 Python 不是一种强类型语言,但 PySpark 中的 DataFrames 是。与 RDD 不同,DataFrame 列的每个元素都有指定的类型(这些都列在pyspark.sql.types子模块中),并且所有数据必须符合指定的模式。

更多信息...

当您使用SparkSession.read属性时,它会返回一个DataFrameReader对象。DataFrameReader是一个用于将数据读入 DataFrame 的接口。

从 JSON

要从 JSON 格式文件中读取数据,您只需执行以下操作:

sample_data_json_df = (
    spark
    .read
    .json('../Data/DataFrames_sample.json')
)

从 JSON 格式文件中读取数据的唯一缺点(尽管是一个小缺点)是所有列将按字母顺序排序。通过运行sample_data_json_df.show()来自己看看:

但数据类型保持不变:sample_data_json_df.printSchema()

从 CSV

从 CSV 文件中读取同样简单:

sample_data_csv = (
    spark
    .read
    .csv(
        '../Data/DataFrames_sample.csv'
        , header=True
        , inferSchema=True)
)

传递的唯一附加参数确保该方法将第一行视为列名(header参数),并且它将尝试根据内容为每列分配正确的数据类型(inferSchema参数默认分配字符串)。

与从 JSON 格式文件中读取数据不同,从 CSV 文件中读取可以保留列的顺序。

另请参阅

访问底层 RDD

切换到使用 DataFrames 并不意味着我们需要完全放弃 RDD。在底层,DataFrames 仍然使用 RDD,但是Row(...)对象,如前所述。在本示例中,我们将学习如何与 DataFrame 的底层 RDD 交互。

准备工作

要执行此示例,您需要一个可用的 Spark 2.3 环境。此外,您应该已经完成了上一个示例,因为我们将重用我们在那里创建的数据。

没有其他要求。

如何做...

在这个示例中,我们将把 HDD 的大小和类型提取到单独的列中,然后计算放置每台计算机所需的最小容量:

import pyspark.sql as sql
import pyspark.sql.functions as f

sample_data_transformed = (
    sample_data_df
    .rdd
    .map(lambda row: sql.Row(
        **row.asDict()
        , HDD_size=row.HDD.split(' ')[0]
        )
    )
    .map(lambda row: sql.Row(
        **row.asDict()
        , HDD_type=row.HDD.split(' ')[1]
        )
    )
    .map(lambda row: sql.Row(
        **row.asDict()
        , Volume=row.H * row.D * row.W
        )
    )
    .toDF()
    .select(
        sample_data_df.columns + 
        [
              'HDD_size'
            , 'HDD_type'
            , f.round(
                f.col('Volume')
            ).alias('Volume_cuIn')
        ]
    )
)

它是如何工作的...

正如前面指出的,DataFrame 中的 RDD 的每个元素都是一个Row(...)对象。您可以通过运行以下两个语句来检查它:

sample_data_df.rdd.take(1)

还有:

sample_data.take(1)

第一个产生一个单项列表,其中元素是Row(...)

另一个也产生一个单项列表,但项目是一个元组:

sample_data RDD 是我们在上一个示例中创建的第一个 RDD。

有了这个想法,现在让我们把注意力转向代码。

首先,我们加载必要的模块:要使用Row(...)对象,我们需要pyspark.sql,稍后我们将使用.round(...)方法,因此我们需要pyspark.sql.functions子模块。

接下来,我们从sample_data_df中提取.rdd。使用.map(...)转换,我们首先将HDD_size列添加到模式中。

由于我们正在使用 RDD,我们希望保留所有其他列。因此,我们首先使用.asDict()方法将行(即Row(...)对象)转换为字典,然后我们可以稍后使用**进行解包。

在 Python 中,单个*在元组列表之前,如果作为函数的参数传递,将列表的每个元素作为单独的参数传递给函数。双**将第一个元素转换为关键字参数,并使用第二个元素作为要传递的值。

第二个参数遵循一个简单的约定:我们传递要创建的列的名称(HDD_size),并将其设置为所需的值。在我们的第一个示例中,我们拆分了.HDD列并提取了第一个元素,因为它是HDD_size

我们将重复此步骤两次:首先创建HDD_type列,然后创建Volume列。

接下来,我们使用.toDF(...)方法将我们的 RDD 转换回 DataFrame。请注意,您仍然可以使用.toDF(...)方法将常规 RDD(即每个元素不是Row(...)对象的情况)转换为 DataFrame,但是您需要将列名的列表传递给.toDF(...)方法,否则您将得到未命名的列。

最后,我们.select(...)列,以便我们可以.round(...)新创建的Volume列。.alias(...)方法为生成的列产生不同的名称。

生成的 DataFrame 如下所示:

毫不奇怪,台式 iMac 需要最大的盒子。

性能优化

从 Spark 2.0 开始,使用 DataFrame 的 PySpark 性能与 Scala 或 Java 相当。但是,有一个例外:使用用户定义函数UDFs);如果用户定义了一个纯 Python 方法并将其注册为 UDF,在幕后,PySpark 将不断切换运行时(Python 到 JVM 再到 Python)。这是与 Scala 相比性能巨大下降的主要原因,Scala 不需要将 JVM 对象转换为 Python 对象。

在 Spark 2.3 中,情况发生了显著变化。首先,Spark 开始使用新的 Apache 项目。Arrow 创建了一个所有环境都使用的单一内存空间,从而消除了不断复制和转换对象的需要。

来源:https://arrow.apache.org/img/shared.png

有关 Apache Arrow 的概述,请访问arrow.apache.org

其次,Arrow 将列对象存储在内存中,从而大大提高了性能。因此,为了进一步利用这一点,PySpark 代码的部分已经进行了重构,这为我们带来了矢量化 UDF。

在本示例中,我们将学习如何使用它们,并测试旧的逐行 UDF 和新的矢量化 UDF 的性能。

准备工作

要执行此示例,您需要有一个可用的 Spark 2.3 环境。

没有其他要求。

如何做...

在本示例中,我们将使用 SciPy 返回在 0 到 1 之间的 100 万个随机数集的正态概率分布函数(PDF)的值。

import pyspark.sql.functions as f
import pandas as pd
from scipy import stats

big_df = (
    spark
    .range(0, 1000000)
    .withColumn('val', f.rand())
)

big_df.cache()
big_df.show(3)

@f.pandas_udf('double', f.PandasUDFType.SCALAR)
def pandas_pdf(v):
    return pd.Series(stats.norm.pdf(v))

(
    big_df
    .withColumn('probability', pandas_pdf(big_df.val))
    .show(5)
)

它是如何工作的...

首先,像往常一样,我们导入我们将需要运行此示例的所有模块:

  • pyspark.sql.functions为我们提供了访问 PySpark SQL 函数的途径。我们将使用它来创建带有随机数字的 DataFrame。

  • pandas框架将为我们提供.Series(...)数据类型的访问权限,以便我们可以从我们的 UDF 返回一个列。

  • scipy.stats为我们提供了访问统计方法的途径。我们将使用它来计算我们的随机数字的正态 PDF。

接下来是我们的big_dfSparkSession有一个方便的方法.range(...),允许我们在指定的范围内创建一系列数字;在这个示例中,我们只是创建了一个包含一百万条记录的 DataFrame。

在下一行中,我们使用.withColumn(...)方法向 DataFrame 添加另一列;列名为val,它将包含一百万个.rand()数字。

.rand()方法返回从 0 到 1 之间的均匀分布中抽取的伪随机数。

最后,我们使用.cache()方法缓存 DataFrame,以便它完全保留在内存中(以加快速度)。

接下来,我们定义pandas_cdf(...)方法。请注意@f.pandas_udf装饰器在方法声明之前,因为这是在 PySpark 中注册矢量化 UDF 的关键,并且仅在 Spark 2.3 中才可用。

请注意,我们不必装饰我们的方法;相反,我们可以将我们的矢量化方法注册为f.pandas_udf(f=pandas_pdf, returnType='double', functionType=f.PandasUDFType.SCALAR)

装饰器方法的第一个参数是 UDF 的返回类型,在我们的例子中是double。这可以是 DDL 格式的类型字符串,也可以是pyspark.sql.types.DataType。第二个参数是函数类型;如果我们从我们的方法返回单列(例如我们的示例中的 pandas'.Series(...)),它将是.PandasUDFType.SCALAR(默认情况下)。另一方面,如果我们操作多列(例如 pandas'DataFrame(...)),我们将定义.PandasUDFType.GROUPED_MAP

我们的pandas_pdf(...)方法简单地接受一个单列,并返回一个带有正态 CDF 对应数字值的 pandas'.Series(...)对象。

最后,我们简单地使用新方法来转换我们的数据。以下是前五条记录的样子(您的可能看起来不同,因为我们正在创建一百万个随机数):

还有更多...

现在让我们比较这两种方法的性能:

def test_pandas_pdf():
    return (big_df
            .withColumn('probability', pandas_pdf(big_df.val))
            .agg(f.count(f.col('probability')))
            .show()
        )

%timeit -n 1 test_pandas_pdf()

# row-by-row version with Python-JVM conversion
@f.udf('double')
def pdf(v):
    return float(stats.norm.pdf(v))

def test_pdf():
    return (big_df
            .withColumn('probability', pdf(big_df.val))
            .agg(f.count(f.col('probability')))
            .show()
        )

%timeit -n 1 test_pdf()

test_pandas_pdf()方法简单地使用pandas_pdf(...)方法从正态分布中检索 PDF,执行.count(...)操作,并使用.show(...)方法打印结果。test_pdf()方法也是一样,但是使用pdf(...)方法,这是使用 UDF 的逐行方式。

%timeit装饰器简单地运行test_pandas_pdf()test_pdf()方法七次,每次执行都会乘以。这是运行test_pandas_pdf()方法的一个示例输出(因为它是高度重复的,所以缩写了):

test_pdf()方法的时间如下所示:

如您所见,矢量化 UDF 提供了约 100 倍的性能改进!不要太激动,因为只有对于更复杂的查询才会有这样的加速,就像我们之前使用的那样。

另请参阅

使用反射推断模式

DataFrame 有模式,RDD 没有。也就是说,除非 RDD 由Row(...)对象组成。

在这个示例中,我们将学习如何使用反射推断模式创建 DataFrames。

准备工作

要执行此示例,您需要拥有一个可用的 Spark 2.3 环境。

没有其他要求。

如何做...

在这个示例中,我们首先将 CSV 样本数据读入 RDD,然后从中创建一个 DataFrame。以下是代码:

import pyspark.sql as sql

sample_data_rdd = sc.textFile('../Data/DataFrames_sample.csv')

header = sample_data_rdd.first()

sample_data_rdd_row = (
    sample_data_rdd
    .filter(lambda row: row != header)
    .map(lambda row: row.split(','))
    .map(lambda row:
        sql.Row(
            Id=int(row[0])
            , Model=row[1]
            , Year=int(row[2])
            , ScreenSize=row[3]
            , RAM=row[4]
            , HDD=row[5]
            , W=float(row[6])
            , D=float(row[7])
            , H=float(row[8])
            , Weight=float(row[9])
        )
    )
)

它是如何工作的...

首先,加载 PySpark 的 SQL 模块。

接下来,使用 SparkContext 的.textFile(...)方法读取DataFrames_sample.csv文件。

如果您还不知道如何将数据读入 RDD,请查看前一章。

生成的 RDD 如下所示:

如您所见,RDD 仍然包含具有列名的行。为了摆脱它,我们首先使用.first()方法提取它,然后使用.filter(...)转换来删除与标题相等的任何行。

接下来,我们用逗号分割每一行,并为每个观察创建一个Row(...)对象。请注意,我们将所有字段转换为适当的数据类型。例如,Id列应该是整数,Model名称是字符串,W(宽度)是浮点数。

最后,我们只需调用 SparkSession 的.createDataFrame(...)方法,将我们的Row(...)对象的 RDD 转换为 DataFrame。这是最终结果:

另请参阅

以编程方式指定模式

在上一个示例中,我们学习了如何使用反射推断 DataFrame 的模式。

在这个示例中,我们将学习如何以编程方式指定模式。

准备工作

要执行此示例,您需要一个可用的 Spark 2.3 环境。

没有其他要求。

如何做...

在这个例子中,我们将学习如何以编程方式指定模式:

import pyspark.sql.types as typ

sch = typ.StructType([
      typ.StructField('Id', typ.LongType(), False)
    , typ.StructField('Model', typ.StringType(), True)
    , typ.StructField('Year', typ.IntegerType(), True)
    , typ.StructField('ScreenSize', typ.StringType(), True)
    , typ.StructField('RAM', typ.StringType(), True)
    , typ.StructField('HDD', typ.StringType(), True)
    , typ.StructField('W', typ.DoubleType(), True)
    , typ.StructField('D', typ.DoubleType(), True)
    , typ.StructField('H', typ.DoubleType(), True)
    , typ.StructField('Weight', typ.DoubleType(), True)
])

sample_data_rdd = sc.textFile('../Data/DataFrames_sample.csv')

header = sample_data_rdd.first()

sample_data_rdd = (
    sample_data_rdd
    .filter(lambda row: row != header)
    .map(lambda row: row.split(','))
    .map(lambda row: (
                int(row[0])
                , row[1]
                , int(row[2])
                , row[3]
                , row[4]
                , row[5]
                , float(row[6])
                , float(row[7])
                , float(row[8])
                , float(row[9])
        )
    )
)

sample_data_schema = spark.createDataFrame(sample_data_rdd, schema=sch)
sample_data_schema.show()

它是如何工作的...

首先,我们创建一个.StructField(...)对象的列表。.StructField(...)是在 PySpark 中以编程方式向模式添加字段的方法。第一个参数是我们要添加的列的名称。

第二个参数是我们想要存储在列中的数据的数据类型;一些可用的类型包括.LongType().StringType().DoubleType().BooleanType().DateType().BinaryType()

有关 PySpark 中可用数据类型的完整列表,请转到spark.apache.org/docs/latest/api/python/pyspark.sql.html#module-pyspark.sql.types.

.StructField(...)的最后一个参数指示列是否可以包含空值;如果设置为True,则表示可以。

接下来,我们使用 SparkContext 的.textFile(...)方法读取DataFrames_sample.csv文件。我们过滤掉标题,因为我们将明确指定模式,不需要存储在第一行的名称列。接下来,我们用逗号分割每一行,并对每个元素施加正确的数据类型,使其符合我们刚刚指定的模式。

最后,我们调用.createDataFrame(...)方法,但这次,除了 RDD,我们还传递schema。生成的 DataFrame 如下所示:

另请参阅

创建临时表

在 Spark 中,可以很容易地使用 SQL 查询来操作 DataFrame。

在这个示例中,我们将学习如何创建临时视图,以便您可以使用 SQL 访问 DataFrame 中的数据。

准备工作

要执行此示例,您需要一个可用的 Spark 2.3 环境。您应该已经完成了上一个示例,因为我们将使用那里创建的sample_data_schema DataFrame。

没有其他要求。

如何做...

我们只需使用 DataFrame 的.createTempView(...)方法:

sample_data_schema.createTempView('sample_data_view')

它是如何工作的...

.createTempView(...)方法是创建临时视图的最简单方法,稍后可以用来查询数据。唯一需要的参数是视图的名称。

让我们看看这样的临时视图现在如何被用来提取数据:

spark.sql('''
    SELECT Model
        , Year
        , RAM
        , HDD
    FROM sample_data_view
''').show()

我们只需使用 SparkSession 的.sql(...)方法,这使我们能够编写 ANSI-SQL 代码来操作 DataFrame 中的数据。在这个例子中,我们只是提取了四列。这是我们得到的:

还有更多...

一旦创建了临时视图,就不能再创建具有相同名称的另一个视图。但是,Spark 提供了另一种方法,允许我们创建或更新视图:.createOrReplaceTempView(...)。顾名思义,通过调用此方法,我们要么创建一个新视图(如果不存在),要么用新视图替换已经存在的视图:

sample_data_schema.createOrReplaceTempView('sample_data_view')

与以前一样,我们现在可以使用它来使用 SQL 查询与数据交互:

spark.sql('''
    SELECT Model
        , Year
        , RAM
        , HDD
        , ScreenSize
    FROM sample_data_view
''').show()

这是我们得到的:

使用 SQL 与 DataFrame 交互

在上一个示例中,我们学习了如何创建或替换临时视图。

在这个示例中,我们将学习如何使用 SQL 查询在 DataFrame 中处理数据。

准备工作

要执行此示例,您需要具有工作的 Spark 2.3 环境。您应该已经通过以编程方式指定模式的示例,因为我们将使用在那里创建的sample_data_schema DataFrame。

没有其他要求。

如何做...

在这个例子中,我们将扩展我们原始的数据,为苹果电脑的每个型号添加形式因子:

models_df = sc.parallelize([
      ('MacBook Pro', 'Laptop')
    , ('MacBook', 'Laptop')
    , ('MacBook Air', 'Laptop')
    , ('iMac', 'Desktop')
]).toDF(['Model', 'FormFactor'])

models_df.createOrReplaceTempView('models')

sample_data_schema.createOrReplaceTempView('sample_data_view')

spark.sql('''
    SELECT a.*
        , b.FormFactor
    FROM sample_data_view AS a
    LEFT JOIN models AS b
        ON a.Model == b.Model
    ORDER BY Weight DESC
''').show()

它是如何工作的...

首先,我们创建一个简单的 DataFrame,其中包含两列:ModelFormFactor。在这个例子中,我们使用 RDD 的.toDF(...)方法,快速将其转换为 DataFrame。我们传递的列表只是列名的列表,模式将自动推断。

接下来,我们创建模型视图并替换sample_data_view

最后,要将FormFactor附加到我们的原始数据,我们只需在Model列上连接两个视图。由于.sql(...)方法接受常规 SQL 表达式,因此我们还使用ORDER BY子句,以便按权重排序。

这是我们得到的:

还有更多...

SQL 查询不仅限于仅提取数据。我们还可以运行一些聚合:

spark.sql('''
    SELECT b.FormFactor
        , COUNT(*) AS ComputerCnt
    FROM sample_data_view AS a
    LEFT JOIN models AS b
        ON a.Model == b.Model
    GROUP BY FormFactor
''').show()

在这个简单的例子中,我们将计算不同 FormFactors 的不同计算机数量。COUNT(*)运算符计算我们有多少台计算机,并与指定聚合列的GROUP BY子句一起工作。

从这个查询中我们得到了什么:

DataFrame 转换概述

就像 RDD 一样,DataFrame 既有转换又有操作。作为提醒,转换将一个 DataFrame 转换为另一个 DataFrame,而操作对 DataFrame 执行一些计算,并通常将结果返回给驱动程序。而且,就像 RDD 一样,DataFrame 中的转换是惰性的。

在这个示例中,我们将回顾最常见的转换。

准备工作

要执行此示例,您需要具有工作的 Spark 2.3 环境。您应该已经通过以编程方式指定模式的示例,因为我们将使用在那里创建的sample_data_schema DataFrame。

没有其他要求。

如何做...

在本节中,我们将列出一些可用于 DataFrame 的最常见转换。此列表的目的不是提供所有可用转换的全面枚举,而是为您提供最常见转换背后的一些直觉。

.select(...)转换

.select(...)转换允许我们从 DataFrame 中提取列。它的工作方式与 SQL 中的SELECT相同。

看一下以下代码片段:

# select Model and ScreenSize from the DataFrame

sample_data_schema.select('Model', 'ScreenSize').show()

它产生以下输出:

在 SQL 语法中,这将如下所示:

SELECT Model
    , ScreenSize
FROM sample_data_schema;

.filter(...)转换

.filter(...)转换与.select(...)相反,仅选择满足指定条件的行。它可以与 SQL 中的WHERE语句进行比较。

看一下以下代码片段:

# extract only machines from 2015 onwards

(
    sample_data_schema
    .filter(sample_data_schema.Year > 2015)
    .show()
)

它产生以下输出:

在 SQL 语法中,前面的内容相当于:

SELECT *
FROM sample_data_schema
WHERE Year > 2015

.groupBy(...)转换

.groupBy(...)转换根据列(或多个列)的值执行数据聚合。在 SQL 语法中,这相当于GROUP BY

看一下以下代码:

(
    sample_data_schema
    .groupBy('RAM')
    .count()
    .show()
)

它产生此结果:

在 SQL 语法中,这将是:

SELECT RAM
    , COUNT(*) AS count
FROM sample_data_schema
GROUP BY RAM

.orderBy(...) 转换

.orderBy(...) 转换根据指定的列对结果进行排序。 SQL 世界中的等效项也将是ORDER BY

查看以下代码片段:

# sort by width (W)

sample_data_schema.orderBy('W').show()

它产生以下输出:

SQL 等效项将是:

SELECT *
FROM sample_data_schema
ORDER BY W

您还可以使用列的.desc()开关(.col(...)方法)将排序顺序更改为降序。看看以下片段:

# sort by height (H) in descending order

sample_data_schema.orderBy(f.col('H').desc()).show()

它产生以下输出:

以 SQL 语法表示,前面的表达式将是:

SELECT *
FROM sample_data_schema
ORDER BY H DESC

.withColumn(...) 转换

.withColumn(...) 转换将函数应用于其他列和/或文字(使用.lit(...)方法)并将其存储为新函数。在 SQL 中,这可以是应用于任何列的任何转换的任何方法,并使用AS分配新列名。此转换扩展了原始数据框。

查看以下代码片段:

# split the HDD into size and type

(
    sample_data_schema
    .withColumn('HDDSplit', f.split(f.col('HDD'), ' '))
    .show()
)

它产生以下输出:

您可以使用.select(...)转换来实现相同的结果。以下代码将产生相同的结果:

# do the same as withColumn

(
    sample_data_schema
    .select(
        f.col('*')
        , f.split(f.col('HDD'), ' ').alias('HDD_Array')
    ).show()
)

SQL(T-SQL)等效项将是:

SELECT *
    , STRING_SPLIT(HDD, ' ') AS HDD_Array
FROM sample_data_schema

.join(...) 转换

.join(...) 转换允许我们连接两个数据框。第一个参数是我们要连接的另一个数据框,而第二个参数指定要连接的列,最后一个参数指定连接的性质。可用类型为innercrossouterfullfull_outerleftleft_outerrightright_outerleft_semileft_anti。在 SQL 中,等效项是JOIN语句。

如果您不熟悉ANTISEMI连接,请查看此博客:blog.jooq.org/2015/10/13/semi-join-and-anti-join-should-have-its-own-syntax-in-sql/

如下查看以下代码:

models_df = sc.parallelize([
      ('MacBook Pro', 'Laptop')
    , ('MacBook', 'Laptop')
    , ('MacBook Air', 'Laptop')
    , ('iMac', 'Desktop')
]).toDF(['Model', 'FormFactor'])

(
    sample_data_schema
    .join(
        models_df
        , sample_data_schema.Model == models_df.Model
        , 'left'
    ).show()
)

它产生以下输出:

在 SQL 语法中,这将是:

SELECT a.*
    , b,FormFactor
FROM sample_data_schema AS a
LEFT JOIN models_df AS b
    ON a.Model == b.Model

如果我们有一个数据框,不会列出每个Model(请注意MacBook缺失),那么以下代码是:

models_df = sc.parallelize([
      ('MacBook Pro', 'Laptop')
    , ('MacBook Air', 'Laptop')
    , ('iMac', 'Desktop')
]).toDF(['Model', 'FormFactor'])

(
    sample_data_schema
    .join(
        models_df
        , sample_data_schema.Model == models_df.Model
        , 'left'
    ).show()
)

这将生成一个带有一些缺失值的表:

RIGHT连接仅保留与右数据框中的记录匹配的记录。因此,看看以下代码:

(
    sample_data_schema
    .join(
        models_df
        , sample_data_schema.Model == models_df.Model
        , 'right'
    ).show()
)

这将产生以下表:

SEMIANTI连接是相对较新的添加。SEMI连接保留与右数据框中的记录匹配的左数据框中的所有记录(与RIGHT连接一样),但仅保留左数据框中的列ANTI连接是SEMI连接的相反,它仅保留在右数据框中找不到的记录。因此,SEMI连接的以下示例是:

(
    sample_data_schema
    .join(
        models_df
        , sample_data_schema.Model == models_df.Model
        , 'left_semi'
    ).show()
)

这将产生以下结果:

ANTI连接的示例是:

(
    sample_data_schema
    .join(
        models_df
        , sample_data_schema.Model == models_df.Model
        , 'left_anti'
    ).show()
)

这将生成以下内容:

.unionAll(...) 转换

.unionAll(...) 转换附加来自另一个数据框的值。 SQL 语法中的等效项是UNION ALL

看看以下代码:

another_macBookPro = sc.parallelize([
      (5, 'MacBook Pro', 2018, '15"', '16GB', '256GB SSD', 13.75, 9.48, 0.61, 4.02)
]).toDF(sample_data_schema.columns)

sample_data_schema.unionAll(another_macBookPro).show()

它产生以下结果:

在 SQL 语法中,前面的内容将读作:

SELECT *
FROM sample_data_schema

UNION ALL
SELECT *
FROM another_macBookPro

.distinct(...) 转换

.distinct(...) 转换返回列中不同值的列表。 SQL 中的等效项将是DISTINCT

看看以下代码:

# select the distinct values from the RAM column

sample_data_schema.select('RAM').distinct().show()

它产生以下结果:

在 SQL 语法中,这将是:

SELECT DISTINCT RAM
FROM sample_data_schema

.repartition(...) 转换

.repartition(...) 转换在集群中移动数据并将其组合成指定数量的分区。您还可以指定要在其上执行分区的列。在 SQL 世界中没有直接等效项。

看看以下代码:

sample_data_schema_rep = (
    sample_data_schema
    .repartition(2, 'Year')
)

sample_data_schema_rep.rdd.getNumPartitions()

它产生了(预期的)这个结果:

2

.fillna(...) 转换

.fillna(...) 转换填充 DataFrame 中的缺失值。您可以指定一个单个值,所有缺失的值都将用它填充,或者您可以传递一个字典,其中每个键是列的名称,值是要填充相应列中的缺失值。在 SQL 世界中没有直接的等价物。

看下面的代码:

missing_df = sc.parallelize([
    (None, 36.3, 24.2)
    , (1.6, 32.1, 27.9)
    , (3.2, 38.7, 24.7)
    , (2.8, None, 23.9)
    , (3.9, 34.1, 27.9)
    , (9.2, None, None)
]).toDF(['A', 'B', 'C'])

missing_df.fillna(21.4).show()

它产生了以下输出:

我们还可以指定字典,因为 21.4 值实际上并不适合 A 列。在下面的代码中,我们首先计算每列的平均值:

miss_dict = (
    missing_df
    .agg(
        f.mean('A').alias('A')
        , f.mean('B').alias('B')
        , f.mean('C').alias('C')
    )
).toPandas().to_dict('records')[0]

missing_df.fillna(miss_dict).show()

.toPandas() 方法是一个操作(我们将在下一个示例中介绍),它返回一个 pandas DataFrame。pandas DataFrame 的 .to_dict(...) 方法将其转换为字典,其中 records 参数产生一个常规字典,其中每个列是键,每个值是记录。

上述代码产生以下结果:

.dropna(...) 转换

.dropna(...) 转换删除具有缺失值的记录。您可以指定阈值,该阈值转换为记录中的最少缺失观察数,使其符合被删除的条件。与 .fillna(...) 一样,在 SQL 世界中没有直接的等价物。

看下面的代码:

missing_df.dropna().show()

它产生了以下结果:

指定 thresh=2

missing_df.dropna(thresh=2).show()

它保留了第一条和第四条记录:

.dropDuplicates(...) 转换

.dropDuplicates(...) 转换,顾名思义,删除重复的记录。您还可以指定一个子集参数作为列名的列表;该方法将根据这些列中找到的值删除重复的记录。

看下面的代码:

dupes_df = sc.parallelize([
      (1.6, 32.1, 27.9)
    , (3.2, 38.7, 24.7)
    , (3.9, 34.1, 27.9)
    , (3.2, 38.7, 24.7)
]).toDF(['A', 'B', 'C'])

dupes_df.dropDuplicates().show()

它产生了以下结果

.summary().describe() 转换

.summary().describe() 转换产生类似的描述性统计数据,.summary() 转换另外还产生四分位数。

看下面的代码:

sample_data_schema.select('W').summary().show()
sample_data_schema.select('W').describe().show()

它产生了以下结果:

.freqItems(...) 转换

.freqItems(...) 转换返回列中频繁项的列表。您还可以指定 minSupport 参数,该参数将丢弃低于某个阈值的项。

看下面的代码:

sample_data_schema.freqItems(['RAM']).show()

它产生了这个结果:

另请参阅

DataFrame 操作概述

上一个示例中列出的转换将一个 DataFrame 转换为另一个 DataFrame。但是,只有在对 DataFrame 调用操作时才会执行它们。

在本示例中,我们将概述最常见的操作。

准备工作

要执行此示例,您需要一个可用的 Spark 2.3 环境。您应该已经完成了上一个示例,以编程方式指定模式,因为我们将使用在那里创建的 sample_data_schema DataFrame。

没有其他要求。

如何做...

在本节中,我们将列出一些可用于 DataFrame 的最常见操作。此列表的目的不是提供所有可用转换的全面枚举,而是为您提供对最常见转换的直觉。

.show(...) 操作

.show(...) 操作默认显示表格形式的前五行记录。您可以通过传递整数作为参数来指定要检索的记录数。

看下面的代码:

sample_data_schema.select('W').describe().show()

它产生了这个结果:

.collect() 操作

.collect() 操作,顾名思义,从所有工作节点收集所有结果,并将它们返回给驱动程序。在大型数据集上使用此方法时要小心,因为如果尝试返回数十亿条记录的整个 DataFrame,驱动程序很可能会崩溃;只能用此方法返回小的、聚合的数据。

看看下面的代码:

sample_data_schema.groupBy('Year').count().collect()

它产生了以下结果:

.take(...) 操作

.take(...) 操作的工作方式与 RDDs 中的相同–它将指定数量的记录返回给驱动节点:

Look at the following code:sample_data_schema.take(2)

它产生了这个结果:

.toPandas() 操作

.toPandas() 操作,顾名思义,将 Spark DataFrame 转换为 pandas DataFrame。与.collect() 操作一样,需要在这里发出相同的警告–.toPandas() 操作从所有工作节点收集所有记录,将它们返回给驱动程序,然后将结果转换为 pandas DataFrame。

由于我们的样本数据很小,我们可以毫无问题地做到这一点:

sample_data_schema.toPandas()

这就是结果的样子:

另请参阅

第四章:为建模准备数据

在本章中,我们将介绍如何清理数据并为建模做准备。您将学习以下内容:

  • 处理重复项

  • 处理缺失观察

  • 处理异常值

  • 探索描述性统计

  • 计算相关性

  • 绘制直方图

  • 可视化特征之间的相互作用

介绍

现在我们对 RDD 和 DataFrame 的工作原理以及它们的功能有了深入的了解,我们可以开始为建模做准备了。

有名的人(阿尔伯特·爱因斯坦)曾经说过(引用):

"宇宙和任何数据集的问题都是无限的,我对前者不太确定。"

前面的话当然是一个笑话。然而,您处理的任何数据集,无论是在工作中获取的、在线找到的、自己收集的,还是通过其他方式获取的,都是脏的,直到证明为止;您不应该信任它,不应该玩弄它,甚至不应该看它,直到您自己证明它足够干净(没有完全干净的说法)。

您的数据集可能会出现哪些问题?嗯,举几个例子:

  • 重复的观察:这些是由系统和操作员的错误导致的

  • 缺失观察:这可能是由于传感器问题、受访者不愿回答问题,或者仅仅是一些数据损坏导致的

  • 异常观察:与数据集或人口其他部分相比,观察结果在观察时显得突出

  • 编码:文本字段未经规范化(例如,单词未经词干处理或使用同义词),使用不同语言,或者您可能遇到无意义的文本输入,日期和日期时间字段可能没有以相同的方式编码

  • 不可信的答案(尤其是调查):受访者因任何原因而撒谎;这种脏数据更难处理和清理

正如您所看到的,您的数据可能会受到成千上万个陷阱的困扰,它们正等待着您去陷入其中。清理数据并熟悉数据是我们(作为数据科学家)80%的时间所做的事情(剩下的 20%我们花在建模和抱怨清理数据上)。所以系好安全带,准备迎接颠簸的旅程,这是我们信任我们拥有的数据并熟悉它所必需的。

在本章中,我们将使用一个包含22条记录的小数据集:

dirty_data = spark.createDataFrame([
          (1,'Porsche','Boxster S','Turbo',2.5,4,22,None)
        , (2,'Aston Martin','Vanquish','Aspirated',6.0,12,16,None)
        , (3,'Porsche','911 Carrera 4S Cabriolet','Turbo',3.0,6,24,None)
        , (3,'General Motors','SPARK ACTIV','Aspirated',1.4,None,32,None)
        , (5,'BMW','COOPER S HARDTOP 2 DOOR','Turbo',2.0,4,26,None)
        , (6,'BMW','330i','Turbo',2.0,None,27,None)
        , (7,'BMW','440i Coupe','Turbo',3.0,6,23,None)
        , (8,'BMW','440i Coupe','Turbo',3.0,6,23,None)
        , (9,'Mercedes-Benz',None,None,None,None,27,None)
        , (10,'Mercedes-Benz','CLS 550','Turbo',4.7,8,21,79231)
        , (11,'Volkswagen','GTI','Turbo',2.0,4,None,None)
        , (12,'Ford Motor Company','FUSION AWD','Turbo',2.7,6,20,None)
        , (13,'Nissan','Q50 AWD RED SPORT','Turbo',3.0,6,22,None)
        , (14,'Nissan','Q70 AWD','Aspirated',5.6,8,18,None)
        , (15,'Kia','Stinger RWD','Turbo',2.0,4,25,None)
        , (16,'Toyota','CAMRY HYBRID LE','Aspirated',2.5,4,46,None)
        , (16,'Toyota','CAMRY HYBRID LE','Aspirated',2.5,4,46,None)
        , (18,'FCA US LLC','300','Aspirated',3.6,6,23,None)
        , (19,'Hyundai','G80 AWD','Turbo',3.3,6,20,None)
        , (20,'Hyundai','G80 AWD','Turbo',3.3,6,20,None)
        , (21,'BMW','X5 M','Turbo',4.4,8,18,121231)
        , (22,'GE','K1500 SUBURBAN 4WD','Aspirated',5.3,8,18,None)
    ], ['Id','Manufacturer','Model','EngineType','Displacement',
        'Cylinders','FuelEconomy','MSRP'])

在接下来的教程中,我们将清理前面的数据集,并对其进行更深入的了解。

处理重复项

数据中出现重复项的原因很多,但有时很难发现它们。在这个教程中,我们将向您展示如何发现最常见的重复项,并使用 Spark 进行处理。

准备工作

要执行此教程,您需要一个可用的 Spark 环境。如果没有,请返回第一章,安装和配置 Spark,并按照那里找到的教程进行操作。

我们将使用介绍中的数据集。本章中所需的所有代码都可以在我们为本书设置的 GitHub 存储库中找到:bit.ly/2ArlBck。转到Chapter04并打开4.Preparing data for modeling.ipynb笔记本。

不需要其他先决条件。

操作步骤...

重复项是数据集中出现多次的记录。它是一个完全相同的副本。Spark DataFrames 有一个方便的方法来删除重复的行,即.dropDuplicates()转换:

  1. 检查是否有任何重复行,如下所示:
dirty_data.count(), dirty_data.distinct().count()
  1. 如果有重复项,请删除它们:
full_removed = dirty_data.dropDuplicates()

它是如何工作的...

你现在应该知道这个了,但是.count()方法计算我们的 DataFrame 中有多少行。第二个命令检查我们有多少个不同的行。在我们的dirty_data DataFrame 上执行这两个命令会产生(22, 21)的结果。因此,我们现在知道我们的数据集中有两条完全相同的记录。让我们看看哪些:

(
    dirty_data
    .groupby(dirty_data.columns)
    .count()
    .filter('count > 1')
    .show()
)

让我们解开这里发生的事情。首先,我们使用.groupby(...)方法来定义用于聚合的列;在这个例子中,我们基本上使用了所有列,因为我们想找到数据集中所有列的所有不同组合。接下来,我们使用.count()方法计算这样的值组合发生的次数;该方法将count列添加到我们的数据集中。使用.filter(...)方法,我们选择数据集中出现多次的所有行,并使用.show()操作将它们打印到屏幕上。

这产生了以下结果:

因此,Id等于16的行是重复的。因此,让我们使用.dropDuplicates(...)方法将其删除。最后,运行full_removed.count()命令确认我们现在有 21 条记录。

还有更多...

嗯,还有更多的内容,你可能会想象。在我们的full_removed DataFrame 中仍然有一些重复的记录。让我们仔细看看。

只有 ID 不同

如果您随时间收集数据,可能会记录具有不同 ID 但相同数据的数据。让我们检查一下我们的 DataFrame 是否有这样的记录。以下代码片段将帮助您完成此操作:

(
    full_removed
    .groupby([col for col in full_removed.columns if col != 'Id'])
    .count()
    .filter('count > 1')
    .show()
)

就像以前一样,我们首先按所有列分组,但是我们排除了'Id'列,然后计算给定此分组的记录数,最后提取那些具有'count > 1'的记录并在屏幕上显示它们。运行上述代码后,我们得到以下结果:

正如你所看到的,我们有四条不同 ID 但是相同车辆的记录:BMW 440i CoupeHyundai G80 AWD

我们也可以像以前一样检查计数:

no_ids = (
    full_removed
    .select([col for col in full_removed.columns if col != 'Id'])
)

no_ids.count(), no_ids.distinct().count()
(21, 19), indicating that we have four records that are duplicated, just like we saw earlier.

.dropDuplicates(...)方法可以轻松处理这种情况。我们需要做的就是将要考虑的所有列的列表传递给subset参数,以便在搜索重复项时使用。方法如下:

id_removed = full_removed.dropDuplicates(
    subset = [col for col in full_removed.columns if col != 'Id']
)

再次,我们选择除了'Id'列之外的所有列来定义重复的列。如果我们现在计算id_removed DataFrame 中的总行数,应该得到19

这正是我们得到的!

ID 碰撞

您可能还会假设,如果有两条具有相同 ID 的记录,它们是重复的。嗯,虽然这可能是真的,但是当我们基于所有列删除记录时,我们可能已经删除了它们。因此,在这一点上,任何重复的 ID 更可能是碰撞。

重复的 ID 可能出现多种原因:仪器误差或存储 ID 的数据结构不足,或者如果 ID 表示记录元素的某个哈希函数,可能会出现哈希函数的选择引起的碰撞。这只是您可能具有重复 ID 但记录实际上并不重复的原因中的一部分。

让我们检查一下我们的数据集是否符合这一点:

import pyspark.sql.functions as fn

id_removed.agg(
      fn.count('Id').alias('CountOfIDs')
    , fn.countDistinct('Id').alias('CountOfDistinctIDs')
).show()

在这个例子中,我们将使用.agg(...)方法,而不是对记录进行子集化,然后计算记录数,然后计算不同的记录数。为此,我们首先从pyspark.sql.functions模块中导入所有函数。

有关pyspark.sql.functions中所有可用函数的列表,请参阅spark.apache.org/docs/latest/api/python/pyspark.sql.html#module-pyspark.sql.functions

我们将使用的两个函数将允许我们一次完成计数:.count(...)方法计算指定列中非空值的所有记录的数量,而.countDistinct(...)返回这样一列中不同值的计数。.alias(...)方法允许我们为计数结果的列指定友好的名称。在计数之后,我们得到了以下结果:

好的,所以我们有两条具有相同 ID 的记录。再次,让我们检查哪些 ID 是重复的:

(
    id_removed
    .groupby('Id')
    .count()
    .filter('count > 1')
    .show()
)

与之前一样,我们首先按'Id'列中的值进行分组,然后显示所有具有大于1count的记录。这是我们得到的结果:

嗯,看起来我们有两条'Id == 3'的记录。让我们检查它们是否相同:

这些绝对不是相同的记录,但它们共享相同的 ID。在这种情况下,我们可以创建一个新的 ID,这将是唯一的(我们已经确保数据集中没有其他重复)。PySpark 的 SQL 函数模块提供了一个.monotonically_increasing_id()方法,它创建一个唯一的 ID 流。

.monotonically_increasing_id()生成的 ID 保证是唯一的,只要你的数据存在少于十亿个分区,并且每个分区中的记录少于八十亿条。这是一个非常大的数字。

以下是一个代码段,将创建并替换我们的 ID 列为一个唯一的 ID:

new_id = (
    id_removed
    .select(
        [fn.monotonically_increasing_id().alias('Id')] + 
        [col for col in id_removed.columns if col != 'Id'])
)

new_id.show()

我们首先创建 ID 列,然后选择除原始'Id'列之外的所有其他列。新的 ID 看起来是这样的:

这些数字绝对是唯一的。我们现在准备处理数据集中的其他问题。

处理缺失观察

缺失观察在数据集中几乎是第二常见的问题。这是由于许多原因引起的,正如我们在介绍中已经提到的那样。在这个示例中,我们将学习如何处理它们。

准备好了

要执行这个示例,你需要一个可用的 Spark 环境。此外,我们将在前一个示例中创建的new_id DataFrame 上进行操作,因此我们假设你已经按照步骤删除了重复的记录。

不需要其他先决条件。

如何做...

由于我们的数据有两个维度(行和列),我们需要检查每行和每列中缺失数据的百分比,以确定保留什么,放弃什么,以及(可能)插补什么:

  1. 要计算一行中有多少缺失观察,使用以下代码段:
(
    spark.createDataFrame(
        new_id
        .rdd
        .map(
           lambda row: (
                 row['Id']
               , sum([c == None for c in row])
           )
        )
        .collect()
        .filter(lambda el: el[1] > 1)
        ,['Id', 'CountMissing']
    )
    .orderBy('CountMissing', ascending=False)
    .show()
)
  1. 要计算每列中缺少多少数据,使用以下代码:
for k, v in sorted(
    merc_out
        .agg(*[
               (1 - (fn.count(c) / fn.count('*')))
                    .alias(c + '_miss')
               for c in merc_out.columns
           ])
        .collect()[0]
        .asDict()
        .items()
    , key=lambda el: el[1]
    , reverse=True
):
    print(k, v)

让我们一步一步地走过这些。

它是如何工作的...

现在让我们详细看看如何处理行和列中的缺失观察。

每行的缺失观察

要计算一行中缺少多少数据,更容易使用 RDD,因为我们可以循环遍历 RDD 记录的每个元素,并计算缺少多少值。因此,我们首先访问new_id DataFrame 中的.rdd。使用.map(...)转换,我们循环遍历每一行,提取'Id',并使用sum([c == None for c in row])表达式计算缺少元素的次数。这些操作的结果是每个都有两个值的元素的 RDD:行的 ID 和缺失值的计数。

接下来,我们只选择那些有多于一个缺失值的记录,并在驱动程序上.collect()这些记录。然后,我们创建一个简单的 DataFrame,通过缺失值的计数按降序.orderBy(...),并显示记录。

结果如下所示:

正如你所看到的,其中一条记录有八个值中的五个缺失。让我们看看那条记录:

(
    new_id
    .where('Id == 197568495616')
    .show()
)

前面的代码显示了Mercedes-Benz记录中大部分值都缺失:

因此,我们可以删除整个观测值,因为这条记录中实际上并没有太多价值。为了实现这个目标,我们可以使用 DataFrame 的.dropna(...)方法:merc_out = new_id.dropna(thresh=4)

如果你使用.dropna()而不传递任何参数,任何具有缺失值的记录都将被删除。

我们指定thresh=4,所以我们只删除具有至少四个非缺失值的记录;我们的记录只有三个有用的信息。

让我们确认一下:运行new_id.count(), merc_out.count()会产生(19, 18),所以是的,确实,我们移除了一条记录。我们真的移除了Mercedes-Benz吗?让我们检查一下:

(
    merc_out
    .where('Id == 197568495616')
    .show()
)
Id equal to 197568495616, as shown in the following screenshot:

每列的缺失观测值

我们还需要检查是否有某些列中有特别低的有用信息发生率。在我们提供的代码中发生了很多事情,所以让我们一步一步地拆开它。

让我们从内部列表开始:

[
    (1 - (fn.count(c) / fn.count('*')))
        .alias(c + '_miss')
    for c in merc_out.columns
]

我们遍历merc_out DataFrame 中的所有列,并计算我们在每列中找到的非缺失值的数量。然后我们将它除以所有行的总数,并从中减去 1,这样我们就得到了缺失值的百分比。

我们在本章前面导入了pyspark.sql.functions作为fn

然而,我们在这里实际上做的并不是真正的计算。Python 存储这些信息的方式,此时只是作为一系列对象或指针,指向某些操作。只有在我们将列表传递给.agg(...)方法后,它才会被转换为 PySpark 的内部执行图(只有在我们调用.collect()动作时才会执行)。

.agg(...)方法接受一组参数,不是作为列表对象,而是作为逗号分隔的参数列表。因此,我们没有将列表本身传递给.agg(...)方法,而是在列表前面包含了'*',这样我们的列表的每个元素都会被展开,并像参数一样传递给我们的方法。

.collect()方法将返回一个元素的列表——一个包含聚合信息的Row对象。我们可以使用.asDict()方法将Row转换为字典,然后提取其中的所有items。这将导致一个元组的列表,其中第一个元素是列名(我们使用.alias(...)方法将'_miss'附加到每一列),第二个元素是缺失观测值的百分比。

在循环遍历排序列表的元素时,我们只是将它们打印到屏幕上:

嗯,看起来MSRP列中的大部分信息都是缺失的。因此,我们可以删除它,因为它不会给我们带来任何有用的信息:

no_MSRP = merc_out.select([col for col in new_id.columns if col != 'MSRP'])

我们仍然有两列有一些缺失信息。让我们对它们做点什么。

还有更多...

PySpark 允许你填补缺失的观测值。你可以传递一个值,所有数据中的nullNone都将被替换,或者你可以传递一个包含不同值的字典,用于每个具有缺失观测值的列。在这个例子中,我们将使用后一种方法,并指定燃油经济性和排量之间的比例,以及气缸数和排量之间的比例。

首先,让我们创建我们的字典:

multipliers = (
    no_MSRP
    .agg(
          fn.mean(
              fn.col('FuelEconomy') / 
              (
                  fn.col('Displacement') * fn.col('Cylinders')
              )
          ).alias('FuelEconomy')
        , fn.mean(
            fn.col('Cylinders') / 
            fn.col('Displacement')
        ).alias('Cylinders')
    )
).toPandas().to_dict('records')[0]

在这里,我们有效地计算了我们的乘数。为了替换燃油经济性中的缺失值,我们将使用以下公式:

对于气缸数,我们将使用以下方程:

我们先前的代码使用这两个公式来计算每一行的乘数,然后取这些乘数的平均值。

这不会是完全准确的,但鉴于我们拥有的数据,它应该足够准确。

在这里,我们还提供了另一种将您的(小型!)Spark DataFrame 创建为字典的方法:使用.toPandas()方法将 Spark DataFrame 转换为 pandas DataFrame。 pandas DataFrame 具有.to_dict(...)方法,该方法将允许您将我们的数据转换为字典。 'records'参数指示方法将每一行转换为一个字典,其中键是具有相应记录值的列名。

查看此链接以了解更多关于.to_dict(...)方法的信息:pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.to_dict.html

我们的结果字典如下:

现在让我们使用它来填补我们的缺失数据:

imputed = (
    no_MSRP
    .withColumn('FuelEconomy', fn.col('FuelEconomy') / fn.col('Displacement') / fn.col('Cylinders'))
    .withColumn('Cylinders', fn.col('Cylinders') / fn.col('Displacement'))
    .fillna(multipliers)
    .withColumn('Cylinders', (fn.col('Cylinders') * fn.col('Displacement')).cast('integer'))
    .withColumn('FuelEconomy', fn.col('FuelEconomy') * fn.col('Displacement') * fn.col('Cylinders'))
)

首先,我们将原始数据转换为反映我们之前指定的比率的数据。接下来,我们使用乘数字典填充缺失值,最后将列恢复到其原始状态。

请注意,每次使用.withColumn(...)方法时,都会覆盖原始列名。

生成的 DataFrame 如下所示:

正如您所看到的,汽缸和燃油经济性的结果值并不完全准确,但仍然可以说比用预定义值替换它们要好。

另请参阅

处理异常值

异常值是与其余观察结果差异很大的观察结果,即它们位于数据分布的长尾部分,本配方中,我们将学习如何定位和处理异常值。

准备工作

要执行此配方,您需要有一个可用的 Spark 环境。此外,我们将在前一个配方中创建的imputedDataFrame 上进行操作,因此我们假设您已经按照处理缺失观察的步骤进行了操作。

不需要其他先决条件。

如何做...

让我们从异常值的一个常见定义开始。

一个点,,符合以下标准:

不被视为异常值;在此范围之外的任何点都是异常值。在上述方程中,是第一四分位数(25^(th)百分位数),是第三四分位数,IQR四分位距,定义为的差值:IQR= Q³-Q¹

要标记异常值,请按照以下步骤进行:

  1. 让我们先计算我们的范围:
features = ['Displacement', 'Cylinders', 'FuelEconomy']
quantiles = [0.25, 0.75]

cut_off_points = []

for feature in features:
    quants = imputed.approxQuantile(feature, quantiles, 0.05)

    IQR = quants[1] - quants[0]
    cut_off_points.append((feature, [
        quants[0] - 1.5 * IQR,
        quants[1] + 1.5 * IQR,
    ]))

cut_off_points = dict(cut_off_points)
  1. 接下来,我们标记异常值:
outliers = imputed.select(*['id'] + [
       (
           (imputed[f] < cut_off_points[f][0]) |
           (imputed[f] > cut_off_points[f][1])
       ).alias(f + '_o') for f in features
  ])

它是如何工作的...

我们只会查看数值变量:排量、汽缸和燃油经济性。

我们循环遍历所有这些特征,并使用.approxQuantile(...)方法计算第一和第三四分位数。该方法将特征(列)名称作为第一个参数,要计算的四分位数的浮点数(或浮点数列表)作为第二个参数,第三个参数指定相对目标精度(将此值设置为 0 将找到精确的四分位数,但可能非常昂贵)。

该方法返回两个(在我们的情况下)值的列表:。然后我们计算四分位距,并将(feature_name, [lower_bound, upper_bound])元组附加到cut_off_point列表中。转换为字典后,我们的截断点如下:

因此,现在我们可以使用这些来标记我们的异常观察结果。我们只会选择 ID 列,然后循环遍历我们的特征,以检查它们是否落在我们计算的边界之外。这是我们得到的结果:

因此,我们在燃油经济性列中有两个异常值。让我们检查记录:

with_outliers_flag = imputed.join(outliers, on='Id')

(
    with_outliers_flag
    .filter('FuelEconomy_o')
    .select('Id', 'Manufacturer', 'Model', 'FuelEconomy')
    .show()
)

首先,我们将我们的imputed DataFrame 与outliers进行连接,然后我们根据FuelEconomy_o标志进行筛选,仅选择我们的异常记录。最后,我们只提取最相关的列以显示:

因此,我们有SPARK ACTIVCAMRY HYBRID LE作为异常值。SPARK ACTIV由于我们的填充逻辑而成为异常值,因为我们不得不填充其燃油经济值;考虑到其引擎排量为 1.4 升,我们的逻辑并不奏效。好吧,您可以用其他方法填充值。作为混合动力车,凯美瑞在由大型涡轮增压引擎主导的数据集中显然是一个异常值;看到它出现在这里并不奇怪。

尝试基于带有异常值的数据构建机器学习模型可能会导致一些不可信的结果或无法很好泛化的模型,因此我们通常会从数据集中删除这些异常值:

no_outliers = (
    with_outliers_flag
    .filter('!FuelEconomy_o')
    .select(imputed.columns)
)
FuelEconomy_o column. That's it!

另请参阅

探索描述性统计

描述性统计是您可以在数据上计算的最基本的度量。在本示例中,我们将学习在 PySpark 中熟悉我们的数据集是多么容易。

准备工作

要执行此示例,您需要一个可用的 Spark 环境。此外,我们将使用在处理异常值示例中创建的no_outliers DataFrame,因此我们假设您已经按照处理重复项、缺失观测值和异常值的步骤进行了操作。

不需要其他先决条件。

如何做...

在 PySpark 中计算数据的描述性统计非常容易。以下是方法:

descriptive_stats = no_outliers.describe(features)

就是这样!

工作原理...

上述代码几乎不需要解释。.describe(...)方法接受要计算描述性统计的列的列表,并返回一个包含基本描述性统计的 DataFrame:计数、平均值、标准偏差、最小值和最大值。

您可以将数字和字符串列都指定为.describe(...)的输入参数。

这是我们在features列上运行.describe(...)方法得到的结果:

正如预期的那样,我们总共有16条记录。我们的数据集似乎偏向于较大的引擎,因为平均排量为3.44升,有六个汽缸。对于如此庞大的引擎来说,燃油经济性似乎还不错,为 19 英里/加仑。

还有更多...

如果您不传递要计算描述性统计的列的列表,PySpark 将返回 DataFrame 中每一列的统计信息。请查看以下代码片段:

descriptive_stats_all = no_outliers.describe()
descriptive_stats_all.show()

这将导致以下表:

正如您所看到的,即使字符串列也有它们的描述性统计,但解释起来相当可疑。

聚合列的描述性统计

有时,您希望在一组值中计算一些描述性统计。在此示例中,我们将为具有不同汽缸数量的汽车计算一些基本统计信息:

(
    no_outliers
    .select(features)
    .groupBy('Cylinders')
    .agg(*[
          fn.count('*').alias('Count')
        , fn.mean('FuelEconomy').alias('MPG_avg')
        , fn.mean('Displacement').alias('Disp_avg')
        , fn.stddev('FuelEconomy').alias('MPG_stdev')

        , fn.stddev('Displacement').alias('Disp_stdev')
    ])
    .orderBy('Cylinders')
).show()

首先,我们选择我们的features列列表,以减少我们需要分析的数据量。接下来,我们在汽缸列上聚合我们的数据,并使用(已经熟悉的).agg(...)方法来计算燃油经济性和排量的计数、平均值和标准偏差。

pyspark.sql.functions模块中还有更多的聚合函数:avg(...), count(...), countDistinct(...), first(...), kurtosis(...), max(...), mean(...), min(...), skewness(...), stddev_pop(...), stddev_samp(...), sum(...), sumDistinct(...), var_pop(...), var_samp(...), 和 variance(...).

这是结果表:

我们可以从这个表中得出两点结论:

  • 我们的填充方法真的不准确,所以下次我们应该想出一个更好的方法。

  • 六缸汽车的MPG_avg高于四缸汽车,这可能有些可疑。这就是为什么你应该熟悉你的数据,因为这样你就可以发现数据中的隐藏陷阱。

如何处理这样的发现超出了本书的范围。但是,重点是这就是为什么数据科学家会花 80%的时间来清理数据并熟悉它,这样建立在这样的数据上的模型才能得到可靠的依赖。

另请参阅

  • 你可以在你的数据上计算许多其他统计量,我们在这里没有涵盖(但 PySpark 允许你计算)。为了更全面地了解,我们建议你查看这个网站:www.socialresearchmethods.net/kb/statdesc.php

计算相关性

与结果相关的特征是可取的,但那些在彼此之间也相关的特征可能会使模型不稳定。在这个配方中,我们将向你展示如何计算特征之间的相关性。

准备工作

要执行这个步骤,你需要一个可用的 Spark 环境。此外,我们将使用我们在处理离群值配方中创建的no_outliers DataFrame,所以我们假设你已经按照处理重复项、缺失观察和离群值的步骤进行了操作。

不需要其他先决条件。

如何做...

要计算两个特征之间的相关性,你只需要提供它们的名称:

(
    no_outliers
    .corr('Cylinders', 'Displacement')
)

就是这样!

它是如何工作的...

.corr(...)方法接受两个参数,即你想要计算相关系数的两个特征的名称。

目前只有皮尔逊相关系数是可用的。

上述命令将为我们的数据集产生一个相关系数等于0.938

还有更多...

如果你想计算一个相关矩阵,你需要手动完成这个过程。以下是我们的解决方案:

n_features = len(features)

corr = []

for i in range(0, n_features):
    temp = [None] * i

    for j in range(i, n_features):
        temp.append(no_outliers.corr(features[i], features[j]))
    corr.append([features[i]] + temp)

correlations = spark.createDataFrame(corr, ['Column'] + features)

上述代码实际上是在我们的features列表中循环,并计算它们之间的成对相关性,以填充矩阵的上三角部分。

我们在处理离群值配方中介绍了features列表。

然后将计算出的系数附加到temp列表中,然后将其添加到corr列表中。最后,我们创建了相关性 DataFrame。它看起来是这样的:

如你所见,唯一的强相关性是DisplacementCylinders之间的,这当然不足为奇。FuelEconomy与排量并没有真正相关,因为还有其他影响FuelEconomy的因素,比如汽车的阻力和重量。然而,如果你试图预测,例如最大速度,并假设(这是一个合理的假设),DisplacementCylinders都与最大速度高度正相关,那么你应该只使用其中一个。

绘制直方图

直方图是直观检查数据分布的最简单方法。在这个配方中,我们将向你展示如何在 PySpark 中做到这一点。

准备工作

要执行这个步骤,你需要一个可用的 Spark 环境。此外,我们将使用我们在处理离群值配方中创建的no_outliers DataFrame,所以我们假设你已经按照处理重复项、缺失观察和离群值的步骤进行了操作。

不需要其他先决条件。

如何做...

在 PySpark 中有两种生成直方图的方法:

  • 选择你想要可视化的特征,在驱动程序上.collect()它,然后使用 matplotlib 的本地.hist(...)方法来绘制直方图

  • 在 PySpark 中计算每个直方图箱中的计数,并将计数返回给驱动程序进行可视化

前一个解决方案适用于小数据集(例如本章中的数据),但如果数据太大,它将破坏您的驱动程序。此外,我们分发数据的一个很好的原因是,我们可以并行计算而不是在单个线程中进行计算。因此,在这个示例中,我们只会向您展示第二个解决方案。这是为我们做所有计算的片段:

histogram_MPG = (
    no_outliers
    .select('FuelEconomy')
    .rdd
    .flatMap(lambda record: record)
    .histogram(5)
)

它是如何工作的...

上面的代码非常容易理解。首先,我们选择感兴趣的特征(在我们的例子中是燃油经济)。

Spark DataFrames 没有本地的直方图方法,这就是为什么我们要切换到底层的 RDD。

接下来,我们将结果展平为一个长列表(而不是一个Row对象),并使用.histogram(...)方法来计算我们的直方图。

.histogram(...)方法接受一个整数,该整数指定要将我们的数据分配到的桶的数量,或者是一个具有指定桶限制的列表。

查看 PySpark 关于.histogram(...)的文档:spark.apache.org/docs/latest/api/python/pyspark.html#pyspark.RDD.histogram

该方法返回两个元素的元组:第一个元素是一个 bin 边界的列表,另一个元素是相应 bin 中元素的计数。这是我们的燃油经济特征的样子:

请注意,我们指定.histogram(...)方法将我们的数据分桶为五个 bin,但第一个列表中有六个元素。但是,我们的数据集中仍然有五个桶:[8.97, 12.38), [ 12.38, 15.78), [15.78, 19.19), [19.19, 22.59)[22.59, 26.0)

我们不能在 PySpark 中本地创建任何图表,而不经过大量设置(例如,参见这个:plot.ly/python/apache-spark/)。更简单的方法是准备一个包含我们的数据的 DataFrame,并在驱动程序上使用一些魔法(好吧,是 sparkmagics,但它仍然有效!)。

首先,我们需要提取我们的数据并创建一个临时的histogram_MPG表:

(
    spark
    .createDataFrame(
        [(bins, counts) 
         for bins, counts 
         in zip(
             histogram_MPG[0], 
             histogram_MPG[1]
         )]
        , ['bins', 'counts']
    )
    .registerTempTable('histogram_MPG')
)

我们创建一个两列的 DataFrame,其中第一列包含 bin 的下限,第二列包含相应的计数。.registerTempTable(...)方法(顾名思义)注册一个临时表,这样我们就可以在%%sql魔法中使用它:

%%sql -o hist_MPG -q
SELECT * FROM histogram_MPG

上面的命令从我们的临时histogram_MPG表中选择所有记录,并将其输出到本地可访问的hist_MPG变量;-q开关是为了确保笔记本中没有打印出任何内容。

有了本地可访问的hist_MPG,我们现在可以使用它来生成我们的图表:

%%local
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('ggplot')

fig = plt.figure(figsize=(12,9))
ax = fig.add_subplot(1, 1, 1)
ax.bar(hist_MPG['bins'], hist_MPG['counts'], width=3)
ax.set_title('Histogram of fuel economy')

%%local在本地模式下执行笔记本单元格中的任何内容。首先,我们导入matplotlib库,并指定它在笔记本中内联生成图表,而不是每次生成图表时弹出一个新窗口。plt.style.use(...)更改我们图表的样式。

要查看可用样式的完整列表,请查看matplotlib.org/devdocs/gallery/style_sheets/style_sheets_reference.html

接下来,我们创建一个图表,并向其中添加一个子图,最后,我们使用.bar(...)方法来绘制我们的直方图并设置标题。图表的样子如下:

就是这样!

还有更多...

Matplotlib 不是我们绘制直方图的唯一库。Bokeh(可在bokeh.pydata.org/en/latest/找到)是另一个功能强大的绘图库,建立在D3.js之上,允许您与图表进行交互。

bokeh.pydata.org/en/latest/docs/gallery.html上查看示例的图库。

这是使用 Bokeh 绘图的方法:

%%local
from bokeh.io import show
from bokeh.plotting import figure
from bokeh.io import output_notebook
output_notebook()

labels = [str(round(e, 2)) for e in hist_MPG['bins']]

p = figure(
    x_range=labels, 
    plot_height=350, 
    title='Histogram of fuel economy'
)

p.vbar(x=labels, top=hist_MPG['counts'], width=0.9)

show(p)

首先,我们加载 Bokeh 的所有必要组件;output_notebook()方法确保我们在笔记本中内联生成图表,而不是每次都打开一个新窗口。接下来,我们生成要放在图表上的标签。然后,我们定义我们的图形:x_range参数指定x轴上的点数,plot_height设置我们图表的高度。最后,我们使用.vbar(...)方法绘制我们直方图的条形;x参数是要放在我们图表上的标签,top参数指定计数。

结果如下所示:

这是相同的信息,但您可以在浏览器中与此图表进行交互。

另请参阅

可视化特征之间的相互作用

绘制特征之间的相互作用可以进一步加深您对数据分布的理解,也可以了解特征之间的关系。在这个配方中,我们将向您展示如何从您的数据中创建散点图。

准备工作

要执行此操作,您需要拥有一个可用的 Spark 环境。此外,我们将在处理异常值配方中创建的no_outliers DataFrame 上进行操作,因此我们假设您已经按照处理重复项、缺失观察和异常值的步骤进行了操作。

不需要其他先决条件。

如何做...

再次,我们将从 DataFrame 中选择我们的数据并在本地公开它:

scatter = (
    no_outliers
    .select('Displacement', 'Cylinders')
)

scatter.registerTempTable('scatter')

%%sql -o scatter_source -q
SELECT * FROM scatter

它是如何工作的...

首先,我们选择我们想要了解其相互作用的两个特征;在我们的案例中,它们是排量和汽缸特征。

我们的示例很小,所以我们可以使用所有的数据。然而,在现实世界中,您应该在尝试绘制数十亿数据点之前首先对数据进行抽样。

在注册临时表之后,我们使用%%sql魔术方法从scatter表中选择所有数据并在本地公开为scatter_source。现在,我们可以开始绘图了:

%%local
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('ggplot')

fig = plt.figure(figsize=(12,9))
ax = fig.add_subplot(1, 1, 1)
ax.scatter(
      list(scatter_source['Cylinders'])
    , list(scatter_source['Displacement'])
    , s = 200
    , alpha = 0.5
)

ax.set_xlabel('Cylinders')
ax.set_ylabel('Displacement')

ax.set_title('Relationship between cylinders and displacement')

首先,我们加载 Matplotlib 库并对其进行设置。

有关这些 Matplotlib 命令的更详细解释,请参阅绘制直方图配方。

接下来,我们创建一个图形并向其添加一个子图。然后,我们使用我们的数据绘制散点图;x轴将代表汽缸数,y轴将代表排量。最后,我们设置轴标签和图表标题。

最终结果如下所示:

还有更多...

您可以使用bokeh创建前面图表的交互版本:

%%local 
from bokeh.io import show
from bokeh.plotting import figure
from bokeh.io import output_notebook
output_notebook()

p = figure(title = 'Relationship between cylinders and displacement')
p.xaxis.axis_label = 'Cylinders'
p.yaxis.axis_label = 'Displacement'

p.circle( list(scatter_source['Cylinders'])
         , list(scatter_source['Displacement'])
         , fill_alpha=0.2, size=10)

show(p)

首先,我们创建画布,即我们将绘图的图形。接下来,我们设置我们的标签。最后,我们使用.circle(...)方法在画布上绘制点。

最终结果如下所示:

第五章:使用 MLlib 进行机器学习

在本章中,我们将介绍如何使用 PySpark 的 MLlib 模块构建机器学习模型。尽管它现在已经被弃用,大多数模型现在都被移动到 ML 模块,但如果您将数据存储在 RDD 中,您可以使用 MLlib 进行机器学习。您将学习以下示例:

  • 加载数据

  • 探索数据

  • 测试数据

  • 转换数据

  • 标准化数据

  • 创建用于训练的 RDD

  • 预测人口普查受访者的工作小时数

  • 预测人口普查受访者的收入水平

  • 构建聚类模型

  • 计算性能统计

加载数据

为了构建一个机器学习模型,我们需要数据。因此,在开始之前,我们需要读取一些数据。在这个示例中,以及在本章的整个过程中,我们将使用 1994 年的人口普查收入数据。

准备工作

要执行这个示例,您需要一个可用的 Spark 环境。如果没有,您可能需要回到第一章,安装和配置 Spark,并按照那里找到的示例进行操作。

数据集来自archive.ics.uci.edu/ml/datasets/Census+Income

数据集位于本书的 GitHub 存储库的data文件夹中。

本章中您需要的所有代码都可以在我们为本书设置的 GitHub 存储库中找到:bit.ly/2ArlBck;转到Chapter05,打开5\. Machine Learning with MLlib.ipynb笔记本。

不需要其他先决条件。

如何做...

我们将数据读入 DataFrame,这样我们就可以更容易地处理。稍后,我们将把它转换成带标签的 RDD。要读取数据,请执行以下操作:

census_path = '../data/census_income.csv'

census = spark.read.csv(
    census_path
    , header=True
    , inferSchema=True
)

它是如何工作的...

首先,我们指定了我们数据集的路径。在我们的情况下,与本书中使用的所有其他数据集一样,census_income.csv位于data文件夹中,可以从父文件夹中访问。

接下来,我们使用SparkSession.read属性,它返回DataFrameReader对象。.csv(...)方法的第一个参数指定了数据的路径。我们的数据集在第一行中有列名,因此我们使用header选项指示读取器使用第一行作为列名。inferSchema参数指示DataFrameReader自动检测每列的数据类型。

让我们检查数据类型推断是否正确:

census.printSchema()

上述代码产生以下输出:

正如您所看到的,某些列的数据类型被正确地检测到了;如果没有inferSchema参数,所有列将默认为字符串。

还有更多...

然而,我们的数据集存在一个小问题:大多数字符串列都有前导或尾随空格。以下是您可以纠正此问题的方法:

import pyspark.sql.functions as func

for col, typ in census.dtypes:
    if typ == 'string':
        census = census.withColumn(
            col
            , func.ltrim(func.rtrim(census[col]))
        )

我们循环遍历census DataFrame 中的所有列。

DataFrame 的.dtypes属性是一个元组列表,其中第一个元素是列名,第二个元素是数据类型。

如果列的类型等于字符串,我们应用两个函数:.ltrim(...),它删除字符串中的任何前导空格,以及.rtrim(...),它删除字符串中的任何尾随空格。.withColumn(...)方法不会附加任何新列,因为我们重用相同的列名:col

探索数据

直接进入对数据建模是几乎每个新数据科学家都会犯的错误;我们太急于获得回报阶段,所以忘记了大部分时间实际上都花在清理数据和熟悉数据上。在这个示例中,我们将探索人口普查数据集。

准备工作

要执行这个示例,您需要一个可用的 Spark 环境。您应该已经完成了之前的示例,其中我们将人口普查数据加载到了 DataFrame 中。

不需要其他先决条件。

如何做...

首先,我们列出我们想要保留的所有列:

cols_to_keep = census.dtypes

cols_to_keep = (
    ['label','age'
     ,'capital-gain'
     ,'capital-loss'
     ,'hours-per-week'
    ] + [
        e[0] for e in cols_to_keep[:-1] 
        if e[1] == 'string'
    ]
)

接下来,我们选择数值和分类特征,因为我们将分别探索这些特征:

census_subset = census.select(cols_to_keep)

cols_num = [
    e[0] for e in census_subset.dtypes 
    if e[1] == 'int'
]
cols_cat = [
    e[0] for e in census_subset.dtypes[1:] 
    if e[1] == 'string'
]

工作原理...

首先,我们提取所有带有相应数据类型的列。

我们已经在上一节中讨论了 DataFrame 存储的.dtypes属性。

我们将只保留label,这是一个包含有关一个人是否赚超过 5 万美元的标识符的列,以及其他一些数字列。此外,我们保留所有的字符串特征。

接下来,我们创建一个仅包含所选列的 DataFrame,并提取所有的数值和分类列;我们分别将它们存储在cols_numcols_cat列表中。

数值特征

让我们探索数值特征。就像在第四章中的为建模准备数据一样,对于数值变量,我们将计算一些基本的描述性统计:

import pyspark.mllib.stat as st
import numpy as np

rdd_num = (
    census_subset
    .select(cols_num)
    .rdd
    .map(lambda row: [e for e in row])
)

stats_num = st.Statistics.colStats(rdd_num)

for col, min_, mean_, max_, var_ in zip(
      cols_num
    , stats_num.min()
    , stats_num.mean()
    , stats_num.max()
    , stats_num.variance()
):
    print('{0}: min->{1:.1f}, mean->{2:.1f}, max->{3:.1f}, stdev->{4:.1f}'
          .format(col, min_, mean_, max_, np.sqrt(var_)))

首先,我们进一步将我们的census_subset子集化为仅包含数值列。接下来,我们提取底层 RDD。由于此 RDD 的每个元素都是一行,因此我们首先需要创建一个列表,以便我们可以使用它;我们使用.map(...)方法实现这一点。

有关Row类的文档,请查看spark.apache.org/docs/latest/api/python/pyspark.sql.html#pyspark.sql.Row

现在我们的 RDD 准备好了,我们只需从 MLlib 的统计模块中调用.colStats(...)方法。.colStats(...)接受一个数值值的 RDD;这些可以是列表或向量(密集或稀疏,参见pyspark.mllib.linalg.Vectors的文档spark.apache.org/docs/latest/api/python/pyspark.mllib.html#pyspark.mllib.linalg.Vectors)。返回一个MultivariateStatisticalSummary特征,其中包含计数、最大值、平均值、最小值、L1 和 L2 范数、非零观测数和方差等数据。

如果您熟悉 C++或 Java,traits 可以被视为虚拟类(C++)或接口(Java)。您可以在docs.scala-lang.org/tour/traits.html上阅读更多关于 traits 的信息。

在我们的示例中,我们只选择了最小值、平均值、最大值和方差。这是我们得到的结果:

因此,平均年龄约为 39 岁。但是,我们的数据集中有一个 90 岁的异常值。就资本收益或损失而言,人口普查调查对象似乎赚的比亏的多。平均而言,受访者每周工作 40 小时,但我们有人工作接近 100 小时。

分类特征

对于分类数据,我们无法计算简单的描述性统计。因此,我们将计算每个分类列中每个不同值的频率。以下是一个可以实现这一目标的代码片段:

rdd_cat = (
    census_subset
    .select(cols_cat + ['label'])
    .rdd
    .map(lambda row: [e for e in row])
)

results_cat = {}

for i, col in enumerate(cols_cat + ['label']):
    results_cat[col] = (
        rdd_cat
        .groupBy(lambda row: row[i])
        .map(lambda el: (el[0], len(el[1])))
        .collect()
    )

首先,我们重复了我们刚刚为数值列所做的工作,但是对于分类列:我们将census_subset子集化为仅包含分类列和标签,访问底层 RDD,并将每行转换为列表。我们将结果存储在results_cat字典中。我们遍历所有分类列,并使用.groupBy(...)转换来聚合数据。最后,我们创建一个元组列表,其中第一个元素是值(el[0]),第二个元素是频率(len(el[1]))。

.groupBy(...)”转换输出一个列表,其中第一个元素是值,第二个元素是一个pyspark.resultIterable.ResultIterable对象,实际上是包含该值的 RDD 中的所有元素的列表。

现在我们已经聚合了我们的数据,让我们看看我们要处理的内容:

上述列表为简洁起见进行了缩写。检查(或运行代码)我们的 GitHub 存储库中的5\. Machine Learning with MLlib.ipynb笔记本。

正如你所看到的,我们处理的是一个不平衡的样本:它严重偏向男性,大部分是白人。此外,在 1994 年,收入超过 50000 美元的人并不多,只有大约四分之一。

还有更多...

你可能想要检查的另一个重要指标是数值变量之间的相关性。使用 MLlib 计算相关性非常容易:

correlations = st.Statistics.corr(rdd_num)

.corr(...)操作返回一个 NumPy 数组或数组,换句话说,一个矩阵,其中每个元素都是皮尔逊(默认)或斯皮尔曼相关系数。

要打印出来,我们只需循环遍历所有元素:

for i, el_i in enumerate(abs(correlations) > 0.05):
    print(cols_num[i])

    for j, el_j in enumerate(el_i):
        if el_j and j != i:
            print(
                '    '
                , cols_num[j]
                , correlations[i][j]
            )

    print()

我们只打印矩阵的上三角部分,不包括对角线。使用 enumerate 允许我们打印出列名,因为相关性 NumPy 矩阵没有列出它们。这是我们得到的内容:

正如你所看到的,我们的数值变量之间并没有太多的相关性。这实际上是件好事,因为我们可以在我们的模型中使用它们,因为我们不会遭受太多的多重共线性。

如果你不知道什么是多重共线性,请查看这个讲座:onlinecourses.science.psu.edu/stat501/node/343

另请参阅

测试数据

为了构建一个成功的统计或机器学习模型,我们需要遵循一个简单(但困难!)的规则:尽可能简单(这样它才能很好地概括被建模的现象),但不要太简单(这样它就失去了预测的主要能力)。这种情况的视觉示例如下(来自bit.ly/2GpRybB):

中间的图表显示了一个很好的拟合:模型线很好地跟随了真实函数。左侧图表上的模型线过分简化了现象,几乎没有预测能力(除了少数几点)——这是欠拟合的完美例子。右侧的模型线几乎完美地跟随了训练数据,但如果出现新数据,它很可能会错误地表示——这是一种称为过拟合的概念,即它不能很好地概括。从这三个图表中可以看出,模型的复杂性需要恰到好处,这样它才能很好地模拟现象。

一些机器学习模型有过度训练的倾向。例如,任何试图在输入数据和独立变量(或标签)之间找到映射(函数)的模型都有过拟合的倾向;这些模型包括参数回归模型,如线性或广义回归模型,以及最近(再次!)流行的神经网络(或深度学习模型)。另一方面,一些基于决策树的模型(如随机森林)即使是更复杂的模型也不太容易过拟合。

那么,我们如何才能得到恰到好处的模型呢?有四个经验法则:

  • 明智地选择你的特征

  • 不要过度训练,或选择不太容易过拟合的模型

  • 用从数据集中随机选择的数据运行多个模型估计

  • 调整超参数

在这个示例中,我们将专注于第一个要点,其余要点将在本章和下两章的一些示例中涵盖。

准备工作

要执行此示例,您需要一个可用的 Spark 环境。您可能已经完成了加载数据示例,其中我们将人口普查数据加载到了一个 DataFrame 中。

不需要其他先决条件。

如何做...

为了找到问题的最佳特征,我们首先需要了解我们正在处理的问题,因为不同的方法将用于选择回归问题或分类器中的特征:

  • 回归:在回归中,您的目标(或地面真相)是连续变量(例如每周工作小时数)。您有两种方法来选择最佳特征:

  • 皮尔逊相关系数:我们在上一个示例中已经涵盖了这个。如前所述,相关性只能在两个数值(连续)特征之间计算。

  • 方差分析(ANOVA):这是一个解释(或测试)观察结果分布的工具,条件是某些类别。因此,它可以用来选择连续因变量的最具歧视性(分类)特征。

  • 分类:在分类中,您的目标(或标签)是两个(二项式)或多个(多项式)级别的离散变量。还有两种方法可以帮助选择最佳特征:

  • 线性判别分析(LDA):这有助于找到最能解释分类标签方差的连续特征的线性组合

  • χ² 检验:测试两个分类变量之间的独立性

目前,Spark 允许我们在可比较的变量之间测试(或选择)最佳特征;它只实现了相关性(我们之前涵盖的pyspark.mllib.stat.Statistics.corr(...))和χ²检验(pyspark.mllib.stat.Statistics.chiSqTest(...)pyspark.mllib.feature.ChiSqSelector(...)方法)。

在这个示例中,我们将使用.chiSqTest(...)来测试我们的标签(即指示某人是否赚取超过 5 万美元的指标)和人口普查回答者的职业之间的独立性。以下是一个为我们执行此操作的片段:

import pyspark.mllib.linalg as ln

census_occupation = (
    census
    .groupby('label')
    .pivot('occupation')
    .count()
)

census_occupation_coll = (
    census_occupation
    .rdd
    .map(lambda row: (row[1:]))
    .flatMap(lambda row: row)
    .collect()
)

len_row = census_occupation.count()
dense_mat = ln.DenseMatrix(
    len_row
    , 2
    , census_occupation_coll
    , True)
chi_sq = st.Statistics.chiSqTest(dense_mat)

print(chi_sq.pValue)

它是如何工作的...

首先,我们导入 MLlib 的线性代数部分;稍后我们将使用一些矩阵表示。

接下来,我们建立一个数据透视表,其中我们按occupation特征进行分组,并按label列(<=50K>50K)进行数据透视。每次出现都会被计算,结果如下表所示:

接下来,我们通过访问底层 RDD 并仅选择具有映射转换的计数来展平输出:.map(lambda row: (row[1:])).flatMap(...)转换创建了我们需要的所有值的长列表。我们在驱动程序上收集所有数据,以便稍后创建DenseMatrix

您应该谨慎使用.collect(...)操作,因为它会将所有数据带到驱动程序。正如您所看到的,我们只带来了数据集的高度聚合表示。

一旦我们在驱动程序上拥有所有数字,我们就可以创建它们的矩阵表示;我们将有一个 15 行 2 列的矩阵。首先,我们通过检查census_occupation元素的计数来检查有多少个不同的职业值。接下来,我们调用DenseMatrix(...)构造函数来创建我们的矩阵。第一个参数指定行数,第二个参数指定列数。第三个参数指定数据,最后一个指示数据是否被转置。密集表示如下:

以更易读的格式(作为 NumPy 矩阵)呈现如下:

现在,我们只需调用.chiSqTest(...)并将我们的矩阵作为其唯一参数传递。剩下的就是检查pValue以及是否拒绝了nullHypothesis

因此,正如您所看到的,pValue0.0,因此我们可以拒绝空假设,即宣称赚取 5 万美元以上和赚取 5 万美元以下的人之间的职业分布相同。因此,我们可以得出结论,正如 Spark 告诉我们的那样,结果的发生是统计独立的,也就是说,职业应该是某人赚取 5 万美元以上的强有力指标。

另请参阅...

转换数据

机器学习ML)是一个旨在使用机器(计算机)来理解世界现象并预测其行为的研究领域。为了构建一个 ML 模型,我们所有的数据都需要是数字。由于我们几乎所有的特征都是分类的,我们需要转换我们的特征。在这个示例中,我们将学习如何使用哈希技巧和虚拟编码。

做好准备

要执行此示例,您需要有一个可用的 Spark 环境。您可能已经完成了加载数据示例,其中我们将人口普查数据加载到了 DataFrame 中。

不需要其他先决条件。

如何做...

我们将将数据集的维度大致减少一半,因此首先我们需要提取每列中不同值的总数:

len_ftrs = []

for col in cols_cat:
    (
        len_ftrs
        .append(
            (col
             , census
                 .select(col)
                 .distinct()
                 .count()
            )
        )
    )

len_ftrs = dict(len_ftrs)

接下来,对于每个特征,我们将使用.HashingTF(...)方法来对我们的数据进行编码:

import pyspark.mllib.feature as feat
final_data = (    census
    .select(cols_to_keep)
    .rdd
    .map(lambda row: [
        list(
            feat.HashingTF(int(len_ftrs[col] / 2.0))
            .transform(row[i])
            .toArray()
        ) if i >= 5
        else [row[i]] 
        for i, col in enumerate(cols_to_keep)]
    )
)

final_data.take(3)

它是如何工作的...

首先,我们循环遍历所有的分类变量,并附加一个元组,其中包括列名(col)和在该列中找到的不同值的计数。后者是通过选择感兴趣的列,运行.distinct()转换,并计算结果值的数量来实现的。len_ftrs现在是一个元组列表。通过调用dict(...)方法,Python 将创建一个字典,该字典将第一个元组元素作为键,第二个元素作为相应的值。生成的字典如下所示:

现在我们知道了每个特征中不同值的总数,我们可以使用哈希技巧。首先,我们导入 MLlib 的特征组件,因为那里有.HashingTF(...)。接下来,我们将 census DataFrame 子集化为我们想要保留的列。然后,我们在基础 RDD 上使用.map(...)转换:对于每个元素,我们枚举所有列,如果列的索引大于或等于五,我们创建一个新的.HashingTF(...)实例,然后用它来转换值并将其转换为 NumPy 数组。对于.HashingTF(...)方法,您唯一需要指定的是输出元素的数量;在我们的情况下,我们大致将不同值的数量减半,因此我们将有一些哈希碰撞,但这没关系。

供您参考,我们的cols_to_keep如下:

在对我们当前的数据集final_data进行上述操作之后,它看起来如下;请注意,格式可能看起来有点奇怪,但我们很快将准备好创建训练 RDD:

还有更多...

唯一剩下的就是处理我们的标签;如您所见,它仍然是一个分类变量。但是,由于它只有两个值,我们可以将其编码如下:

def labelEncode(label):
    return [int(label[0] == '>50K')]

final_data = (
    final_data
    .map(lambda row: labelEncode(row[0]) 
         + [item 
            for sublist in row[1:] 
            for item in sublist]
        )
)

labelEncode(...)方法获取标签并检查它是否为'>50k';如果是,我们得到一个布尔值 true,否则我们得到 false。我们可以通过简单地将布尔数据包装在 Python 的int(...)方法中来表示布尔数据为整数。

最后,我们再次使用.map(...),在那里我们将row的第一个元素(标签)传递给labelEncode(...)方法。然后,我们循环遍历所有剩余的列表并将它们组合在一起。代码的这部分一开始可能看起来有点奇怪,但实际上很容易理解。我们循环遍历所有剩余的元素(row[1:]),并且由于每个元素都是一个列表(因此我们将其命名为sublist),我们创建另一个循环(for item in sublist部分)来提取单个项目。生成的 RDD 如下所示:

另请参阅...

数据标准化

数据标准化(或归一化)对许多原因都很重要:

  • 某些算法在标准化(或归一化)数据上收敛得更快

  • 如果您的输入变量在不同的尺度上,系数的可解释性可能很难或得出的结论可能是错误的

  • 对于某些模型,如果不进行标准化,最优解可能是错误的

在这个操作中,我们将向您展示如何标准化数据,因此如果您的建模项目需要标准化数据,您将知道如何操作。

准备工作

要执行此操作,您需要拥有一个可用的 Spark 环境。您可能已经完成了之前的操作,其中我们对人口普查数据进行了编码。

不需要其他先决条件。

操作步骤...

MLlib 提供了一个方法来为我们完成大部分工作。尽管以下代码一开始可能会令人困惑,但我们将逐步介绍它:

standardizer = feat.StandardScaler(True, True)
sModel = standardizer.fit(final_data.map(lambda row: row[1:]))
final_data_scaled = sModel.transform(final_data.map(lambda row: row[1:]))

final_data = (
    final_data
    .map(lambda row: row[0])
    .zipWithIndex()
    .map(lambda row: (row[1], row[0]))
    .join(
        final_data_scaled
        .zipWithIndex()
        .map(lambda row: (row[1], row[0]))
    )
    .map(lambda row: row[1])
)

final_data.take(1)

工作原理...

首先,我们创建StandardScaler(...)对象。设置为True的两个参数——前者代表均值,后者代表标准差——表示我们希望模型使用 Z 分数对特征进行标准化:,其中f特征的第i(th)观察值,μ(f)是f特征中所有观察值的均值,σ^(f)是f特征中所有观察值的标准差。

接下来,我们使用StandardScaler(...)对数据进行.fit(...)。请注意,我们不会对第一个特征进行标准化,因为它实际上是我们的标签。最后,我们对数据集进行.transform(...),以获得经过缩放的特征。

然而,由于我们不对标签进行缩放,我们需要以某种方式将其带回我们的缩放数据集。因此,首先从final_data中提取标签(使用.map(lamba row: row[0])转换)。然而,我们将无法将其与final_data_scaled直接连接,因为没有键可以连接。请注意,我们实际上希望以逐行方式进行连接。因此,我们使用.zipWithIndex()方法,它会返回一个元组,第一个元素是数据,第二个元素是行号。由于我们希望根据行号进行连接,我们需要将其带到元组的第一个位置,因为这是 RDD 的.join(...)的工作方式;我们通过第二个.map(...)操作实现这一点。

在 RDD 中,.join(...)操作不能明确指定键;两个 RDD 都需要是两个元素的元组,其中第一个元素是键,第二个元素是数据。

一旦连接完成,我们只需使用.map(lambda row: row[1])转换来提取连接的数据。

现在我们的数据看起来是这样的:

我们还可以查看sModel,以了解用于转换我们的数据的均值和标准差:

创建用于训练的 RDD

在我们可以训练 ML 模型之前,我们需要创建一个 RDD,其中每个元素都是一个标记点。在这个操作中,我们将使用之前操作中创建的final_data RDD 来准备我们的训练 RDD。

准备工作

要执行此操作,您需要拥有一个可用的 Spark 环境。您可能已经完成了之前的操作,当时我们对编码的人口普查数据进行了标准化。

不需要其他先决条件。

操作步骤...

许多 MLlib 模型需要一个标记点的 RDD 进行训练。下一个代码片段将为我们创建这样的 RDD,以构建分类和回归模型。

分类

以下是创建分类标记点 RDD 的片段,我们将使用它来预测某人是否赚取超过$50,000:

final_data_income = (
    final_data
    .map(lambda row: reg.LabeledPoint(
        row[0]
        , row[1:]
        )
)

回归

以下是创建用于预测人们工作小时数的回归标记点 RDD 的片段:

mu, std = sModel.mean[3], sModel.std[3]

final_data_hours = (
    final_data
    .map(lambda row: reg.LabeledPoint(
        row[1][3] * std + mu
        , ln.Vectors.dense([row[0]] + list(row[1][0:3]) + list(row[1][4:]))
        )
)

工作原理...

在创建 RDD 之前,我们必须导入pyspark.mllib.regression子模块,因为那里可以访问LabeledPoint类:

import pyspark.mllib.regression as reg

接下来,我们只需循环遍历final_data RDD 的所有元素,并使用.map(...)转换为每个元素创建一个带标签的点。

LabeledPoint(...)的第一个参数是标签。如果您查看这两个代码片段,它们之间唯一的区别是我们认为标签和特征是什么。

作为提醒,分类问题旨在找到观察结果属于特定类别的概率;因此,标签通常是分类的,换句话说,是离散的。另一方面,回归问题旨在预测给定观察结果的值;因此,标签通常是数值的,或者连续的。

因此,在final_data_income的情况下,我们使用二进制指示符,表示人口普查受访者是否赚得更多(值为 1)还是更少(标签等于 0)50,000 美元,而在final_data_hours中,我们使用hours-per-week特征(请参阅加载数据示例),在我们的情况下,它是final_data RDD 的每个元素的第五部分。请注意,对于此标签,我们需要将其缩放回来,因此我们需要乘以标准差并加上均值。

我们在这里假设您正在通过5\. Machine Learning with MLlib.ipynb笔记本进行工作,并且已经创建了sModel对象。如果没有,请返回到上一个示例,并按照那里概述的步骤进行操作。

LabeledPoint(...)的第二个参数是所有特征的向量。您可以传递 NumPy 数组、列表、scipy.sparse列矩阵或pyspark.mllib.linalg.SparseVectorpyspark.mllib.linalg.DenseVector;在我们的情况下,我们使用哈希技巧对所有特征进行了编码,因此我们将特征编码为DenseVector

还有更多...

我们可以使用完整数据集来训练我们的模型,但是我们会遇到另一个问题:我们如何评估我们的模型有多好?因此,任何数据科学家通常都会将数据拆分为两个子集:训练和测试。

请参阅此示例的另请参阅部分,了解为什么这通常还不够好,您实际上应该将数据拆分为训练、测试和验证数据集。

以下是两个代码片段,显示了在 PySpark 中如何轻松完成此操作:

(
    final_data_income_train
    , final_data_income_test
) = (
    final_data_income.randomSplit([0.7, 0.3])
)

这是第二个:

(
    final_data_hours_train
    , final_data_hours_test
) = (
    final_data_hours.randomSplit([0.7, 0.3])
)

通过简单调用 RDD 的.randomSplit(...)方法,我们可以快速将 RDD 分成训练和测试子集。.randomSplit(...)方法的唯一必需参数是一个列表,其中每个元素指定要随机选择的数据集的比例。请注意,这些比例需要加起来等于 1。

如果我们想要获取训练、测试和验证子集,我们可以传递一个包含三个元素的列表。

另请参阅

  • 为什么应该将数据拆分为三个数据集,而不是两个,可以在这里很好地解释:bit.ly/2GFyvtY

预测人口普查受访者的工作小时数

在这个示例中,我们将构建一个简单的线性回归模型,旨在预测人口普查受访者每周工作的小时数。

准备工作

要执行此示例,您需要一个可用的 Spark 环境。您可能已经通过之前的示例创建了用于估计回归模型的训练和测试数据集。

不需要其他先决条件。

如何做...

使用 MLlib 训练模型非常简单。请参阅以下代码片段:

workhours_model_lm = reg.LinearRegressionWithSGD.train(final_data_hours_train)

它是如何工作的...

正如您所看到的,我们首先创建LinearRegressionWithSGD对象,并调用其.train(...)方法。

对于随机梯度下降的不同派生的很好的概述,请查看这个链接:ruder.io/optimizing-gradient-descent/

我们传递给方法的第一个,也是唯一需要的参数是我们之前创建的带有标记点的 RDD。不过,您可以指定一系列参数:

  • 迭代次数;默认值为100

  • 步长是 SGD 中使用的参数;默认值为1.0

  • miniBatchFraction指定在每个 SGD 迭代中使用的数据比例;默认值为1.0

  • initialWeights参数允许我们将系数初始化为特定值;它没有默认值,算法将从权重等于0.0开始

  • 正则化类型参数regType允许我们指定所使用的正则化类型:'l1'表示 L1 正则化,'l2'表示 L2 正则化;默认值为None,无正则化

  • regParam参数指定正则化参数;默认值为0.0

  • 该模型也可以拟合截距,但默认情况下未设置;默认值为 false

  • 在训练之前,默认情况下,模型可以验证数据

  • 您还可以指定convergenceTol;默认值为0.001

现在让我们看看我们的模型预测工作小时的效果如何:

small_sample_hours = sc.parallelize(final_data_hours_test.take(10))

for t,p in zip(
    small_sample_hours
        .map(lambda row: row.label)
        .collect()
    , workhours_model_lm.predict(
        small_sample_hours
            .map(lambda row: row.features)
    ).collect()):
    print(t,p)

首先,从我们的完整测试数据集中,我们选择 10 个观察值(这样我们可以在屏幕上打印出来)。接下来,我们从测试数据集中提取真实值,而对于预测,我们只需调用workhours_model_lm模型的.predict(...)方法,并传递.features向量。这是我们得到的结果:

如您所见,我们的模型效果不佳,因此需要进一步改进。然而,这超出了本章和本书的范围。

预测人口普查受访者的收入水平

在本示例中,我们将向您展示如何使用 MLlib 解决分类问题,方法是构建两个模型:无处不在的逻辑回归和稍微复杂一些的模型,即SVM支持向量机)。

准备工作

要执行此示例,您需要一个可用的 Spark 环境。您可能已经完成了为训练创建 RDD示例,在那里我们为估计分类模型创建了训练和测试数据集。

不需要其他先决条件。

如何做...

就像线性回归一样,构建逻辑回归始于创建LogisticRegressionWithSGD对象:

import pyspark.mllib.classification as cl

income_model_lr = cl.LogisticRegressionWithSGD.train(final_data_income_train)

工作原理...

LinearRegressionWithSGD模型一样,唯一需要的参数是带有标记点的 RDD。此外,您可以指定相同的一组参数:

  • 迭代次数;默认值为100

  • 步长是 SGD 中使用的参数;默认值为1.0

  • miniBatchFraction指定在每个 SGD 迭代中使用的数据比例;默认值为1.0

  • initialWeights参数允许我们将系数初始化为特定值;它没有默认值,算法将从权重等于0.0开始

  • 正则化类型参数regType允许我们指定所使用的正则化类型:l1表示 L1 正则化,l2表示 L2 正则化;默认值为None,无正则化

  • regParam参数指定正则化参数;默认值为0.0

  • 该模型也可以拟合截距,但默认情况下未设置;默认值为 false

  • 在训练之前,默认情况下,模型可以验证数据

  • 您还可以指定convergenceTol;默认值为0.001

在完成训练后返回的LogisticRegressionModel(...)对象允许我们利用该模型。通过将特征向量传递给.predict(...)方法,我们可以预测观察值最可能关联的类别。

任何分类模型都会产生一组概率,逻辑回归也不例外。在二元情况下,我们可以指定一个阈值,一旦突破该阈值,就会表明观察结果将被分配为等于 1 的类,而不是 0;此阈值通常设置为0.5LogisticRegressionModel(...)默认情况下假定为0.5,但您可以通过调用.setThreshold(...)方法并传递介于 0 和 1 之间(不包括)的所需阈值值来更改它。

让我们看看我们的模型表现如何:

small_sample_income = sc.parallelize(final_data_income_test.take(10))

for t,p in zip(
    small_sample_income
        .map(lambda row: row.label)
        .collect()
    , income_model_lr.predict(
        small_sample_income
            .map(lambda row: row.features)
    ).collect()):
    print(t,p)

与线性回归示例一样,我们首先从测试数据集中提取 10 条记录,以便我们可以在屏幕上适应它们。接下来,我们提取所需的标签,并调用.predict(...)类的income_model_lr模型。这是我们得到的结果:

因此,在 10 条记录中,我们得到了 9 条正确的。还不错。

计算性能统计配方中,我们将学习如何使用完整的测试数据集更正式地评估我们的模型。

还有更多...

逻辑回归通常是用于评估其他分类模型相对性能的基准,即它们是表现更好还是更差。然而,逻辑回归的缺点是它无法处理两个类无法通过一条线分开的情况。SVM 没有这种问题,因为它们的核可以以非常灵活的方式表达:

income_model_svm = cl.SVMWithSGD.train(
    final_data_income
    , miniBatchFraction=1/2.0
)

在这个例子中,就像LogisticRegressionWithSGD模型一样,我们可以指定一系列参数(我们不会在这里重复它们)。但是,miniBatchFraction参数指示 SVM 模型在每次迭代中仅使用一半的数据;这有助于防止过拟合。

small_sample_income RDD 中计算的 10 个观察结果与逻辑回归模型的计算方式相同:

for t,p in zip(
    small_sample_income
        .map(lambda row: row.label)
        .collect()
    , income_model_svm.predict(
        small_sample_income
            .map(lambda row: row.features)
    ).collect()):
    print(t,p)

该模型产生与逻辑回归模型相同的结果,因此我们不会在这里重复它们。但是,在计算性能统计配方中,我们将看到它们的不同。

构建聚类模型

通常,很难获得有标签的数据。而且,有时您可能希望在数据集中找到潜在的模式。在这个配方中,我们将学习如何在 Spark 中构建流行的 k-means 聚类模型。

准备工作

要执行此配方,您需要拥有一个可用的 Spark 环境。您应该已经完成了标准化数据配方,其中我们对编码的人口普查数据进行了标准化。

不需要其他先决条件。

如何做...

就像分类或回归模型一样,在 Spark 中构建聚类模型非常简单。以下是旨在在人口普查数据中查找模式的代码:

import pyspark.mllib.clustering as clu

model = clu.KMeans.train(
    final_data.map(lambda row: row[1])
    , 2
    , initializationMode='random'
    , seed=666
)

它是如何工作的...

首先,我们需要导入 MLlib 的聚类子模块。就像以前一样,我们首先创建聚类估计器对象KMeans.train(...)方法需要两个参数:我们要在其中找到集群的 RDD,以及我们期望的集群数。我们还选择通过指定initializationMode来随机初始化集群的质心;这个的默认值是k-means||。其他参数包括:

  • maxIterations指定估计应在多少次迭代后停止;默认值为100

  • initializationSteps仅在使用默认初始化模式时有用;此参数的默认值为2

  • epsilon是一个停止标准-如果所有质心的中心移动(以欧几里德距离表示)小于此值,则迭代停止;默认值为0.0001

  • initialModel允许您指定以KMeansModel形式先前估计的中心;默认值为None

还有更多...

一旦估计出模型,我们就可以使用它来预测聚类,并查看我们的模型实际上有多好。但是,目前,Spark 并没有提供评估聚类模型的手段。因此,我们将使用 scikit-learn 提供的度量标准:

import sklearn.metrics as m

predicted = (
    model
        .predict(
            final_data.map(lambda row: row[1])
        )
)
predicted = predicted.collect()
true = final_data.map(lambda row: row[0]).collect()

print(m.homogeneity_score(true, predicted))
print(m.completeness_score(true, predicted))

聚类指标位于 scikit-learn 的.metrics子模块中。我们使用了两个可用的指标:同质性和完整性。同质性衡量了一个簇中的所有点是否来自同一类,而完整性得分估计了对于给定的类,所有点是否最终在同一个簇中;任一得分为 1 表示一个完美的模型。

让我们看看我们得到了什么:

嗯,我们的聚类模型表现不佳:15%的同质性得分意味着剩下的 85%观察值被错误地聚类,我们只正确地聚类了∼12%属于同一类的所有观察值。

另请参阅

计算性能统计

在之前的示例中,我们已经看到了我们的分类和回归模型预测的一些值,以及它们与原始值的差距。在这个示例中,我们将学习如何完全计算这些模型的性能统计数据。

准备工作

为了执行这个示例,您需要有一个可用的 Spark 环境,并且您应该已经完成了本章前面介绍的预测人口普查受访者的工作小时数预测人口普查受访者的收入水平的示例。

不需要其他先决条件。

如何做...

在 Spark 中获取回归和分类的性能指标非常简单:

import pyspark.mllib.evaluation as ev

(...)

metrics_lm = ev.RegressionMetrics(true_pred_reg)

(...)

metrics_lr = ev.BinaryClassificationMetrics(true_pred_class_lr)

它是如何工作的...

首先,我们加载评估模块;这样做会暴露.RegressionMetrics(...).BinaryClassificationMetrics(...)方法,我们可以使用它们。

回归指标

true_pred_reg是一个元组的 RDD,其中第一个元素是我们线性回归模型的预测值,第二个元素是期望值(每周工作小时数)。以下是我们创建它的方法:

true_pred_reg = (
    final_data_hours_test
    .map(lambda row: (
         float(workhours_model_lm.predict(row.features))
         , row.label))
)

metrics_lm对象包含各种指标:解释方差平均绝对误差均方误差r2均方根误差。在这里,我们只打印其中的一些:

print('R²: ', metrics_lm.r2)
print('Explained Variance: ', metrics_lm.explainedVariance)
print('meanAbsoluteError: ', metrics_lm.meanAbsoluteError)

让我们看看线性回归模型的结果:

毫不意外,考虑到我们已经看到的内容,模型表现非常糟糕。不要对负的 R 平方感到太惊讶;如果模型的预测是荒谬的,R 平方可以变成负值,也就是说,R 平方的值是不合理的。

分类指标

我们将评估我们之前构建的两个模型;这是逻辑回归模型:

true_pred_class_lr = (
    final_data_income_test
    .map(lambda row: (
        float(income_model_lr.predict(row.features))
        , row.label))
)

metrics_lr = ev.BinaryClassificationMetrics(true_pred_class_lr)

print('areaUnderPR: ', metrics_lr.areaUnderPR)
print('areaUnderROC: ', metrics_lr.areaUnderROC)

这是 SVM 模型:

true_pred_class_svm = (
    final_data_income_test
    .map(lambda row: (
        float(income_model_svm.predict(row.features))
        , row.label))
)

metrics_svm = ev.BinaryClassificationMetrics(true_pred_class_svm)

print('areaUnderPR: ', metrics_svm.areaUnderPR)
print('areaUnderROC: ', metrics_svm.areaUnderROC)

两个指标——精确率-召回率PR)曲线下的面积和接收者操作特征ROC)曲线下的面积——允许我们比较这两个模型。

查看关于这两个指标的有趣讨论:stats.stackexchange.com/questions/7207/roc-vs-precision-and-recall-curves

让我们看看我们得到了什么。对于逻辑回归,我们有:

对于 SVM,我们有:

有点令人惊讶的是,SVM 的表现比逻辑回归稍差。让我们看看混淆矩阵,看看这两个模型的区别在哪里。对于逻辑回归,我们可以用以下代码实现:

(
    true_pred_class_lr
    .map(lambda el: ((el), 1))
    .reduceByKey(lambda x,y: x+y)
    .take(4)
)

然后我们得到:

对于 SVM,代码看起来基本相同,唯一的区别是输入 RDD:

(
    true_pred_class_svm
    .map(lambda el: ((el), 1))
    .reduceByKey(lambda x,y: x+y)
    .take(4)
)

通过上述步骤,我们得到:

正如你所看到的,逻辑回归在预测正例和负例时更准确,因此实现了更少的误分类(假阳性和假阴性)观察。然而,差异并不是那么明显。

要计算总体错误率,我们可以使用以下代码:

trainErr = (
    true_pred_class_lr
    .filter(lambda lp: lp[0] != lp[1]).count() 
    / float(true_pred_class_lr.count())
)
print("Training Error = " + str(trainErr))

对于 SVM,前面的代码看起来一样,唯一的区别是使用true_pred_class_svm而不是true_pred_class_lr。前面的产生了以下结果。对于逻辑回归,我们得到:

对于 SVM,结果如下:

SVM 的误差略高,但仍然是一个相当合理的模型。

另请参阅

第六章:使用 ML 模块进行机器学习

在本章中,我们将继续使用 PySpark 当前支持的机器学习模块——ML 模块。ML 模块像 MLLib 一样,暴露了大量的机器学习模型,几乎完全覆盖了最常用(和可用)的模型。然而,ML 模块是在 Spark DataFrames 上运行的,因此它的性能更高,因为它可以利用钨执行优化。

在本章中,您将学习以下教程:

  • 引入变压器

  • 引入估计器

  • 引入管道

  • 选择最可预测的特征

  • 预测森林覆盖类型

  • 估算森林海拔

  • 聚类森林覆盖类型

  • 调整超参数

  • 从文本中提取特征

  • 离散化连续变量

  • 标准化连续变量

  • 主题挖掘

在本章中,我们将使用从 archive.ics.uci.edu/ml/datasets/covertype 下载的数据。数据集位于本书的 GitHub 仓库中:/data/forest_coverage_type.csv

我们以与之前相同的方式加载数据:

forest_path = '../data/forest_coverage_type.csv'

forest = spark.read.csv(
    forest_path
    , header=True
    , inferSchema=True
)

引入变压器

Transformer 类是在 Spark 1.3 中引入的,它通过通常将一个或多个列附加到现有的 DataFrame 来将一个数据集转换为另一个数据集。变压器是围绕实际转换特征的方法的抽象;这个抽象还包括训练好的机器学习模型(正如我们将在接下来的教程中看到的)。

在本教程中,我们将介绍两个变压器:BucketizerVectorAssembler

我们不会介绍所有的变压器;在本章的其余部分,最有用的变压器将会出现。至于其余的,Spark 文档是学习它们的功能和如何使用它们的好地方。

以下是将一个特征转换为另一个特征的所有变压器的列表:

  • Binarizer 是一种方法,给定一个阈值,将连续的数值特征转换为二进制特征。

  • BucketizerBinarizer 类似,它使用一组阈值将连续数值变量转换为离散变量(级别数与阈值列表长度加一相同)。

  • ChiSqSelector 帮助选择解释分类目标(分类模型)方差大部分的预定义数量的特征。

  • CountVectorizer 将许多字符串列表转换为计数的 SparseVector,其中每一列都是列表中每个不同字符串的标志,值表示当前列表中找到该字符串的次数。

  • DCT 代表离散余弦变换。它接受一组实值向量,并返回以不同频率振荡的余弦函数向量。

  • ElementwiseProduct 可以用于缩放您的数值特征,因为它接受一个值向量,并将其(如其名称所示,逐元素)乘以另一个具有每个值权重的向量。

  • HashingTF 是一个哈希技巧变压器,返回一个指定长度的标记文本表示的向量。

  • IDF 计算记录列表的逆文档频率,其中每个记录都是文本主体的数值表示(请参阅 CountVectorizerHashingTF)。

  • IndexToString 使用 StringIndexerModel 对象的编码将字符串索引反转为原始值。

  • MaxAbsScaler 将数据重新缩放为 -11 的范围内。

  • MinMaxScaler 将数据重新缩放为 01 的范围内。

  • NGram 返回一对、三元组或 n 个连续单词的标记文本。

  • Normalizer 将数据缩放为单位范数(默认为 L2)。

  • OneHotEncoder 将分类变量编码为向量表示,其中只有一个元素是热的,即等于 1(其他都是 0)。

  • PCA 是一种从数据中提取主成分的降维方法。

  • PolynomialExpansion 返回输入向量的多项式展开。

  • QuantileDiscretizer是类似于Bucketizer的方法,但不是定义阈值,而是需要指定返回的箱数;该方法将使用分位数来决定阈值。

  • RegexTokenizer 是一个使用正则表达式处理文本的字符串标记器。

  • RFormula是一种传递 R 语法公式以转换数据的方法。

  • SQLTransformer是一种传递 SQL 语法公式以转换数据的方法。

  • StandardScaler 将数值特征转换为均值为 0,标准差为 1。

  • StopWordsRemover 用于从标记化文本中删除诸如 athe 等单词。

  • StringIndexer根据列中所有单词的列表生成一个索引向量。

  • Tokenizer是一个默认的标记器,它接受一个句子(一个字符串),在空格上分割它,并对单词进行规范化。

  • VectorAssembler将指定的(单独的)特征组合成一个特征。

  • VectorIndexer接受一个分类变量(已经编码为数字)并返回一个索引向量。

  • VectorSlicer 可以被认为是VectorAssembler的相反,因为它根据索引从特征向量中提取数据。

  • Word2Vec将一个句子(或字符串)转换为{string,vector}表示的映射。

准备工作

要执行此操作,您需要一个可用的 Spark 环境,并且您已经将数据加载到 forest DataFrame 中。

无需其他先决条件。

如何做...

Horizontal_Distance_To_Hydrology column into 10 equidistant buckets:
import pyspark.sql.functions as f
import pyspark.ml.feature as feat
import numpy as np

buckets_no = 10

dist_min_max = (
    forest.agg(
          f.min('Horizontal_Distance_To_Hydrology')
            .alias('min')
        , f.max('Horizontal_Distance_To_Hydrology')
            .alias('max')
    )
    .rdd
    .map(lambda row: (row.min, row.max))
    .collect()[0]
)

rng = dist_min_max[1] - dist_min_max[0]

splits = list(np.arange(
    dist_min_max[0]
    , dist_min_max[1]
    , rng / (buckets_no + 1)))

bucketizer = feat.Bucketizer(
    splits=splits
    , inputCol= 'Horizontal_Distance_To_Hydrology'
    , outputCol='Horizontal_Distance_To_Hydrology_Bkt'
)

(
    bucketizer
    .transform(forest)
    .select(
         'Horizontal_Distance_To_Hydrology'
        ,'Horizontal_Distance_To_Hydrology_Bkt'
    ).show(5)
)

有没有想法为什么我们不能使用.QuantileDiscretizer(...)来实现这一点?

它是如何工作的...

与往常一样,我们首先加载我们将在整个过程中使用的必要模块,pyspark.sql.functions,它将允许我们计算Horizontal_Distance_To_Hydrology特征的最小值和最大值。pyspark.ml.feature为我们提供了.Bucketizer(...)转换器供我们使用,而 NumPy 将帮助我们创建一个等间距的阈值列表。

我们想要将我们的数值变量分成 10 个桶,因此我们的buckets_no等于10。接下来,我们计算Horizontal_Distance_To_Hydrology特征的最小值和最大值,并将这两个值返回给驱动程序。在驱动程序上,我们创建阈值列表(splits列表);np.arange(...)方法的第一个参数是最小值,第二个参数是最大值,第三个参数定义了每个步长的大小。

现在我们已经定义了拆分列表,我们将其传递给.Bucketizer(...)方法。

每个转换器(估计器的工作方式类似)都有一个非常相似的 API,但始终需要两个参数:inputColoutputCol,它们分别定义要消耗的输入列和它们的输出列。这两个类——TransformerEstimator——也普遍实现了.getOutputCol()方法,该方法返回输出列的名称。

最后,我们使用bucketizer对象来转换我们的 DataFrame。这是我们期望看到的:

还有更多...

几乎所有在 ML 模块中找到的估计器(或者换句话说,ML 模型)都期望看到一个单一列作为输入;该列应包含数据科学家希望这样一个模型使用的所有特征。正如其名称所示,.VectorAssembler(...)方法将多个特征汇总到一个单独的列中。

考虑以下示例:

vectorAssembler = (
    feat.VectorAssembler(
        inputCols=forest.columns, 
        outputCol='feat'
    )
)

pca = (
    feat.PCA(
        k=5
        , inputCol=vectorAssembler.getOutputCol()
        , outputCol='pca_feat'
    )
)

(
    pca
    .fit(vectorAssembler.transform(forest))
    .transform(vectorAssembler.transform(forest))
    .select('feat','pca_feat')
    .take(1)
)

首先,我们使用.VectorAssembler(...)方法从我们的forest DataFrame 中汇总所有列。

请注意,与其他转换器不同,.VectorAssembler(...)方法具有inputCols参数,而不是inputCol,因为它接受一个列的列表,而不仅仅是一个单独的列。

然后,我们在PCA(...)方法中使用feat列(现在是所有特征的SparseVector)来提取前五个最重要的主成分。

注意我们现在如何可以使用.getOutputCol()方法来获取输出列的名称?当我们介绍管道时,为什么这样做会变得更明显?

上述代码的输出应该看起来像这样:

另请参阅

介绍 Estimators

Estimator类,就像Transformer类一样,是在 Spark 1.3 中引入的。Estimators,顾名思义,用于估计模型的参数,或者换句话说,将模型拟合到数据。

在本文中,我们将介绍两个模型:作为分类模型的线性 SVM,以及预测森林海拔的线性回归模型。

以下是 ML 模块中所有 Estimators 或机器学习模型的列表:

  • 分类:

  • LinearSVC 是用于线性可分问题的 SVM 模型。SVM 的核心具有形式(超平面),其中是系数(或超平面的法向量),是记录,b是偏移量。

  • LogisticRegression 是线性可分问题的默认go-to分类模型。它使用 logit 函数来计算记录属于特定类的概率。

  • DecisionTreeClassifier 是用于分类目的的基于决策树的模型。它构建一个二叉树,其中终端节点中类别的比例确定了类的成员资格。

  • GBTClassifier 是集成模型组中的一员。梯度提升树GBT)构建了几个弱模型,当组合在一起时形成一个强分类器。该模型也可以应用于解决回归问题。

  • RandomForestClassifier 也是集成模型组中的一员。与 GBT 不同,随机森林完全生长决策树,并通过减少方差来实现总误差减少(而 GBT 减少偏差)。就像 GBT 一样,这些模型也可以用来解决回归问题。

  • NaiveBayes 使用贝叶斯条件概率理论,,根据关于概率和可能性的证据和先验假设对观察结果进行分类。

  • MultilayerPerceptronClassifier 源自人工智能领域,更狭义地说是人工神经网络。该模型由模拟(在某种程度上)大脑的基本构建模块的人工神经元组成的有向图。

  • OneVsRest 是一种在多项式场景中只选择一个类的缩减技术。

  • 回归:

  • AFTSurvivalRegression 是一种参数模型,用于预测寿命,并假设特征之一的边际效应加速或减缓过程失败。

  • DecisionTreeRegressorDecisionTreeClassifier的对应物,适用于回归问题。

  • GBTRegressorGBTClassifier的对应物,适用于回归问题。

  • GeneralizedLinearRegression 是一类允许我们指定不同核函数(或链接函数)的线性模型。与假设误差项正态分布的线性回归不同,广义线性模型GLM)允许模型具有其他误差项分布。

  • IsotonicRegression 将自由形式和非递减线拟合到数据。

  • LinearRegression 是回归模型的基准。它通过数据拟合一条直线(或用线性术语定义的平面)。

  • RandomForestRegressorRandomForestClassifier的对应物,适用于回归问题。

  • 聚类:

  • BisectingKMeans 是一个模型,它从一个单一聚类开始,然后迭代地将数据分成k个聚类。

  • Kmeans 通过迭代找到聚类的质心,通过移动聚类边界来最小化数据点与聚类质心之间的距离总和,将数据分成k(定义)个聚类。

  • GaussianMixture 使用k个高斯分布将数据集分解成聚类。

  • LDA潜在狄利克雷分配是主题挖掘中经常使用的模型。它是一个统计模型,利用一些未观察到的(或未命名的)组来对观察结果进行聚类。例如,一个PLANE_linked集群可以包括诸如 engine、flaps 或 wings 等词语。

准备工作

执行此配方,您需要一个可用的 Spark 环境,并且您已经将数据加载到forest DataFrame 中。

不需要其他先决条件。

如何做...

首先,让我们学习如何构建一个 SVM 模型:

import pyspark.ml.classification as cl

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[0:-1]
    , outputCol='features')

fir_dataset = (
    vectorAssembler
    .transform(forest)
    .withColumn(
        'label'
        , (f.col('CoverType') == 1).cast('integer'))
    .select('label', 'features')
)

svc_obj = cl.LinearSVC(maxIter=10, regParam=0.01)
svc_model = svc_obj.fit(fir_dataset)

它是如何工作的...

.LinearSVC(...)方法来自pyspark.ml.classification,因此我们首先加载它。

接下来,我们使用.VectorAssembler(...)forest DataFrame 中获取所有列,但最后一列(CoverType)将用作标签。我们将预测等于1的森林覆盖类型,也就是说,森林是否是云杉冷杉类型;我们通过检查CoverType是否等于1并将结果布尔值转换为整数来实现这一点。最后,我们只选择labelfeatures

接下来,我们创建LinearSVC对象。我们将最大迭代次数设置为 10,并将正则化参数(L2 类型或岭)设置为 1%。

如果您对机器学习中的正则化不熟悉,请查看此网站:enhancedatascience.com/2017/07/04/machine-learning-explained-regularization/

其他参数包括:

  • featuresCol:默认情况下设置为特征列的名称为features(就像在我们的数据集中一样)

  • labelCol:如果有其他名称而不是label,则设置为标签列的名称

  • predictionCol:如果要将其重命名为除prediction之外的其他内容,则设置为预测列的名称

  • tol:这是一个停止参数,它定义了成本函数在迭代之间的最小变化:如果变化(默认情况下)小于 10^(-6),算法将假定它已经收敛

  • rawPredictionCol:这返回生成函数的原始值(在应用阈值之前);您可以指定一个不同的名称而不是rawPrediction

  • fitIntercept:这指示模型拟合截距(常数),而不仅仅是模型系数;默认设置为True

  • standardization:默认设置为True,它在拟合模型之前对特征进行标准化

  • threshold:默认设置为0.0;这是一个决定什么被分类为10的参数

  • weightCol:如果每个观察结果的权重不同,则这是一个列名

  • aggregationDepth:这是用于聚合的树深度参数

最后,我们使用对象.fit(...)数据集;对象返回一个.LinearSVCModel(...)。一旦模型被估计,我们可以这样提取估计模型的系数:svc_model.coefficients。这是我们得到的:

还有更多...

现在,让我们看看线性回归模型是否可以合理准确地估计森林海拔:

import pyspark.ml.regression as rg

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[1:]
    , outputCol='features')

elevation_dataset = (
    vectorAssembler
    .transform(forest)
    .withColumn(
        'label'
        , f.col('Elevation').cast('float'))
    .select('label', 'features')
)

lr_obj = rg.LinearRegression(
    maxIter=10
    , regParam=0.01
    , elasticNetParam=1.00)
lr_model = lr_obj.fit(elevation_dataset)

上述代码与之前介绍的代码非常相似。顺便说一句,这对于几乎所有的 ML 模块模型都是正确的,因此测试各种模型非常简单。

区别在于label列-现在,我们使用Elevation并将其转换为float(因为这是一个回归问题)。

同样,线性回归对象lr_obj实例化了.LinearRegression(...)对象。

有关.LinearRegression(...)的完整参数列表,请参阅文档:bit.ly/2J9OvEJ

一旦模型被估计,我们可以通过调用lr_model.coefficients来检查其系数。这是我们得到的:

此外,.LinearRegressionModel(...)计算一个返回基本性能统计信息的摘要:

summary = lr_model.summary

print(
    summary.r2
    , summary.rootMeanSquaredError
    , summary.meanAbsoluteError
)

上述代码将产生以下结果:

令人惊讶的是,线性回归在这个应用中表现不错:78%的 R 平方并不是一个坏结果。

介绍管道

Pipeline类有助于对导致估计模型的单独块的执行进行排序或简化;它将多个 Transformer 和 Estimator 链接在一起,形成一个顺序执行的工作流程。

管道很有用,因为它们避免了在整体数据转换和模型估计过程中通过不同部分推送数据时显式创建多个转换数据集。相反,管道通过自动化数据流程来抽象不同的中间阶段。这使得代码更易读和可维护,因为它创建了系统的更高抽象,并有助于代码调试。

在这个操作步骤中,我们将简化广义线性回归模型的执行。

准备工作

要执行此操作步骤,您需要一个可用的 Spark 环境,并且您已经将数据加载到forest DataFrame 中。

不需要其他先决条件。

操作步骤...

以下代码提供了通过 GLM 估计线性回归模型的执行的简化版本:

from pyspark.ml import Pipeline

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[1:]
    , outputCol='features')

lr_obj = rg.GeneralizedLinearRegression(
    labelCol='Elevation'
    , maxIter=10
    , regParam=0.01
    , link='identity'
    , linkPredictionCol="p"
)

pip = Pipeline(stages=[vectorAssembler, lr_obj])

(
    pip
    .fit(forest)
    .transform(forest)
    .select('Elevation', 'prediction')
    .show(5)
)

工作原理...

整个代码比我们在上一个示例中使用的代码要短得多,因为我们不需要做以下工作:

elevation_dataset = (
    vectorAssembler
    .transform(forest)
    .withColumn(
        'label'
        , f.col('Elevation').cast('float'))
    .select('label', 'features')
)

然而,与之前一样,我们指定了vectorAssemblerlr_obj.GeneralizedLinearRegression(...)对象)。.GeneralizedLinearRegression(...)允许我们不仅指定模型的 family,还可以指定 link 函数。为了决定选择什么样的 link 函数和 family,我们可以查看我们的Elevation列的分布:

import matplotlib.pyplot as plt

transformed_df = forest.select('Elevation')
transformed_df.toPandas().hist()

plt.savefig('Elevation_histogram.png')

plt.close('all')

这是运行上述代码后得到的图表:

分布有点偏斜,但在一定程度上,我们可以假设它遵循正态分布。因此,我们可以使用family = 'gaussian'(默认)和link = 'identity'

创建了 Transformer(vectorAssembler)和 Estimator(lr_obj)之后,我们将它们放入管道中。stages参数是一个有序列表,用于将数据推送到我们的数据中;在我们的情况下,vectorAssembler首先进行,因为我们需要整理所有的特征,然后我们使用lr_obj估计我们的模型。

最后,我们使用管道同时估计模型。管道的.fit(...)方法调用.transform(...)方法(如果对象是 Transformer),或者.fit(...)方法(如果对象是 Estimator)。因此,在PipelineModel上调用.transform(...)方法会调用 Transformer 和 Estimator 对象的.transform(...)方法。

最终结果如下:

正如你所看到的,结果与实际结果并没有太大不同。

另请参阅

选择最可预测的特征

(几乎)每个数据科学家的口头禅是:构建一个简单的模型,同时尽可能解释目标中的方差。换句话说,您可以使用所有特征构建模型,但模型可能非常复杂且容易过拟合。而且,如果其中一个变量缺失,整个模型可能会产生错误的输出,有些变量可能根本不必要,因为其他变量已经解释了相同部分的方差(称为共线性)。

在这个操作步骤中,我们将学习如何在构建分类或回归模型时选择最佳的预测模型。我们将在接下来的操作步骤中重复使用本操作步骤中学到的内容。

准备工作

要执行此操作,您需要一个可用的 Spark 环境,并且您已经将数据加载到forest DataFrame 中。

不需要其他先决条件。

如何做...

让我们从一段代码开始,这段代码将帮助选择具有最强预测能力的前 10 个特征,以找到forest DataFrame 中观察结果的最佳类别:

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[0:-1]
    , outputCol='features'
)

selector = feat.ChiSqSelector(
    labelCol='CoverType'
    , numTopFeatures=10
    , outputCol='selected')

pipeline_sel = Pipeline(stages=[vectorAssembler, selector])

它是如何工作的...

首先,我们使用.VectorAssembler(...)方法将所有特征组装成一个单一向量。请注意,我们不使用最后一列,因为它是CoverType特征,这是我们的目标。

接下来,我们使用.ChiSqSelector(...)方法基于每个变量与目标之间的成对卡方检验来选择最佳特征。根据测试的值,选择numTopFeatures个最可预测的特征。selected向量将包含前 10 个(在这种情况下)最可预测的特征。labelCol指定目标列。

你可以在这里了解更多关于卡方检验的信息:learntech.uwe.ac.uk/da/Default.aspx?pageid=1440

让我们来看看:

(
    pipeline_sel
    .fit(forest)
    .transform(forest)
    .select(selector.getOutputCol())
    .show(5)
)

从运行前面的代码段中,你应该看到以下内容:

正如你所看到的,生成的SparseVector长度为 10,只包括最可预测的特征。

还有更多...

我们不能使用.ChiSqSelector(...)方法来选择连续的目标特征,也就是回归问题。选择最佳特征的一种方法是检查每个特征与目标之间的相关性,并选择那些与目标高度相关但与其他特征几乎没有相关性的特征:

import pyspark.ml.stat as st

features_and_label = feat.VectorAssembler(
    inputCols=forest.columns
    , outputCol='features'
)

corr = st.Correlation.corr(
    features_and_label.transform(forest), 
    'features', 
    'pearson'
)

print(str(corr.collect()[0][0]))

在 Spark 中没有自动执行此操作的方法,但是从 Spark 2.2 开始,我们现在可以计算数据框中特征之间的相关性。

.Correlation(...)方法是pyspark.ml.stat模块的一部分,所以我们首先导入它。

接下来,我们创建.VectorAssembler(...),它汇总forest DataFrame 的所有列。现在我们可以使用 Transformer,并将结果 DataFrame 传递给Correlation类。Correlation类的.corr(...)方法接受 DataFrame 作为其第一个参数,具有所有特征的列的名称作为第二个参数,要计算的相关性类型作为第三个参数;可用的值是pearson(默认值)和spearman

查看这个网站,了解更多关于这两种相关性方法的信息:bit.ly/2xm49s7

从运行该方法中,我们期望看到的内容如下:

现在我们有了相关矩阵,我们可以提取与我们的标签最相关的前 10 个特征:

num_of_features = 10
cols = dict([
    (i, e) 
    for i, e 
    in enumerate(forest.columns)
])

corr_matrix = corr.collect()[0][0]
label_corr_with_idx = [
    (i[0], e) 
    for i, e 
    in np.ndenumerate(corr_matrix.toArray()[:,0])
][1:]

label_corr_with_idx_sorted = sorted(
    label_corr_with_idx
    , key=lambda el: -abs(el[1])
)

features_selected = np.array([
    cols[el[0]] 
    for el 
    in label_corr_with_idx_sorted
])[0:num_of_features]

首先,我们指定要提取的特征数量,并创建一个包含forest DataFrame 的所有列的字典;请注意,我们将其与索引一起压缩,因为相关矩阵不会传播特征名称,只传播索引。

接下来,我们从corr_matrix中提取第一列(因为这是我们的目标,即 Elevation 特征);.toArray()方法将 DenseMatrix 转换为 NumPy 数组表示。请注意,我们还将索引附加到此数组的元素,以便我们知道哪个元素与我们的目标最相关。

接下来,我们按相关系数的绝对值降序排序列表。

最后,我们循环遍历结果列表的前 10 个元素(在这种情况下),并从cols字典中选择与所选索引对应的列。

对于我们旨在估计森林海拔的问题,这是我们得到的特征列表:

另请参阅

预测森林覆盖类型

在本示例中,我们将学习如何处理数据并构建两个旨在预测森林覆盖类型的分类模型:基准逻辑回归模型和随机森林分类器。我们手头的问题是多项式,也就是说,我们有超过两个类别,我们希望将我们的观察结果分类到其中。

准备工作

要执行此示例,您需要一个可用的 Spark 环境,并且您已经将数据加载到forest DataFrame 中。

不需要其他先决条件。

如何做...

这是帮助我们构建逻辑回归模型的代码:

forest_train, forest_test = (
    forest
    .randomSplit([0.7, 0.3], seed=666)
)

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[0:-1]
    , outputCol='features'
)

selector = feat.ChiSqSelector(
    labelCol='CoverType'
    , numTopFeatures=10
    , outputCol='selected'
)

logReg_obj = cl.LogisticRegression(
    labelCol='CoverType'
    , featuresCol=selector.getOutputCol()
    , regParam=0.01
    , elasticNetParam=1.0
    , family='multinomial'
)

pipeline = Pipeline(
    stages=[
        vectorAssembler
        , selector
        , logReg_obj
    ])

pModel = pipeline.fit(forest_train)

它是如何工作的...

首先,我们将数据分成两个子集:第一个forest_train,我们将用于训练模型,而forest_test将用于测试模型的性能。

接下来,我们构建了本章前面已经看到的通常阶段:我们使用.VectorAssembler(...)整理我们要用来构建模型的所有特征,然后通过.ChiSqSelector(...)方法选择前 10 个最具预测性的特征。

在构建 Pipeline 之前的最后一步,我们创建了logReg_obj:我们将用它来拟合我们的数据的.LogisticRegression(...)对象。在这个模型中,我们使用弹性网络类型的正则化:regParam参数中定义了 L2 部分,elasticNetParam中定义了 L1 部分。请注意,我们指定模型的 family 为multinomial,因为我们正在处理多项式分类问题。

如果要模型自动选择,或者如果您有一个二进制变量,还可以指定family参数为autobinomial

最后,我们构建了 Pipeline,并将这三个对象作为阶段列表传递。接下来,我们使用.fit(...)方法将我们的数据通过管道传递。

现在我们已经估计了模型,我们可以检查它的性能如何:

import pyspark.ml.evaluation as ev

results_logReg = (
    pModel
    .transform(forest_test)
    .select('CoverType', 'probability', 'prediction')
)

evaluator = ev.MulticlassClassificationEvaluator(
    predictionCol='prediction'
    , labelCol='CoverType')

(
    evaluator.evaluate(results_logReg)
    , evaluator.evaluate(
        results_logReg
        , {evaluator.metricName: 'weightedPrecision'}
    ) 
    , evaluator.evaluate(
        results_logReg
        , {evaluator.metricName: 'accuracy'}
    )
)

首先,我们加载pyspark.ml.evaluation模块,因为它包含了我们将在本章其余部分中使用的所有评估方法。

接下来,我们将forest_test通过我们的pModel,以便我们可以获得模型以前从未见过的数据集的预测。

最后,我们创建了MulticlassClassificationEvaluator(...)对象,它将计算我们模型的性能指标。predictionCol指定包含观察的预测类的列的名称,labelCol指定真实标签。

如果评估器的.evaluate(...)方法没有传递其他参数,而只返回模型的结果,则将返回 F1 分数。如果要检索精确度、召回率或准确度,则需要分别调用weightedPrecisionweightedRecallaccuracy

如果您对分类指标不熟悉,可以在此处找到很好的解释:turi.com/learn/userguide/evaluation/classification.html

这是我们的逻辑回归模型的表现:

几乎 70%的准确率表明这不是一个非常糟糕的模型。

还有更多...

让我们看看随机森林模型是否能做得更好:

rf_obj = cl.RandomForestClassifier(
    labelCol='CoverType'
    , featuresCol=selector.getOutputCol()
    , minInstancesPerNode=10
    , numTrees=10
)

pipeline = Pipeline(
    stages=[vectorAssembler, selector, rf_obj]
)

pModel = pipeline.fit(forest_train)

从前面的代码中可以看出,我们将重用我们已经为逻辑回归模型创建的大多数对象;我们在这里引入的是.RandomForestClassifier(...),我们可以重用vectorAssemblerselector对象。这是与管道一起工作的简单示例之一。

.RandomForestClassifier(...)对象将为我们构建随机森林模型。在此示例中,我们仅指定了四个参数,其中大多数您可能已经熟悉,例如labelColfeaturesColminInstancesPerNode指定允许将节点拆分为两个子节点的最小记录数,而numTrees指定要估计的森林中的树木数量。其他值得注意的参数包括:

  • impurity: 指定用于信息增益的标准。默认情况下,它设置为 gini,但也可以是 entropy

  • maxDepth: 指定任何树的最大深度。

  • maxBins: 指定任何树中的最大箱数。

  • minInfoGain: 指定迭代之间的最小信息增益水平。

有关该类的完整规范,请参阅 bit.ly/2sgQAFa

估计了模型后,让我们看看它的表现,以便与逻辑回归进行比较:

results_rf = (
    pModel
    .transform(forest_test)
    .select('CoverType', 'probability', 'prediction')
)

(
    evaluator.evaluate(results_rf)
    , evaluator.evaluate(
        results_rf
        , {evaluator.metricName: 'weightedPrecision'}
    )
    , evaluator.evaluate(
        results_rf
        , {evaluator.metricName: 'accuracy'}
    )
)

上述代码应该产生类似以下的结果:

结果完全相同,表明两个模型表现一样好,我们可能希望在选择阶段增加所选特征的数量,以潜在地获得更好的结果。

估计森林海拔

在这个示例中,我们将构建两个回归模型,用于预测森林海拔:随机森林回归模型和梯度提升树回归器。

准备工作

要执行此示例,您需要一个可用的 Spark 环境,并且您已经将数据加载到 forest DataFrame 中。

不需要其他先决条件。

如何做...

在这个示例中,我们将只构建一个两阶段的管道,使用 .VectorAssembler(...).RandomForestRegressor(...) 阶段。我们将跳过特征选择阶段,因为目前这不是一个自动化的过程。

您可以手动执行此操作。只需在本章中稍早的 选择最可预测的特征 示例中检查。

以下是完整的代码:

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[1:]
    , outputCol='features')

rf_obj = rg.RandomForestRegressor(
    labelCol='Elevation'
    , maxDepth=10
    , minInstancesPerNode=10
    , minInfoGain=0.1
    , numTrees=10
)

pip = Pipeline(stages=[vectorAssembler, rf_obj])

工作原理...

首先,像往常一样,我们使用 .VectorAssembler(...) 方法收集我们想要在模型中使用的所有特征。请注意,我们只使用从第二列开始的列,因为第一列是我们的目标——海拔特征。

接下来,我们指定 .RandomForestRegressor(...) 对象。该对象使用的参数列表几乎与 .RandomForestClassifier(...) 相同。

查看上一个示例,了解其他显著参数的列表。

最后一步是构建管道对象;pip 只有两个阶段:vectorAssemblerrf_obj

接下来,让我们看看我们的模型与我们在 介绍估计器 示例中估计的线性回归模型相比表现如何:

results = (
    pip
    .fit(forest)
    .transform(forest)
    .select('Elevation', 'prediction')
)

evaluator = ev.RegressionEvaluator(labelCol='Elevation')
evaluator.evaluate(results, {evaluator.metricName: 'r2'})

.RegressionEvaluator(...) 计算回归模型的性能指标。默认情况下,它返回 rmse,即均方根误差,但也可以返回:

  • mse: 这是均方误差

  • r2: 这是 指标

  • mae: 这是平均绝对误差

从上述代码中,我们得到:

这比我们之前构建的线性回归模型要好,这意味着我们的模型可能不像我们最初认为的那样线性可分。

查看此网站,了解有关不同类型回归指标的更多信息:bit.ly/2sgpONr

还有更多...

让我们看看梯度提升树模型是否能击败先前的结果:

gbt_obj = rg.GBTRegressor(
    labelCol='Elevation'
    , minInstancesPerNode=10
    , minInfoGain=0.1
)

pip = Pipeline(stages=[vectorAssembler, gbt_obj])

与随机森林回归器相比唯一的变化是,我们现在使用 .GBTRegressor(...) 类来将梯度提升树模型拟合到我们的数据中。这个类的最显著参数包括:

  • maxDepth: 指定构建树的最大深度,默认设置为 5

  • maxBins: 指定最大箱数

  • minInfoGain: 指定迭代之间的最小信息增益水平

  • minInstancesPerNode: 当树仍然执行分裂时,指定实例的最小数量

  • lossType: 指定损失类型,并接受 squaredabsolute

  • impurity: 默认设置为 variance,目前(在 Spark 2.3 中)是唯一允许的选项

  • maxIter: 指定最大迭代次数——算法的停止准则

现在让我们检查性能:

results = (
    pip
    .fit(forest)
    .transform(forest)
    .select('Elevation', 'prediction')
)

evaluator = ev.RegressionEvaluator(labelCol='Elevation')
evaluator.evaluate(results, {evaluator.metricName: 'r2'})

以下是我们得到的结果:

如您所见,即使我们略微改进了随机森林回归器。

聚类森林覆盖类型

聚类是一种无监督的方法,试图在没有任何类别指示的情况下找到数据中的模式。换句话说,聚类方法找到记录之间的共同点,并根据它们彼此的相似程度以及与其他聚类中发现的记录的不相似程度将它们分组成聚类。

在本教程中,我们将构建最基本的模型之一——k-means 模型。

准备工作

要执行此教程,您需要一个可用的 Spark 环境,并且您已经将数据加载到forest DataFrame 中。

不需要其他先决条件。

如何做...

在 Spark 中构建聚类模型的过程与我们在分类或回归示例中已经看到的过程没有明显的偏差:

import pyspark.ml.clustering as clust

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[:-1]
    , outputCol='features')

kmeans_obj = clust.KMeans(k=7, seed=666)

pip = Pipeline(stages=[vectorAssembler, kmeans_obj])

它是如何工作的...

像往常一样,我们首先导入相关模块;在这种情况下,是pyspark.ml.clustering模块。

接下来,我们将汇总所有要在构建模型中使用的特征,使用众所周知的.VectorAssembler(...)转换器。

然后实例化.KMeans(...)对象。我们只指定了两个参数,但最显著的参数列表如下:

  • k:指定预期的聚类数,是构建 k-means 模型的唯一必需参数

  • initMode:指定聚类中心的初始化类型;k-means||使用 k-means 的并行变体,或random选择随机的聚类中心点

  • initSteps:指定初始化步骤

  • maxIter:指定算法停止的最大迭代次数,即使它尚未收敛

最后,我们只构建了包含两个阶段的管道。

一旦计算出结果,我们可以看看我们得到了什么。我们的目标是看看是否在森林覆盖类型中找到了任何潜在模式:

results = (
    pip
    .fit(forest)
    .transform(forest)
    .select('features', 'CoverType', 'prediction')
)

results.show(5)

这是我们从运行上述代码中得到的结果:

如您所见,似乎没有许多模式可以区分森林覆盖类型。但是,让我们看看我们的分割是否表现不佳,这就是为什么我们找不到任何模式的原因,还是我们找到的模式根本不与CoverType对齐:

clustering_ev = ev.ClusteringEvaluator()
clustering_ev.evaluate(results)

.ClusteringEvaluator(...)是自 Spark 2.3 以来可用的新评估器,仍处于实验阶段。它计算聚类结果的轮廓度量。

要了解更多有关轮廓度量的信息,请查看scikit-learn.org/stable/modules/generated/sklearn.metrics.silhouette_score.html

这是我们的 k-means 模型:

如您所见,我们得到了一个不错的模型,因为 0.5 左右的任何值都表示聚类分离良好。

另请参阅

调整超参数

本章中已经提到的许多模型都有多个参数,这些参数决定了模型的性能。选择一些相对简单,但有许多参数是我们无法直观设置的。这就是超参数调整方法的作用。超参数调整方法帮助我们选择最佳(或接近最佳)的参数集,以最大化我们定义的某个度量标准。

在本教程中,我们将向您展示超参数调整的两种方法。

准备工作

要执行此操作,您需要一个可用的 Spark 环境,并且已经将数据加载到forest DataFrame 中。我们还假设您已经熟悉了转换器、估计器、管道和一些回归模型。

不需要其他先决条件。

如何做...

我们从网格搜索开始。这是一种蛮力方法,简单地循环遍历参数的特定值,构建新模型并比较它们的性能,给定一些客观的评估器:

import pyspark.ml.tuning as tune

vectorAssembler = feat.VectorAssembler(
    inputCols=forest.columns[0:-1]
    , outputCol='features')

selector = feat.ChiSqSelector(
    labelCol='CoverType'
    , numTopFeatures=5
    , outputCol='selected')

logReg_obj = cl.LogisticRegression(
    labelCol='CoverType'
    , featuresCol=selector.getOutputCol()
    , family='multinomial'
)

logReg_grid = (
    tune.ParamGridBuilder()
    .addGrid(logReg_obj.regParam
            , [0.01, 0.1]
        )
    .addGrid(logReg_obj.elasticNetParam
            , [1.0, 0.5]
        )
    .build()
)

logReg_ev = ev.MulticlassClassificationEvaluator(
    predictionCol='prediction'
    , labelCol='CoverType')

cross_v = tune.CrossValidator(
    estimator=logReg_obj
    , estimatorParamMaps=logReg_grid
    , evaluator=logReg_ev
)

pipeline = Pipeline(stages=[vectorAssembler, selector])
data_trans = pipeline.fit(forest_train)

logReg_modelTest = cross_v.fit(
    data_trans.transform(forest_train)
)

它是如何工作的...

这里发生了很多事情,让我们一步一步地解开它。

我们已经了解了.VectorAssembler(...).ChiSqSelector(...).LogisticRegression(...)类,因此我们在这里不会重复。

如果您对前面的概念不熟悉,请查看以前的配方。

这个配方的核心从logReg_grid对象开始。这是.ParamGridBuilder()类,它允许我们向网格中添加元素,算法将循环遍历并估计所有参数和指定值的组合的模型。

警告:您包含的参数越多,指定的级别越多,您将需要估计的模型就越多。模型的数量在参数数量和为这些参数指定的级别数量上呈指数增长。当心!

在这个例子中,我们循环遍历两个参数:regParamelasticNetParam。对于每个参数,我们指定两个级别,因此我们需要构建四个模型。

作为评估器,我们再次使用.MulticlassClassificationEvaluator(...)

接下来,我们指定.CrossValidator(...)对象,它将所有这些东西绑定在一起:我们的estimator将是logReg_objestimatorParamMaps将等于构建的logReg_grid,而evaluator将是logReg_ev

.CrossValidator(...)对象将训练数据拆分为一组折叠(默认为3),并将它们用作单独的训练和测试数据集来拟合模型。因此,我们不仅需要根据要遍历的参数网格拟合四个模型,而且对于这四个模型中的每一个,我们都要构建三个具有不同训练和验证数据集的模型。

请注意,我们首先构建的管道是纯数据转换的,即,它只将特征汇总到完整的特征向量中,然后选择具有最大预测能力的前五个特征;我们在这个阶段不拟合logReg_obj

当我们使用cross_v对象拟合转换后的数据时,模型拟合开始。只有在这时,Spark 才会估计四个不同的模型并选择表现最佳的模型。

现在已经估计了模型并选择了表现最佳的模型,让我们看看所选的模型是否比我们在预测森林覆盖类型配方中估计的模型表现更好:

data_trans_test = data_trans.transform(forest_test)
results = logReg_modelTest.transform(data_trans_test)

print(logReg_ev.evaluate(results, {logReg_ev.metricName: 'weightedPrecision'}))
print(logReg_ev.evaluate(results, {logReg_ev.metricName: 'weightedRecall'}))
print(logReg_ev.evaluate(results, {logReg_ev.metricName: 'accuracy'}))

借助前面的代码,我们得到了以下结果:

正如您所看到的,我们的表现略逊于之前的模型,但这很可能是因为我们只选择了前 5 个(而不是之前的 10 个)特征与我们的选择器。

还有更多...

另一种旨在找到表现最佳模型的方法称为训练验证拆分。该方法将训练数据拆分为两个较小的子集:一个用于训练模型,另一个用于验证模型是否过拟合。拆分只进行一次,因此与交叉验证相比,成本较低:

train_v = tune.TrainValidationSplit(
    estimator=logReg_obj
    , estimatorParamMaps=logReg_grid
    , evaluator=logReg_ev
    , parallelism=4
)

logReg_modelTrainV = (
    train_v
    .fit(data_trans.transform(forest_train))

results = logReg_modelTrainV.transform(data_trans_test)

print(logReg_ev.evaluate(results, {logReg_ev.metricName: 'weightedPrecision'}))
print(logReg_ev.evaluate(results, {logReg_ev.metricName: 'weightedRecall'}))
print(logReg_ev.evaluate(results, {logReg_ev.metricName: 'accuracy'}))

前面的代码与.CrossValidator(...)所看到的并没有太大不同。我们为.TrainValidationSplit(...)方法指定的唯一附加参数是控制在选择最佳模型时会启动多少线程的并行级别。

使用.TrainValidationSplit(...)方法产生与.CrossValidator(...)方法相同的结果:

从文本中提取特征

通常,数据科学家需要处理非结构化数据,比如自由流动的文本:公司收到客户的反馈或建议(以及其他内容),这可能是预测客户下一步行动或他们对品牌情感的宝藏。

在这个步骤中,我们将学习如何从文本中提取特征。

准备工作

要执行这个步骤,你需要一个可用的 Spark 环境。

不需要其他先决条件。

如何做...

一个通用的过程旨在从文本中提取数据并将其转换为机器学习模型可以使用的内容,首先从自由流动的文本开始。第一步是取出文本的每个句子,并在空格字符上进行分割(通常是)。接下来,移除所有的停用词。最后,简单地计算文本中不同单词的数量或使用哈希技巧将我们带入自由流动文本的数值表示领域。

以下是如何使用 Spark 的 ML 模块来实现这一点:

some_text = spark.createDataFrame([
    ['''
    Apache Spark achieves high performance for both batch
    and streaming data, using a state-of-the-art DAG scheduler, 
    a query optimizer, and a physical execution engine.
    ''']
    , ['''
    Apache Spark is a fast and general-purpose cluster computing 
    system. It provides high-level APIs in Java, Scala, Python 
    and R, and an optimized engine that supports general execution 
    graphs. It also supports a rich set of higher-level tools including 
    Spark SQL for SQL and structured data processing, MLlib for machine 
    learning, GraphX for graph processing, and Spark Streaming.
    ''']
    , ['''
    Machine learning is a field of computer science that often uses 
    statistical techniques to give computers the ability to "learn" 
    (i.e., progressively improve performance on a specific task) 
    with data, without being explicitly programmed.
    ''']
], ['text'])

splitter = feat.RegexTokenizer(
    inputCol='text'
    , outputCol='text_split'
    , pattern='\s+|[,.\"]'
)

sw_remover = feat.StopWordsRemover(
    inputCol=splitter.getOutputCol()
    , outputCol='no_stopWords'
)

hasher = feat.HashingTF(
    inputCol=sw_remover.getOutputCol()
    , outputCol='hashed'
    , numFeatures=20
)

idf = feat.IDF(
    inputCol=hasher.getOutputCol()
    , outputCol='features'
)

pipeline = Pipeline(stages=[splitter, sw_remover, hasher, idf])

pipelineModel = pipeline.fit(some_text)

它是如何工作的...

正如前面提到的,我们从一些文本开始。在我们的例子中,我们使用了一些从 Spark 文档中提取的内容。

.RegexTokenizer(...)是使用正则表达式来分割句子的文本分词器。在我们的例子中,我们在至少一个(或多个)空格上分割句子——这是\s+表达式。然而,我们的模式还会在逗号、句号或引号上进行分割——这是[,.\"]部分。管道符|表示在空格或标点符号上进行分割。通过.RegexTokenizer(...)处理后的文本将如下所示:

接下来,我们使用.StopWordsRemover(...)方法来移除停用词,正如其名称所示。

查看 NLTK 的最常见停用词列表:gist.github.com/sebleier/554280

.StopWordsRemover(...)简单地扫描标记化文本,并丢弃它遇到的任何停用词。移除停用词后,我们的文本将如下所示:

正如你所看到的,剩下的是句子的基本含义;人类可以阅读这些词,并且在一定程度上理解它。

哈希技巧(或特征哈希)是一种将任意特征列表转换为向量形式的方法。这是一种高效利用空间的方法,用于标记文本,并同时将文本转换为数值表示。哈希技巧使用哈希函数将一种表示转换为另一种表示。哈希函数本质上是任何将一种表示转换为另一种表示的映射函数。通常,它是一种有损和单向的映射(或转换);不同的输入可以被哈希成相同的哈希值(称为冲突),一旦被哈希,几乎总是极其困难来重构输入。.HashingTF(...)方法接受sq_remover对象的输入列,并将标记化文本转换(或编码)为一个包含 20 个特征的向量。在经过哈希处理后,我们的文本将如下所示:

现在我们已经对特征进行了哈希处理,我们可能可以使用这些特征来训练一个机器学习模型。然而,简单地计算单词出现的次数可能会导致误导性的结论。一个更好的度量是词频-逆文档频率TF-IDF)。这是一个度量,它计算一个词在整个语料库中出现的次数,然后计算一个句子中该词出现次数与整个语料库中出现次数的比例。这个度量有助于评估一个词对整个文档集合中的一个文档有多重要。在 Spark 中,我们使用.IDF(...)方法来实现这一点。

在通过整个管道后,我们的文本将如下所示:

因此,实际上,我们已经将 Spark 文档中的内容编码成了一个包含 20 个元素的向量,现在我们可以用它来训练一个机器学习模型。

还有更多...

将文本编码成数字形式的另一种方法是使用 Word2Vec 算法。该算法计算单词的分布式表示,优势在于相似的单词在向量空间中被放在一起。

查看这个教程,了解更多关于 Word2Vec 和 skip-gram 模型的信息:mccormickml.com/2016/04/19/word2vec-tutorial-the-skip-gram-model/

在 Spark 中我们是这样做的:

w2v = feat.Word2Vec(
    vectorSize=5
    , minCount=2
    , inputCol=sw_remover.getOutputCol()
    , outputCol='vector'
)

我们将从.Word2Vec(...)方法中得到一个包含五个元素的向量。此外,只有在语料库中至少出现两次的单词才会被用来创建单词嵌入。以下是结果向量的样子:

另请参阅

  • 要了解更多关于文本特征工程的信息,请查看 Packt 的这个位置:bit.ly/2IZ7ZZA

离散化连续变量

有时,将连续变量离散化表示实际上是有用的。

在这个配方中,我们将学习如何使用傅立叶级数中的一个例子离散化数值特征。

准备工作

要执行这个配方,你需要一个可用的 Spark 环境。

不需要其他先决条件。

如何做...

在这个配方中,我们将使用位于data文件夹中的一个小数据集,即fourier_signal.csv

signal_df = spark.read.csv(
    '../data/fourier_signal.csv'
    , header=True
    , inferSchema=True
)

steps = feat.QuantileDiscretizer(
       numBuckets=10,
       inputCol='signal',
       outputCol='discretized')

transformed = (
    steps
    .fit(signal_df)
    .transform(signal_df)
)

工作原理...

首先,我们将数据读入signal_dffourier_signal.csv包含一个名为signal的单独列。

接下来,我们使用.QuantileDiscretizer(...)方法将信号离散为 10 个桶。桶的范围是基于分位数选择的,也就是说,每个桶将有相同数量的观察值。

这是原始信号的样子(黑线),以及它的离散表示的样子:

标准化连续变量

使用具有显著不同范围和分辨率的特征(如年龄和工资)构建机器学习模型可能不仅会带来计算问题,还会带来模型收敛和系数可解释性问题。

在这个配方中,我们将学习如何标准化连续变量,使它们的平均值为 0,标准差为 1。

准备工作

要执行这个配方,你需要一个可用的 Spark 环境。你还必须执行前面的配方。

不需要其他先决条件。

如何做...

为了标准化我们在前面的配方中引入的signal列,我们将使用.StandardScaler(...)方法:

vec = feat.VectorAssembler(
    inputCols=['signal']
    , outputCol='signal_vec'
)

norm = feat.StandardScaler(
    inputCol=vec.getOutputCol()
    , outputCol='signal_norm'
    , withMean=True
    , withStd=True
)

norm_pipeline = Pipeline(stages=[vec, norm])
signal_norm = (
    norm_pipeline
    .fit(signal_df)
    .transform(signal_df)
)

工作原理...

首先,我们需要将单个特征转换为向量表示,因为.StandardScaler(...)方法只接受向量化的特征。

接下来,我们实例化.StandardScaler(...)对象。withMean参数指示方法将数据居中到平均值,而withStd参数将数据缩放到标准差等于 1。

这是我们信号的标准化表示的样子。请注意两条线的不同刻度:

主题挖掘

有时,有必要根据其内容将文本文档聚类到桶中。

在这个配方中,我们将通过一个例子来为从维基百科提取的一组短段落分配一个主题。

准备工作

要执行这个配方,你需要一个可用的 Spark 环境。

不需要其他先决条件。

如何做...

为了对文档进行聚类,我们首先需要从我们的文章中提取特征。请注意,以下文本由于空间限制而被缩写,有关完整代码,请参考 GitHub 存储库:

articles = spark.createDataFrame([
    ('''
        The Andromeda Galaxy, named after the mythological 
        Princess Andromeda, also known as Messier 31, M31, 
        or NGC 224, is a spiral galaxy approximately 780 
        kiloparsecs (2.5 million light-years) from Earth, 
        and the nearest major galaxy to the Milky Way. 
        Its name stems from the area of the sky in which it 
        appears, the constellation of Andromeda. The 2006 
        observations by the Spitzer Space Telescope revealed 
        that the Andromeda Galaxy contains approximately one 
        trillion stars, more than twice the number of the 
        Milky Way’s estimated 200-400 billion stars. The 
        Andromeda Galaxy, spanning approximately 220,000 light 
        years, is the largest galaxy in our Local Group, 
        which is also home to the Triangulum Galaxy and 
        other minor galaxies. The Andromeda Galaxy's mass is 
        estimated to be around 1.76 times that of the Milky 
        Way Galaxy (~0.8-1.5×1012 solar masses vs the Milky 
        Way's 8.5×1011 solar masses).
    ''','Galaxy', 'Andromeda')
    (...) 
    , ('''
        Washington, officially the State of Washington, is a state in the Pacific 
        Northwest region of the United States. Named after George Washington, 
        the first president of the United States, the state was made out of the 
        western part of the Washington Territory, which was ceded by Britain in 
        1846 in accordance with the Oregon Treaty in the settlement of the 
        Oregon boundary dispute. It was admitted to the Union as the 42nd state 
        in 1889\. Olympia is the state capital. Washington is sometimes referred 
        to as Washington State, to distinguish it from Washington, D.C., the 
        capital of the United States, which is often shortened to Washington.
    ''','Geography', 'Washington State') 
], ['articles', 'Topic', 'Object'])

splitter = feat.RegexTokenizer(
    inputCol='articles'
    , outputCol='articles_split'
    , pattern='\s+|[,.\"]'
)

sw_remover = feat.StopWordsRemover(
    inputCol=splitter.getOutputCol()
    , outputCol='no_stopWords'
)

count_vec = feat.CountVectorizer(
    inputCol=sw_remover.getOutputCol()
    , outputCol='vector'
)

lda_clusters = clust.LDA(
    k=3
    , optimizer='online'
    , featuresCol=count_vec.getOutputCol()
)

topic_pipeline = Pipeline(
    stages=[
        splitter
        , sw_remover
        , count_vec
        , lda_clusters
    ]
)

工作原理...

首先,我们创建一个包含我们文章的 DataFrame。

接下来,我们将几乎按照从文本中提取特征配方中的步骤进行操作:

  1. 我们使用.RegexTokenizer(...)拆分句子

  2. 我们使用.StopWordsRemover(...)去除停用词

  3. 我们使用.CountVectorizer(...)计算每个单词的出现次数

为了在我们的数据中找到聚类,我们将使用潜在狄利克雷分配LDA)模型。在我们的情况下,我们知道我们希望有三个聚类,但如果你不知道你可能有多少聚类,你可以使用我们在本章前面介绍的调整超参数配方之一。

最后,我们把所有东西都放在管道中以方便我们使用。

一旦模型被估计,让我们看看它的表现。这里有一段代码可以帮助我们做到这一点;注意 NumPy 的.argmax(...)方法,它可以帮助我们找到最高值的索引:

for topic in ( 
        topic_pipeline
        .fit(articles)
        .transform(articles)
        .select('Topic','Object','topicDistribution')
        .take(10)
):
    print(
        topic.Topic
        , topic.Object
        , np.argmax(topic.topicDistribution)
        , topic.topicDistribution
    )

这就是我们得到的结果:

正如你所看到的,通过适当的处理,我们可以从文章中正确提取主题;关于星系的文章被分组在第 2 个聚类中,地理信息在第 1 个聚类中,动物在第 0 个聚类中。

第七章:使用 PySpark 进行结构化流处理

在本章中,我们将介绍如何在 PySpark 中使用 Apache Spark 结构化流处理。您将学习以下内容:

  • 理解 DStreams

  • 理解全局聚合

  • 使用结构化流进行连续聚合

介绍

随着机器生成的实时数据的普及,包括但不限于物联网传感器、设备和信标,迅速获得这些数据的洞察力变得越来越重要。无论您是在检测欺诈交易、实时检测传感器异常,还是对下一个猫视频的情感分析,流分析都是一个越来越重要的差异化因素和商业优势。

随着我们逐步学习这些内容,我们将结合批处理实时处理的构建来创建连续应用。使用 Apache Spark,数据科学家和数据工程师可以使用 Spark SQL 在批处理和实时中分析数据,使用 MLlib 训练机器学习模型,并通过 Spark Streaming 对这些模型进行评分。

Apache Spark 迅速被广泛采用的一个重要原因是它统一了所有这些不同的数据处理范式(通过 ML 和 MLlib 进行机器学习,Spark SQL 和流处理)。正如在Spark Streaming: What is It and Who’s Using itwww.datanami.com/2015/11/30/spark-streaming-what-is-it-and-whos-using-it/)中所述,像 Uber、Netflix 和 Pinterest 这样的公司经常通过 Spark Streaming 展示他们的用例:

理解 Spark Streaming

对于 Apache Spark 中的实时处理,当前的重点是结构化流,它是建立在 DataFrame/数据集基础设施之上的。使用 DataFrame 抽象允许在 Spark SQL 引擎 Catalyst Optimizer 中对流处理、机器学习和 Spark SQL 进行优化,并且定期进行改进(例如,Project Tungsten)。然而,为了更容易地理解 Spark Streaming,值得了解其 Spark Streaming 前身的基本原理。以下图表代表了涉及 Spark 驱动程序、工作程序、流源和流目标的 Spark Streaming 应用程序数据流:

前面图表的描述如下:

  1. Spark Streaming ContextSSC)开始,驱动程序将在执行程序(即 Spark 工作程序)上执行长时间运行的任务。

  2. 在驱动程序中定义的代码(从ssc.start()开始),执行程序(在此图中为Executor 1)从流源接收数据流。Spark Streaming 可以接收KafkaTwitter,或者您可以构建自己的自定义接收器。接收器将数据流分成块并将这些块保存在内存中。

  3. 这些数据块被复制到另一个执行程序以实现高可用性。

  4. 块 ID 信息被传输到驱动程序上的块管理器主节点,从而确保内存中的每个数据块都被跟踪和记录。

  5. 对于 SSC 中配置的每个批处理间隔(通常是每 1 秒),驱动程序将启动 Spark 任务来处理这些块。这些块然后被持久化到任意数量的目标数据存储中,包括云存储(例如 S3、WASB)、关系型数据存储(例如 MySQL、PostgreSQL 等)和 NoSQL 存储。

在接下来的小节中,我们将回顾离散流DStreams(基本的流构建块)的示例,然后通过对 DStreams 进行有状态的计算来执行全局聚合。然后,我们将通过使用结构化流简化我们的流应用程序,同时获得性能优化。

理解 DStreams

在我们深入讨论结构化流之前,让我们先谈谈 DStreams。DStreams 是建立在 RDDs 之上的,表示被分成小块的数据流。下图表示这些数据块以毫秒到秒的微批次形式存在。在这个例子中,DStream 的行被微批次到秒中,每个方块代表在那一秒窗口内发生的一个微批次事件:

  • 在 1 秒的时间间隔内,事件blue出现了五次,事件green出现了三次

  • 在 2 秒的时间间隔内,事件gohawks出现了一次

  • 在 4 秒的时间间隔内,事件green出现了两次

因为 DStreams 是建立在 RDDs 之上的,Apache Spark 的核心数据抽象,这使得 Spark Streaming 可以轻松地与其他 Spark 组件(如 MLlib 和 Spark SQL)集成。

准备工作

对于这些 Apache Spark Streaming 示例,我们将通过 bash 终端创建和执行一个控制台应用程序。为了简化操作,你需要打开两个终端窗口。

如何做...

如前一节所述,我们将使用两个终端窗口:

  • 一个终端窗口传输一个事件

  • 另一个终端接收这些事件

请注意,此代码的源代码可以在 Apache Spark 1.6 Streaming 编程指南中找到:spark.apache.org/docs/1.6.0/streaming-programming-guide.html

终端 1 - Netcat 窗口

对于第一个窗口,我们将使用 Netcat(或 nc)手动发送事件,如 blue、green 和 gohawks。要启动 Netcat,请使用以下命令;我们将把我们的事件定向到端口9999,我们的 Spark Streaming 作业将会检测到:

nc -lk 9999

为了匹配上一个图表,我们将输入我们的事件,使得控制台屏幕看起来像这样:

$nc -lk 9999
blue blue blue blue blue green green green
gohawks
green green 

终端 2 - Spark Streaming 窗口

我们将使用以下代码创建一个简单的 PySpark Streaming 应用程序,名为streaming_word_count.py

#
# streaming_word_count.py
#

# Import the necessary classes and create a local SparkContext and Streaming Contexts
from pyspark import SparkContext
from pyspark.streaming import StreamingContext

# Create Spark Context with two working threads (note, `local[2]`)
sc = SparkContext("local[2]", "NetworkWordCount")

# Create local StreamingContextwith batch interval of 1 second
ssc = StreamingContext(sc, 1)

# Create DStream that will connect to the stream of input lines from connection to localhost:9999
lines = ssc.socketTextStream("localhost", 9999)

# Split lines into words
words = lines.flatMap(lambda line: line.split(" "))

# Count each word in each batch
pairs = words.map(lambda word: (word, 1))
wordCounts = pairs.reduceByKey(lambda x, y: x + y)

# Print the first ten elements of each RDD generated in this DStream to the console
wordCounts.pprint()

# Start the computation
ssc.start()

# Wait for the computation to terminate
ssc.awaitTermination()

要运行这个 PySpark Streaming 应用程序,请在$SPARK_HOME文件夹中执行以下命令:

./bin/spark-submit streaming_word_count.py localhost 9999

在时间上的安排,你应该:

  1. 首先使用nc -lk 9999

  2. 然后,启动你的 PySpark Streaming 应用程序:/bin/spark-submit streaming_word_count.py localhost 9999

  3. 然后,开始输入你的事件,例如:

  4. 对于第一秒,输入blue blue blue blue blue green green green

  5. 在第二秒时,输入gohawks

  6. 等一下,在第四秒时,输入green green

你的 PySpark 流应用程序的控制台输出将类似于这样:

$ ./bin/spark-submit streaming_word_count.py localhost 9999
-------------------------------------------
Time: 2018-06-21 23:00:30
-------------------------------------------
(u'blue', 5)
(u'green', 3)
-------------------------------------------
Time: 2018-06-21 23:00:31
-------------------------------------------
(u'gohawks', 1)
-------------------------------------------
Time: 2018-06-21 23:00:32
-------------------------------------------
-------------------------------------------
Time: 2018-06-21 23:00:33
-------------------------------------------
(u'green', 2)
------------------------------------------- 

要结束流应用程序(以及nc窗口),执行终止命令(例如,Ctrl + C)。

它是如何工作的...

如前面的小节所述,这个示例由一个终端窗口组成,用nc传输事件数据。第二个窗口运行我们的 Spark Streaming 应用程序,从第一个窗口传输到的端口读取数据。

这段代码的重要调用如下所示:

  • 我们使用两个工作线程创建一个 Spark 上下文,因此使用local[2]

  • 如 Netcat 窗口中所述,我们使用ssc.socketTextStream来监听localhost的本地套接字,端口为9999

  • 请记住,对于每个 1 秒批处理,我们不仅读取一行(例如blue blue blue blue blue green green green),还通过split将其拆分为单独的words

  • 我们使用 Python 的lambda函数和 PySpark 的mapreduceByKey函数来快速计算 1 秒批处理中单词的出现次数。例如,在blue blue blue blue blue green green green的情况下,有五个蓝色和三个绿色事件,如我们的流应用程序的2018-06-21 23:00:30报告的那样。

  • ssc.start()是指应用程序启动 Spark Streaming 上下文。

  • ssc.awaitTermination()正在等待终止命令来停止流应用程序(例如Ctrl + C);否则,应用程序将继续运行。

还有更多...

在使用 PySpark 控制台时,通常会有很多消息发送到控制台,这可能会使流输出难以阅读。为了更容易阅读,请确保您已经创建并修改了$SPARK_HOME/conf文件夹中的log4j.properties文件。要做到这一点,请按照以下步骤操作:

  1. 转到$SPARK_HOME/conf文件夹。

  2. 默认情况下,有一个log4j.properties.template文件。将其复制为相同的名称,删除.template,即:

cp log4j.properties.template log4j.properties
  1. 在您喜欢的编辑器(例如 sublime、vi 等)中编辑log4j.properties。在文件的第 19 行,更改此行:
log4j.rootCategory=INFO, console

改为:

log4j.rootCategory=ERROR, console

这样,不是所有的日志信息(即INFO)都被定向到控制台,只有错误(即ERROR)会被定向到控制台。

理解全局聚合

在前一节中,我们的示例提供了事件的快照计数。也就是说,它提供了在某一时间点的事件计数。但是,如果您想要了解一段时间窗口内的事件总数呢?这就是全局聚合的概念:

如果我们想要全局聚合,与之前相同的示例(时间 1:5 蓝色,3 绿色,时间 2:1 gohawks,时间 4:2 绿色)将被计算为:

  • 时间 1:5 蓝色,3 绿色

  • 时间 2:5 蓝色,3 绿色,1 gohawks

  • 时间 4:5 蓝色,5 绿色,1 gohawks

在传统的批处理计算中,这将类似于groupbykeyGROUP BY语句。但是在流应用程序的情况下,这个计算需要在毫秒内完成,这通常是一个太短的时间窗口来执行GROUP BY计算。然而,通过 Spark Streaming 全局聚合,可以通过执行有状态的流计算来快速完成这个计算。也就是说,使用 Spark Streaming 框架,执行聚合所需的所有信息都保存在内存中(即保持数据在state中),以便在其小时间窗口内进行计算。

准备就绪

对于这些 Apache Spark Streaming 示例,我们将通过 bash 终端创建和执行一个控制台应用程序。为了简化操作,您需要打开两个终端窗口。

如何做到这一点...

如前一节所述,我们将使用两个终端窗口:

  • 一个终端窗口用于传输事件

  • 另一个终端接收这些事件

此代码的源代码可以在 Apache Spark 1.6 Streaming 编程指南中找到:spark.apache.org/docs/1.6.0/streaming-programming-guide.html

终端 1 - Netcat 窗口

对于第一个窗口,我们将使用 Netcat(或nc)手动发送事件,如蓝色、绿色和 gohawks。要启动 Netcat,请使用以下命令;我们将把我们的事件定向到端口9999,我们的 Spark Streaming 作业将检测到:

nc -lk 9999

为了匹配前面的图表,我们将输入我们的事件,以便控制台屏幕看起来像这样:

$nc -lk 9999
blue blue blue blue blue green green green
gohawks
green green 

终端 2 - Spark Streaming 窗口

我们将使用以下代码创建一个简单的 PySpark Streaming 应用程序,名为streaming_word_count.py

#
# stateful_streaming_word_count.py
#

# Import the necessary classes and create a local SparkContext and Streaming Contexts
from pyspark import SparkContext
from pyspark.streaming import StreamingContext

# Create Spark Context with two working threads (note, `local[2]`)
sc = SparkContext("local[2]", "StatefulNetworkWordCount")

# Create local StreamingContextwith batch interval of 1 second
ssc = StreamingContext(sc, 1)

# Create checkpoint for local StreamingContext
ssc.checkpoint("checkpoint")

# Define updateFunc: sum of the (key, value) pairs
def updateFunc(new_values, last_sum):
   return sum(new_values) + (last_sum or 0)

# Create DStream that will connect to the stream of input lines from connection to localhost:9999
lines = ssc.socketTextStream("localhost", 9999)

# Calculate running counts
# Line 1: Split lines in to words
# Line 2: count each word in each batch
# Line 3: Run `updateStateByKey` to running count
running_counts = lines.flatMap(lambda line: line.split(" "))\
          .map(lambda word: (word, 1))\
          .updateStateByKey(updateFunc)

# Print the first ten elements of each RDD generated in this stateful DStream to the console
running_counts.pprint()

# Start the computation
ssc.start() 

# Wait for the computation to terminate
ssc.awaitTermination() 

要运行此 PySpark Streaming 应用程序,请从您的$SPARK_HOME文件夹执行以下命令:

./bin/spark-submit stateful_streaming_word_count.py localhost 9999

在计时方面,您应该:

  1. 首先使用nc -lk 9999

  2. 然后,启动您的 PySpark Streaming 应用程序:./bin/spark-submit stateful_streaming_word_count.py localhost 9999

  3. 然后,开始输入您的事件,例如:

  4. 第一秒,输入blue blue blue blue blue green green green

  5. 第二秒,输入gohawks

  6. 等一秒;第四秒,输入green green

您的 PySpark 流应用程序的控制台输出将类似于以下输出:

$ ./bin/spark-submit stateful_streaming_word_count.py localhost 9999
-------------------------------------------
Time: 2018-06-21 23:00:30
-------------------------------------------
(u'blue', 5)
(u'green', 3)
-------------------------------------------
Time: 2018-06-21 23:00:31
-------------------------------------------
(u'blue', 5)
(u'green', 3)
(u'gohawks', 1)
-------------------------------------------
Time: 2018-06-21 23:00:32
-------------------------------------------
-------------------------------------------
Time: 2018-06-21 23:00:33
-------------------------------------------
(u'blue', 5)
(u'green', 5)
(u'gohawks', 1)
------------------------------------------- 

要结束流应用程序(以及nc窗口),执行终止命令(例如,Ctrl + C)。

它是如何工作的...

如前几节所述,这个示例由一个终端窗口传输事件数据使用nc组成。第二个窗口运行我们的 Spark Streaming 应用程序,从第一个窗口传输到的端口读取数据。

此代码的重要调用如下所示:

  • 我们使用两个工作线程创建一个 Spark 上下文,因此使用local[2]

  • 如 Netcat 窗口中所述,我们使用ssc.socketTextStream来监听localhost的本地套接字,端口为9999

  • 我们创建了一个updateFunc,它执行将先前的值与当前聚合值进行聚合的任务。

  • 请记住,对于每个 1 秒批处理,我们不仅仅是读取一行(例如,blue blue blue blue blue green green green),还要通过split将其拆分为单独的words

  • 我们使用 Python 的lambda函数和 PySpark 的mapreduceByKey函数来快速计算 1 秒批处理中单词的出现次数。例如,在blue blue blue blue blue green green green的情况下,有 5 个蓝色和 3 个绿色事件,如我们的流应用程序的2018-06-21 23:00:30报告的那样。

  • 与以前的流应用程序相比,当前的有状态版本计算了当前聚合(例如,五个蓝色和三个绿色事件)的运行计数(running_counts),并使用updateStateByKey。这使得 Spark Streaming 可以在先前定义的updateFunc的上下文中保持当前聚合的状态。

  • ssc.start()是指应用程序启动 Spark Streaming 上下文。

  • ssc.awaitTermination()正在等待终止命令以停止流应用程序(例如,Ctrl + C);否则,应用程序将继续运行。

使用结构化流进行连续聚合

如前几章所述,Spark SQL 或 DataFrame 查询的执行围绕着构建逻辑计划,选择一个基于成本优化器的物理计划(从生成的物理计划中选择一个),然后通过 Spark SQL 引擎 Catalyst 优化器生成代码(即代码生成)。结构化流引入的概念是增量执行计划。也就是说,结构化流会针对每个新的数据块重复应用执行计划。这样,Spark SQL 引擎可以利用包含在 Spark DataFrames 中的优化,并将其应用于传入的数据流。因为结构化流是构建在 Spark DataFrames 之上的,这意味着它也将更容易地集成其他 DataFrame 优化的组件,包括 MLlib、GraphFrames、TensorFrames 等等:

准备工作

对于这些 Apache Spark Streaming 示例,我们将通过 bash 终端创建和执行控制台应用程序。为了使事情变得更容易,您需要打开两个终端窗口。

如何做...

如前一节所述,我们将使用两个终端窗口:

  • 一个终端窗口传输一个事件

  • 另一个终端接收这些事件

此源代码可以在 Apache Spark 2.3.1 结构化流编程指南中找到:spark.apache.org/docs/latest/structured-streaming-programming-guide.html

终端 1-Netcat 窗口

对于第一个窗口,我们将使用 Netcat(或nc)手动发送事件,例如 blue、green 和 gohawks。要启动 Netcat,请使用此命令;我们将把我们的事件定向到端口9999,我们的 Spark Streaming 作业将检测到:

nc -lk 9999

为了匹配之前的图表,我们将输入我们的事件,以便控制台屏幕看起来像这样:

$nc -lk 9999
blue blue blue blue blue green green green
gohawks
green green 

终端 2-Spark Streaming 窗口

我们将使用以下代码创建一个简单的 PySpark Streaming 应用程序,名为structured_streaming_word_count.py

#
# structured_streaming_word_count.py
#

# Import the necessary classes and create a local SparkSession
from pyspark.sql import SparkSession
from pyspark.sql.functions import explode
from pyspark.sql.functions import split

spark = SparkSession \
  .builder \
  .appName("StructuredNetworkWordCount") \
  .getOrCreate()

 # Create DataFrame representing the stream of input lines from connection to localhost:9999
lines = spark\
  .readStream\
  .format('socket')\
  .option('host', 'localhost')\
  .option('port', 9999)\
  .load()

# Split the lines into words
words = lines.select(
  explode(
      split(lines.value, ' ')
  ).alias('word')
)

# Generate running word count
wordCounts = words.groupBy('word').count()

# Start running the query that prints the running counts to the console
query = wordCounts\
  .writeStream\
  .outputMode('complete')\
  .format('console')\
  .start()

# Await Spark Streaming termination
query.awaitTermination()

要运行此 PySpark Streaming 应用程序,请从您的$SPARK_HOME文件夹执行以下命令:

./bin/spark-submit structured_streaming_word_count.py localhost 9999

在计时方面,您应该:

  1. 首先从nc -lk 9999开始。

  2. 然后,启动您的 PySpark Streaming 应用程序:./bin/spark-submit stateful_streaming_word_count.py localhost 9999

  3. 然后,开始输入您的事件,例如:

  4. 对于第一秒,输入blue blue blue blue blue green green green

  5. 对于第二秒,输入gohawks

  6. 等一下;在第四秒,输入green green

您的 PySpark 流应用程序的控制台输出将类似于以下内容:

$ ./bin/spark-submit structured_streaming_word_count.py localhost 9999
-------------------------------------------
Batch: 0
-------------------------------------------
+-----+-----+
| word|count|
+-----+-----+
|green|    3|
| blue|    5|
+-----+-----+

-------------------------------------------
Batch: 1
-------------------------------------------
+-------+-----+
|   word|count|
+-------+-----+
|  green|    3|
|   blue|    5|
|gohawks|    1|
+-------+-----+

-------------------------------------------
Batch: 2
-------------------------------------------
+-------+-----+
|   word|count|
+-------+-----+
|  green|    5|
|   blue|    5|
|gohawks|    1|
+-------+-----+

要结束流应用程序(以及nc窗口),执行终止命令(例如,Ctrl + C)。

与 DStreams 的全局聚合类似,使用结构化流,您可以在 DataFrame 的上下文中轻松执行有状态的全局聚合。您还会注意到结构化流的另一个优化是,只有在有新事件时,流聚合才会出现。特别注意当我们在时间=2 秒和时间=4 秒之间延迟时,控制台没有额外的批次报告。

它是如何工作的...

如前文所述,此示例由一个终端窗口组成,该窗口使用nc传输事件数据。第二个窗口运行我们的 Spark Streaming 应用程序,从第一个窗口传输到的端口读取。

此代码的重要部分在这里标出:

  • 我们创建一个SparkSession而不是创建一个 Spark 上下文

  • 有了 SparkSession,我们可以使用readStream指定socket format来指定我们正在监听localhost的端口9999

  • 我们使用 PySpark SQL 函数splitexplode来获取我们的line并将其拆分为words

  • 要生成我们的运行词计数,我们只需要创建wordCounts来运行groupBy语句和count()words

  • 最后,我们将使用writeStreamquery数据的complete集写入console(而不是其他数据汇)

  • 因为我们正在使用一个 Spark 会话,该应用程序正在等待终止命令来停止流应用程序(例如,)通过query.awaitTermination()

因为结构化流使用 DataFrames,所以它更简单、更容易阅读,因为我们使用熟悉的 DataFrame 抽象,同时也获得了所有 DataFrame 的性能优化。

第八章:GraphFrames - 使用 PySpark 进行图论

在本章中,我们将介绍如何使用 Apache Spark 的 GraphFrames。您将学习以下内容:

  • 关于 Apache Spark 的图论和 GraphFrames 的快速入门

  • 安装 GraphFrames

  • 准备数据

  • 构建图

  • 针对图运行查询

  • 理解图

  • 使用 PageRank 确定机场排名

  • 寻找最少的连接数

  • 可视化您的图

介绍

图形使解决某些数据问题更加容易和直观。图的核心概念是边、节点(或顶点)及其属性。例如,以下是两个看似不相关的图。左边的图代表一个社交网络和朋友之间的关系(图的),而右边的图代表餐厅推荐。请注意,我们的餐厅推荐的顶点不仅是餐厅本身,还包括美食类型(例如拉面)和位置(例如加拿大卑诗省温哥华);这些是顶点的属性。将节点分配给几乎任何东西,并使用边来定义这些节点之间的关系的能力是图的最大优点,即它们的灵活性:

这种灵活性使我们能够在概念上将这两个看似不相关的图连接成一个共同的图。在这种情况下,我们可以将社交网络与餐厅推荐连接起来,其中朋友和餐厅之间的边(即连接)是通过他们的评分进行的:

例如,如果 Isabella 想要在温哥华找到一家很棒的拉面餐厅(顶点:美食类型),然后遍历她朋友的评价(边:评分),她很可能会选择 Kintaro Ramen(顶点:餐厅),因为 Samantha(顶点:朋友)和 Juliette(顶点:朋友)都对这家餐厅给出了好评。

虽然图形直观且灵活,但图形的一个关键问题是其遍历和计算图形算法通常需要大量资源且速度缓慢。使用 Apache Spark 的 GraphFrames,您可以利用 Apache Spark DataFrames 的速度和性能来分布式遍历和计算图形。

安装 GraphFrames

GraphFrames 的核心是两个 Spark DataFrames:一个用于顶点,另一个用于边。GraphFrames 可以被认为是 Spark 的 GraphX 库的下一代,相对于后者有一些重大改进:

  • GraphFrames 利用了 DataFrame API 的性能优化和简单性。

  • 通过使用 DataFrame API,GraphFrames 可以通过 Python、Java 和 Scala API 进行交互。相比之下,GraphX 只能通过 Scala 接口使用。

您可以在graphframes.github.io/的 GraphFrames 概述中找到 GraphFrames 的最新信息。

准备就绪

我们需要一个可用的 Spark 安装。这意味着您需要按照第一章中概述的步骤进行操作,即安装和配置 Spark。作为提醒,要启动本地 Spark 集群的 PySpark shell,您可以运行以下命令:

./bin/pyspark --master local[n]

其中n是核心数。

如何做...

如果您正在从 Spark CLI(例如spark-shellpysparkspark-sqlspark-submit)运行作业,您可以使用--packages命令,该命令将为您提取、编译和执行必要的代码,以便您使用 GraphFrames 包。

例如,要在 Spark 2.1 和 Scala 2.11 与spark-shell一起使用最新的 GraphFrames 包(在撰写本书时为版本 0.5),命令是:

$SPARK_HOME/bin/pyspark --packages graphframes:graphframes:0.5.0-spark2.3-s_2.11

然而,为了在 Spark 2.3 中使用 GraphFrames,您需要从源代码构建包。

查看此处概述的步骤:github.com/graphframes/graphframes/issues/267

如果您使用类似 Databricks 的服务,您将需要创建一个包含 GraphFrames 的库。有关更多信息,请参阅 Databricks 中如何创建库的信息,以及如何安装 GraphFrames Spark 包。

它是如何工作的...

您可以通过在 GraphFrames GitHub 存储库上构建来安装 GraphFrames 等包,但更简单的方法是使用可在spark-packages.org/package/graphframes/graphframes找到的 GraphFrames Spark 包。Spark Packages 是一个包含 Apache Spark 第三方包索引的存储库。通过使用 Spark 包,PySpark 将下载 GraphFrames Spark 包的最新版本,编译它,然后在您的 Spark 作业上下文中执行它。

当您使用以下命令包含 GraphFrames 包时,请注意graphframes控制台输出,表示该包正在从spark-packages存储库中拉取进行编译:

$ ./bin/pyspark --master local --packages graphframes:graphframes:0.5.0-spark2.1-s_2.11
...
graphframes#graphframes added as a dependency
:: resolving dependencies :: org.apache.spark#spark-submit-parent;1.0
  confs: [default]
  found graphframes#graphframes;0.5.0-spark2.1-s_2.11 in spark-packages
  found com.typesafe.scala-logging#scala-logging-api_2.11;2.1.2 in central
  found com.typesafe.scala-logging#scala-logging-slf4j_2.11;2.1.2 in central
  found org.scala-lang#scala-reflect;2.11.0 in central
  found org.slf4j#slf4j-api;1.7.7 in central
downloading http://dl.bintray.com/spark-packages/maven/graphframes/graphframes/0.5.0-spark2.1-s_2.11/graphframes-0.5.0-spark2.1-s_2.11.jar ...
  [SUCCESSFUL ] graphframes#graphframes;0.5.0-spark2.1-s_2.11!graphframes.jar (600ms)
:: resolution report :: resolve 1503ms :: artifacts dl 608ms
  :: modules in use:
  com.typesafe.scala-logging#scala-logging-api_2.11;2.1.2 from central in [default]
  com.typesafe.scala-logging#scala-logging-slf4j_2.11;2.1.2 from central in [default]
  graphframes#graphframes;0.5.0-spark2.1-s_2.11 from spark-packages in [default]
  org.scala-lang#scala-reflect;2.11.0 from central in [default]
  org.slf4j#slf4j-api;1.7.7 from central in [default]
  ---------------------------------------------------------------------
  | | modules || artifacts |
  | conf | number| search|dwnlded|evicted|| number|dwnlded|

  ---------------------------------------------------------------------
  | default | 5 | 1 | 1 | 0 || 5 | 1 |
  ---------------------------------------------------------------------
:: retrieving :: org.apache.spark#spark-submit-parent
  confs: [default]
  1 artifacts copied, 4 already retrieved (323kB/9ms)

准备数据

我们在烹饪书中将使用的示例场景是准点飞行表现数据(即,航班场景),它将使用两组数据:

  • 航空公司准点表现和航班延误原因可在bit.ly/2ccJPPM找到。这些数据集包含有关航班计划和实际起飞和到达时间以及延误原因的信息。数据由美国航空公司报告,并由交通统计局航空公司信息办公室收集。

  • OpenFlights,机场和航空公司数据可在openflights.org/data.html找到。该数据集包含美国机场数据列表,包括 IATA 代码、机场名称和机场位置。

我们将创建两个数据框:一个用于机场,一个用于航班。airports数据框将构成我们的顶点,而flights数据框将表示我们的 GraphFrame 的所有边。

准备工作

如果您正在本地运行此程序,请将链接的文件复制到本地文件夹;为了这个示例,我们将称位置为/data

如果您使用 Databricks,数据已经加载到/databricks-datasets文件夹中;文件的位置可以在/databricks-datasets/flights/airport-codes-na.txt/databricks-datasets/flights/departuredelays.csv中找到,分别用于机场和航班数据。

如何做...

为了准备我们的图数据,我们将首先清理数据,并仅包括存在于可用航班数据中的机场代码。也就是说,我们排除任何在DepartureDelays.csv数据集中不存在的机场。接下来的步骤执行以下操作:

  1. 设置文件路径为您下载的文件

  2. 通过读取 CSV 文件并推断架构,配置了标题,创建了aptsdeptDelays数据框

  3. iata仅包含存在于deptDelays数据框中的机场代码(IATA列)。

  4. iataapts数据框连接起来,创建apts_df数据框

我们过滤数据以创建airports DataFrame 的原因是,当我们在下面的示例中创建我们的 GraphFrame 时,我们将只有图的边缘的顶点:

# Set File Paths
delays_fp = "/data/departuredelays.csv"
apts_fp = "/data/airport-codes-na.txt"

# Obtain airports dataset
apts = spark.read.csv(apts_fp, header='true', inferSchema='true', sep='\t')
apts.createOrReplaceTempView("apts")

# Obtain departure Delays data
deptsDelays = spark.read.csv(delays_fp, header='true', inferSchema='true')
deptsDelays.createOrReplaceTempView("deptsDelays")
deptsDelays.cache()

# Available IATA codes from the departuredelays sample dataset
iata = spark.sql("""
    select distinct iata 
    from (
        select distinct origin as iata 
        from deptsDelays 

        union all 
        select distinct destination as iata 
        from deptsDelays
    ) as a
""")
iata.createOrReplaceTempView("iata")

# Only include airports with atleast one trip from the departureDelays dataset
airports = sqlContext.sql("""
    select f.IATA
        , f.City
        , f.State
        , f.Country 
    from apts as f 
    join iata as t 
        on t.IATA = f.IATA
""")
airports.registerTempTable("airports")
airports.cache()

它是如何工作的...

用于此代码片段的两个关键概念是:

  • spark.read.csv:这个SparkSession方法返回一个DataFrameReader对象,它包含了从文件系统读取 CSV 文件的类和函数

  • spark.sql:这允许我们执行 Spark SQL 语句

有关更多信息,请参考 Spark DataFrames 的前几章,或者参考pyspark.sql模块的 PySpark 主文档,网址为spark.apache.org/docs/2.3.0/api/python/pyspark.sql.html

还有更多...

在将数据读入我们的 GraphFrame 之前,让我们再创建一个 DataFrame:

import pyspark.sql.functions as f
import pyspark.sql.types as t

@f.udf
def toDate(weirdDate):
    year = '2014-'
    month = weirdDate[0:2] + '-'
    day = weirdDate[2:4] + ' '
    hour = weirdDate[4:6] + ':'
    minute = weirdDate[6:8] + ':00'

    return year + month + day + hour + minute 

deptsDelays = deptsDelays.withColumn('normalDate', toDate(deptsDelays.date))
deptsDelays.createOrReplaceTempView("deptsDelays")

# Get key attributes of a flight
deptsDelays_GEO = spark.sql("""
    select cast(f.date as int) as tripid
        , cast(f.normalDate as timestamp) as `localdate`
        , cast(f.delay as int)
        , cast(f.distance as int)
        , f.origin as src
        , f.destination as dst
        , o.city as city_src
        , d.city as city_dst
        , o.state as state_src
        , d.state as state_dst 
    from deptsDelays as f 
    join airports as o 
        on o.iata = f.origin 
    join airports as d 
        on d.iata = f.destination
""") 

# Create Temp View
deptsDelays_GEO.createOrReplaceTempView("deptsDelays_GEO")

# Cache and Count
deptsDelays_GEO.cache()
deptsDelays_GEO.count()
deptsDelays_GEO DataFrame:
  • 它创建了一个tripid列,允许我们唯一标识每次旅行。请注意,这有点像是一个黑客行为,因为我们已经将日期(数据集中每次旅行都有一个唯一日期)转换为 int 列。

  • date列实际上并不是传统意义上的日期,因为它的格式是MMYYHHmm。因此,我们首先应用udf将其转换为正确的格式(toDate(...)方法)。然后将其转换为实际的时间戳格式。

  • delaydistance列重新转换为整数值,而不是字符串。

  • 在接下来的几节中,我们将使用机场代码(iata列)作为我们的顶点。为了为我们的图创建边缘,我们需要指定源(起始机场)和目的地(目的机场)的 IATA 代码。join语句和将f.origin重命名为src以及将f.destination重命名为dst是为了准备创建 GraphFrame 以指定边缘(它们明确寻找srcdst列)。

构建图

在前面的章节中,您安装了 GraphFrames 并构建了图所需的 DataFrame;现在,您可以开始构建图本身了。

如何做...

这个示例的第一个组件涉及到导入必要的库,这种情况下是 PySpark SQL 函数(pyspark.sql.functions)和 GraphFrames(graphframes)。在上一个示例中,我们已经创建了deptsDelays_geo DataFrame 的一部分,创建了srcdst列。在 GraphFrames 中创建边缘时,它专门寻找srcdst列来创建边缘,就像edges一样。同样,GraphFrames 正在寻找id列来表示图的顶点(以及连接到srcdst列)。因此,在创建顶点vertices时,我们将IATA列重命名为id

from pyspark.sql.functions import *
from graphframes import *

# Create Vertices (airports) and Edges (flights)
vertices = airports.withColumnRenamed("IATA", "id").distinct()
edges = deptsDelays_geo.select("tripid", "delay", "src", "dst", "city_dst", "state_dst")

# Cache Vertices and Edges
edges.cache()
vertices.cache()

# This GraphFrame builds up on the vertices and edges based on our trips (flights)
graph = GraphFrame(vertices, edges)

请注意,edgesvertices是包含图的边缘和顶点的 DataFrame。您可以通过查看数据来检查这一点,如下面的屏幕截图所示(在这种情况下,我们在 Databricks 中使用display命令)。

例如,命令display(vertices)显示vertices DataFrame 的id(IATA 代码)、CityStateCountry列:

同时,命令display(edges)显示edges DataFrame 的tripiddelaysrcdstcity_dststate_dst

最后的语句GraphFrame(vertices, edges)执行将两个 DataFrame 合并到我们的 GraphFrame graph中的任务。

它是如何工作的...

如前一节所述,创建 GraphFrame 时,它专门寻找以下列:

  • id:这标识了顶点,并将连接到srcdst列。在我们的示例中,IATA 代码LAX(代表洛杉矶机场)是构成我们图的顶点之一。

  • src:我们图的边的源顶点;例如,从洛杉矶到纽约的航班的src = LAX

  • dst: 我们图的边的目的地顶点;例如,从洛杉矶到纽约的航班的dst = JFK

通过创建两个数据框(verticesedges),其中属性遵循先前提到的命名约定,我们可以调用 GraphFrame 来创建我们的图,利用两个数据框的性能优化。

对图运行查询

现在您已经创建了图,可以开始对 GraphFrame 运行一些简单的查询。

准备工作

确保您已经从上一节的verticesedges数据框中创建了graph GraphFrame。

如何操作...

让我们从一些简单的计数查询开始,以确定机场的数量(节点或顶点;记住吗?)和航班的数量(边),可以通过应用count()来确定。调用count()类似于数据框,只是您还需要包括您正在计数vertices还是edges

print "Airport count: %d" % graph.vertices.count()
print "Trips count: %d" % graph.edges.count()

这些查询的输出应该类似于以下输出,表示有 279 个顶点(即机场)和超过 130 万条边(即航班):

Output:
  Airports count: 279 
  Trips count: 1361141

与数据框类似,您也可以执行filtergroupBy子句,以更好地了解延误航班的数量。要了解准点或提前到达的航班数量,我们使用delay <= 0的过滤器;而延误航班则显示delay > 0

print "Early or on-time: %d" % graph.edges.filter("delay <= 0").count()
print "Delayed: %d" % graph.edges.filter("delay > 0").count()

# Output
Early or on-time: 780469
Delayed: 580672

进一步深入,您可以过滤出从旧金山出发的延误航班(delay > 0),并按目的地机场分组,按平均延误时间降序排序(desc("avg(delay)")):

display(
    graph
    .edges
    .filter("src = 'SFO' and delay > 0")
    .groupBy("src", "dst")
    .avg("delay")
    .sort(desc("avg(delay)"))
)

如果您正在使用 Databricks 笔记本,可以可视化 GraphFrame 查询。例如,我们可以使用以下查询确定从西雅图出发延误超过 100 分钟的航班的目的地州:

# States with the longest cumulative delays (with individual delays > 100 minutes) 
# origin: Seattle
display(graph.edges.filter("src = 'SEA' and delay > 100"))

上述代码生成了以下地图。蓝色越深,航班延误越严重。从下图可以看出,大部分从西雅图出发的延误航班的目的地在加利福尼亚州内:

操作原理...

如前几节所述,GraphFrames 建立在两个数据框之上:一个用于顶点,一个用于边。这意味着 GraphFrames 利用了与数据框相同的性能优化(不像较旧的 GraphX)。同样重要的是,它们还继承了许多 Spark SQL 语法的组件。

理解图

为了更容易理解城市机场之间的复杂关系以及它们之间的航班,我们可以使用motifs的概念来查找由航班连接的机场的模式。结果是一个数据框,其中列名由 motif 键给出。

准备工作

为了更容易在 Motifs 的上下文中查看我们的数据,让我们首先创建一个名为graphSmallgraph GraphFrame 的较小版本:

edgesSubset = deptsDelays_GEO.select("tripid", "delay", "src", "dst")
graphSmall = GraphFrame(vertices, edgesSubset)

如何操作...

要执行 Motif,执行以下命令:

motifs = (
    graphSmall
    .find("(a)-[ab]->(b); (b)-[bc]->(c)")
    .filter("""
        (b.id = 'SFO') 
        and (ab.delay > 500 or bc.delay > 500) 
        and bc.tripid > ab.tripid 
        and bc.tripid < ab.tripid + 10000
    """)
)
display(motifs)

此查询的结果如下:

Motif 查询的输出

操作原理...

这个例子的查询有很多内容,让我们从查询本身开始。查询的第一部分是建立我们的 Motif,即建立我们要查找顶点(a)(b)(c)之间的关系。具体来说,我们关心的是两组顶点之间的边,即(a)(b)之间的边,表示为[ab],以及顶点(b)(c)之间的边,表示为[bc]

graphSmall.find("(a)-[ab]->(b); (b)-[bc]->(c)")

例如,我们试图确定两个不同城市之间的所有航班,洛杉矶是中转城市(例如,西雅图 - 洛杉矶 -> 纽约,波特兰 - 洛杉矶 -> 亚特兰大,等等):

  • (b): 这代表了洛杉矶市

  • (a): 这代表了起始城市,例如本例中的西雅图和波特兰

  • [ab]:这代表了航班,比如西雅图-洛杉矶和波特兰-洛杉矶在这个例子中

  • (c):这代表了目的地城市,比如纽约和亚特兰大在这个例子中

  • [bc]:这代表了航班,比如洛杉矶->纽约和洛杉矶->亚特兰大在这个例子中

b.id = 'SFO'). We're also specifying any trips (that is, graph edges) where the delay is greater than 500 minutes (ab.delay > 500 or bc.delay > 500). We have also specified that the second leg of the trip must occur after the first leg of the trip (bc.tripid > ab.tripid and bc.tripid < ab.tripid + 10000").

请注意,这个陈述是对航班的过度简化,因为它没有考虑哪些航班是有效的连接航班。还要记住,tripid是基于时间格式为MMDDHHMM转换为整数生成的:

filter("(b.id = 'SFO') and (ab.delay > 500 or bc.delay > 500) and bc.tripid > ab.tripid and bc.tripid < ab.tripid + 10000")

前面小节中显示的输出表示了所有在旧金山中转并且航班延误超过 500 分钟的航班。进一步挖掘单个航班,让我们回顾第一行的输出,尽管我们已经对其进行了旋转以便更容易审查:

顶点 数值
[ab]
  • tripid: 2021900

  • delay: 39

  • src: STL

  • dst: SFO

|

(a)
  • id: STL

  • City: St. Louis

  • State: MO

  • Country: USA

|

(b)
  • id: SFO

  • City: San Francisco

  • State: CA

  • Country: USA

|

[bc]
  • tripid: 2030906

  • delay: 516

  • src: SFO

  • dst: PHL

|

(c)
  • id: PHL

  • City: Philadelphia

  • State: PA

  • Country: USA

|

如前所述,[ab][bc]是航班,而[a][b][c]是机场。在这个例子中,从圣路易斯(STL)到旧金山的航班延误了 39 分钟,但它潜在的连接航班到费城(PHL)延误了 516 分钟。当您深入研究结果时,您可以看到围绕旧金山作为主要中转站的起始和最终目的地城市之间的许多不同的潜在航班模式。随着您接管更大的枢纽城市,如亚特兰大、达拉斯和芝加哥,这个查询将变得更加复杂。

使用 PageRank 确定机场排名

PageRank 是由谷歌搜索引擎推广并由拉里·佩奇创建的算法。Ian Rogers 说(见www.cs.princeton.edu/~chazelle/courses/BIB/pagerank.htm):

“(...)PageRank 是所有其他网页对页面重要性的“投票”。对页面的链接算作支持的投票。如果没有链接,就没有支持(但这是对页面的投票而不是反对的弃权)。”

您可能会想象,这种方法不仅可以应用于排名网页,还可以应用于其他问题。在我们的情境中,我们可以用它来确定机场的排名。为了实现这一点,我们可以使用包括在这个出发延误数据集中的各种机场的航班数量和连接到各个机场的航班数量。

准备工作

确保您已经从前面的小节中创建了graph GraphFrame。

如何做...

执行以下代码片段,通过 PageRank 算法确定我们数据集中最重要的机场:

# Determining Airport ranking of importance using `pageRank`
ranks = graph.pageRank(resetProbability=0.15, maxIter=5)
display(ranks.vertices.orderBy(ranks.vertices.pagerank.desc()).limit(20))

从以下图表的输出中可以看出,亚特兰大、达拉斯和芝加哥是最重要的三个城市(请注意,此数据集仅包含美国数据):

它是如何工作的...

在撰写本书时,GraphFrames 的当前版本是 v0.5,其中包含了 PageRank 的两种实现:

  • 我们正在使用的版本利用了 GraphFrame 接口,并通过设置maxIter运行了固定次数的 PageRank。

  • 另一个版本使用org.apache.spark.graphx.Pregel接口,并通过设置tol运行 PageRank 直到收敛。

有关更多信息,请参阅graphframes.github.io/api/scala/index.html#org.graphframes.lib.PageRank上的 GraphFrames Scala 文档中的 PageRank。

如前所述,我们正在使用独立的 GraphFrame 版本的 PageRank,设置如下:

  • resetProbability:目前设置为默认值0.15,表示重置到随机顶点的概率。如果值太高,计算时间会更长,但如果值太低,计算可能会超出范围而无法收敛。

  • maxIter:对于此演示,我们将该值设置为5;数字越大,计算的精度越高。

寻找最少的连接

当您飞往许多城市时,一个经常出现的问题是确定两个城市之间的最短路径或最短旅行时间。从航空旅客的角度来看,目标是找到两个城市之间最短的航班组合。从航空公司的角度来看,确定如何尽可能高效地将乘客路由到各个城市,可以提高客户满意度并降低价格(燃料消耗、设备磨损、机组人员的便利等)。在 GraphFrames 和图算法的背景下,一个方法是使用广度优先搜索BFS)算法来帮助我们找到这些机场之间的最短路径。

准备工作

确保您已经从前面的小节中创建了graph GraphFrame。

操作步骤...

让我们开始使用我们的 BFS 算法来确定SFOSEA之间是否有直达航班:

subsetOfPaths = graph.bfs(
   fromExpr = "id = 'SEA'",
   toExpr = "id = 'SFO'",
   maxPathLength = 1)

display(subsetOfPaths)

从输出中可以看出,西雅图(SEA)和旧金山(SFO)之间有许多直达航班:

工作原理...

在调用 BFS 算法时,关键参数是fromExprtoExprmaxPathLength。由于我们的顶点包含了机场,为了了解从西雅图到旧金山的直达航班数量,我们将指定:

fromExpr = "id = 'SEA'",
toExpr = "id = 'SFO'

maxPathLength是用来指定两个顶点之间的最大边数的参数。如果maxPathLength = 1,表示两个顶点之间只有一条边。也就是说,两个机场之间只有一次航班或者两个城市之间有一次直达航班。增加这个值意味着 BFS 将尝试找到两个城市之间的多个连接。例如,如果我们指定maxPathLength = 2,这意味着西雅图和旧金山之间有两条边或两次航班。这表示一个中转城市,例如,SEA - POR -> SFO,SEA - LAS -> SFO,SEA - DEN -> SFO 等。

还有更多...

如果您想要找到通常没有直达航班的两个城市之间的连接,该怎么办?例如,让我们找出旧金山和水牛城之间的可能航线:

subsetOfPaths = graph.bfs(
   fromExpr = "id = 'SFO'",
   toExpr = "id = 'BUF'",
   maxPathLength = 1)

display(subsetOfPaths)

Output:
   OK

在这种情况下,OK表示旧金山和水牛城之间没有直达航班,因为我们无法检索到单个边缘(至少从这个数据集中)。但是,要找出是否有任何中转航班,只需更改maxPathLength = 2(表示一个中转城市):

subsetOfPaths = graph.bfs(
   fromExpr = "id = 'SFO'",
   toExpr = "id = 'BUF'",
   maxPathLength = 2)

display(subsetOfPaths)

如您所见,有许多带有一次中转的航班连接旧金山和水牛城:

另请参阅

但是旧金山和水牛城之间最常见的中转城市是哪个?从前面的结果来看,似乎是明尼阿波利斯,但外表可能具有欺骗性。相反,运行以下查询:

display(subsetOfPaths.groupBy("v1.id", "v1.City").count().orderBy(desc("count")).limit(10))

如下图所示,JFK 是这两个城市之间最常见的中转点:

可视化图形

在前面的示例中,我们一直在使用 Databrick 笔记本的本地可视化功能来可视化我们的航班(例如,条形图、折线图、地图等)。但是我们还没有将我们的图形可视化为图形。在本节中,我们将利用 Mike Bostock 的 Airports D3.js 可视化工具(mbostock.github.io/d3/talk/20111116/airports.html)在我们的 Databricks 笔记本中进行可视化。

准备工作

确保您已经从前面的小节中创建了graph GraphFrame 和源deptsDelays_GEO DataFrame。

如何做...

我们将利用我们的 Python Databricks 笔记本,但我们将包括以下 Scala 单元。在这里的顶层,代码的流程如下:

%scala
package d3a

import org.apache.spark.sql._
import com.databricks.backend.daemon.driver.EnhancedRDDFunctions.displayHTML

case class Edge(src: String, dest: String, count: Long)
case class Node(name: String)
case class Link(source: Int, target: Int, value: Long)
case class Graph(nodes: Seq[Node], links: Seq[Link])

object graphs {
val sqlContext = SQLContext.getOrCreate(org.apache.spark.SparkContext.getOrCreate())
import sqlContext.implicits._

def force(clicks: Dataset[Edge], height: Int = 100, width: Int = 960): Unit = {
  val data = clicks.collect()
  val nodes = (data.map(_.src) ++ data.map(_.dest)).map(_.replaceAll("_", " ")).toSet.toSeq.map(Node)
  val links = data.map { t =>
    Link(nodes.indexWhere(_.name == t.src.replaceAll("_", " ")), nodes.indexWhere(_.name == t.dest.replaceAll("_", " ")), t.count / 20 + 1)
  }
  showGraph(height, width, Seq(Graph(nodes, links)).toDF().toJSON.first())
}

/**
 * Displays a force directed graph using d3
 * input: {"nodes": [{"name": "..."}], "links": [{"source": 1, "target": 2, "value": 0}]}
 */
def showGraph(height: Int, width: Int, graph: String): Unit = {

displayHTML(s"""<!DOCTYPE html>
<html>
  <head>
    <link type="text/css" rel="stylesheet" href="https://mbostock.github.io/d3/talk/20111116/style.css"/>
    <style type="text/css">
      #states path {
        fill: #ccc;
        stroke: #fff;
      }

      path.arc {
        pointer-events: none;
        fill: none;
        stroke: #000;
        display: none;
      }

      path.cell {
        fill: none;
        pointer-events: all;
      }

      circle {
        fill: steelblue;
        fill-opacity: .8;
        stroke: #fff;
      }

      #cells.voronoi path.cell {
        stroke: brown;
      }

      #cells g:hover path.arc {
        display: inherit;
      }
    </style>
  </head>
  <body>
    <script src="img/d3.js"></script>
    <script src="img/d3.csv.js"></script>
    <script src="img/d3.geo.js"></script>
    <script src="img/d3.geom.js"></script>
    <script>
      var graph = $graph;
      var w = $width;
      var h = $height;

      var linksByOrigin = {};
      var countByAirport = {};
      var locationByAirport = {};
      var positions = [];

      var projection = d3.geo.azimuthal()
          .mode("equidistant")
          .origin([-98, 38])
          .scale(1400)
          .translate([640, 360]);

      var path = d3.geo.path()
          .projection(projection);

      var svg = d3.select("body")
          .insert("svg:svg", "h2")
          .attr("width", w)
          .attr("height", h);

      var states = svg.append("svg:g")
          .attr("id", "states");

      var circles = svg.append("svg:g")
          .attr("id", "circles");

      var cells = svg.append("svg:g")
          .attr("id", "cells");

      var arc = d3.geo.greatArc()
          .source(function(d) { return locationByAirport[d.source]; })
          .target(function(d) { return locationByAirport[d.target]; });

      d3.select("input[type=checkbox]").on("change", function() {
        cells.classed("voronoi", this.checked);
      });

      // Draw US map.
      d3.json("https://mbostock.github.io/d3/talk/20111116/us-states.json", function(collection) {
        states.selectAll("path")
          .data(collection.features)
          .enter().append("svg:path")
          .attr("d", path);
      });

      // Parse links
      graph.links.forEach(function(link) {
        var origin = graph.nodes[link.source].name;
        var destination = graph.nodes[link.target].name;

        var links = linksByOrigin[origin] || (linksByOrigin[origin] = []);
        links.push({ source: origin, target: destination });

        countByAirport[origin] = (countByAirport[origin] || 0) + 1;
        countByAirport[destination] = (countByAirport[destination] || 0) + 1;
      });

      d3.csv("https://mbostock.github.io/d3/talk/20111116/airports.csv", function(data) {

      // Build list of airports.
      var airports = graph.nodes.map(function(node) {
        return data.find(function(airport) {
          if (airport.iata === node.name) {
            var location = [+airport.longitude, +airport.latitude];
            locationByAirport[airport.iata] = location;
            positions.push(projection(location));

            return true;
          } else {
            return false;
          }
        });
      });

      // Compute the Voronoi diagram of airports' projected positions.
      var polygons = d3.geom.voronoi(positions);

      var g = cells.selectAll("g")
        .data(airports)
        .enter().append("svg:g");

      g.append("svg:path")
        .attr("class", "cell")
        .attr("d", function(d, i) { return "M" + polygons[i].join("L") + "Z"; })
        .on("mouseover", function(d, i) { d3.select("h2 span").text(d.name); });

      g.selectAll("path.arc")
        .data(function(d) { return linksByOrigin[d.iata] || []; })
        .enter().append("svg:path")
        .attr("class", "arc")
        .attr("d", function(d) { return path(arc(d)); });

      circles.selectAll("circle")
        .data(airports)
        .enter().append("svg:circle")
        .attr("cx", function(d, i) { return positions[i][0]; })
        .attr("cy", function(d, i) { return positions[i][1]; })
        .attr("r", function(d, i) { return Math.sqrt(countByAirport[d.iata]); })
        .sort(function(a, b) { return countByAirport[b.iata] - countByAirport[a.iata]; });
      });
    </script>
  </body>
</html>""")
  }

  def help() = {
displayHTML("""
<p>
Produces a force-directed graph given a collection of edges of the following form:</br>
<tt><font color="#a71d5d">case class</font> <font color="#795da3">Edge</font>(<font color="#ed6a43">src</font>: <font color="#a71d5d">String</font>, <font color="#ed6a43">dest</font>: <font color="#a71d5d">String</font>, <font color="#ed6a43">count</font>: <font color="#a71d5d">Long</font>)</tt>
</p>
<p>Usage:<br/>
<tt>%scala</tt></br>
<tt><font color="#a71d5d">import</font> <font color="#ed6a43">d3._</font></tt><br/>
<tt><font color="#795da3">graphs.force</font>(</br>
  <font color="#ed6a43">height</font> = <font color="#795da3">500</font>,<br/>
  <font color="#ed6a43">width</font> = <font color="#795da3">500</font>,<br/>
  <font color="#ed6a43">clicks</font>: <font color="#795da3">Dataset</font>[<font color="#795da3">Edge</font>])</tt>
</p>""")
  }
}

在下一个单元格中,您将调用以下 Scala 单元:

%scala
// On-time and Early Arrivals
import d3a._
graphs.force(
 height = 800,
 width = 1200,
 clicks = sql("""select src, dst as dest, count(1) as count from deptsDelays_GEO where delay <= 0 group by src, dst""").as[Edge])

这导致以下可视化效果:

它是如何工作的...

package d3a, which specifies the JavaScript calls that define our airport visualization. As you dive into the code, you'll notice that this is a force-directed graph (def force) visualization that shows a graph (show graph) that builds up the map of the US and location of the airports (blue bubbles).

force函数有以下定义:

def force(clicks: Dataset[Edge], height: Int = 100, width: Int = 960): Unit = {
  ...
  showGraph(height, width, Seq(Graph(nodes, links)).toDF().toJSON.first())
}

回想一下,我们在下一个单元格中使用以下代码片段调用这个函数:

%scala
// On-time and Early Arrivals
import d3a._
graphs.force(
  height = 800,
  width = 1200,
  clicks = sql("""select src, dst as dest, count(1) as count from deptsDelays_GEO where delay <= 0 group by src, dst""").as[Edge])

高度和宽度是显而易见的,但关键的呼叫是我们使用 Spark SQL 查询来定义边缘(即源和目的地 IATA 代码)对deptsDelays_GEO DataFrame。由于 IATA 代码已经在showGraph的调用中定义,我们已经有了可视化的顶点。请注意,由于我们已经创建了 DataFrame deptsDelays_GEO,即使它是使用 PySpark 创建的,它也可以在同一个 Databricks 笔记本中被 Scala 访问。

posted @ 2024-05-21 12:53  绝不原创的飞龙  阅读(27)  评论(0编辑  收藏  举报