PySpark-秘籍-三-

57 阅读54分钟

PySpark 秘籍(三)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:使用 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 访问。