Python 大规模机器学习(三)
七、大规模无监督学习
在前几章中,问题的焦点是预测一个变量,它可能是一个数字、类或类别。在这一章中,我们将改变方法,并尝试在规模上创建新的特征和变量,希望比已经包含在观察矩阵中的特征和变量更好地用于我们的预测目的。我们将首先介绍无监督方法,并举例说明其中三种能够扩展到大数据的方法:
- 主成分分析 ( 主成分分析),一种减少特征数量的有效方法
- K-means ,一种可扩展的聚类算法
- 潜在狄利克雷分配 ( LDA ),一种能够从一系列文本文档中提取主题的非常有效的算法
无监督方法
无监督学习是机器学习的一个分支,其算法从没有明确标签(无标签数据)的数据中揭示推理。这种技术的目标是提取隐藏的模式,并将相似的数据分组。
在这些算法中,每个观察的未知感兴趣参数(例如组成员和主题组成)通常被建模为潜在变量(或一系列隐藏变量),隐藏在不能直接观察的观察变量系统中,而只能从系统的过去和现在的输出中推导出来。通常,系统的输出包含噪声,这使得操作更加困难。
在常见问题中,无监督方法主要用于两种情况:
- 使用标记数据集提取要由分类器/回归器向下处理到处理链的附加特征。通过附加功能的增强,它们可能会表现得更好。
- 用有标签或无标签的数据集来提取一些关于数据结构的信息。这类算法通常在建模的探索性数据分析 ( EDA )阶段使用。
首先,在开始演示之前,让我们沿着笔记本中的章节导入必要的模块:
In : import matplotlib
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import pylab
%matplotlib inline
import matplotlib.cm as cm
import copy
import tempfile
import os
特征分解–主成分分析
主成分分析是一种常用的算法,用于分解输入信号的维度,只保留主成分的维度。从数学角度来看,主成分分析对观测矩阵进行正交变换,输出一组线性不相关变量,称为主成分。输出变量形成一个基集,其中每个分量都与其他分量正交。此外,可以对输出分量进行排序(以便仅使用主分量),因为第一个分量包含输入数据集的最大可能方差,第二个分量与第一个分量正交(根据定义),包含残差信号的最大可能方差,第三个分量与前两个分量正交,并且基于残差方差,以此类推。
主成分分析的一般变换可以表示为空间的投影。如果只从变换基中提取主成分,输出空间将比输入空间具有更小的维度。数学上,它可以表达如下:
这里, X 是维度 N 训练集的泛点, T 是来自 PCA 的变换矩阵,是输出向量。请注意,符号表示该矩阵方程中的点积。从实用的角度来看,还要注意的是 X 的所有特性在做这个操作之前都必须以零为中心。
现在让我们从一个实际的例子开始;后面,我们将深入讲解数学 PCA。在本例中,我们将创建一个由两个点组成的虚拟数据集,一个点在(-5,0)中居中,另一个点在(5,5)中居中。让我们使用主成分分析来转换数据集,并将输出与输入进行比较。在这个简单的例子中,我们将使用所有的特征,也就是说,我们将不执行特征约简:
In:from sklearn.datasets.samples_generator import make_blobs
from sklearn.decomposition import PCA
X, y = make_blobs(n_samples=1000, random_state=101, \
centers=[[-5, 0], [5, 5]])
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X)
pca_comp = pca.components_.T
test_point = np.matrix([5, -2])
test_point_pca = pca.transform(test_point)
plt.subplot(1, 2, 1)
plt.scatter(X[:, 0], X[:, 1], c=y, edgecolors='none')
plt.quiver(0, 0, pca_comp[:,0], pca_comp[:,1], width=0.02, \
scale=5, color='orange')
plt.plot(test_point[0, 0], test_point[0, 1], 'o')
plt.title('Input dataset')
plt.subplot(1, 2, 2)
plt.scatter(X_pca[:, 0], X_pca[:, 1], c=y, edgecolors='none')
plt.plot(test_point_pca[0, 0], test_point_pca[0, 1], 'o')
plt.title('After "lossless" PCA')
plt.show()
如您所见,输出比原始要素的空间更有条理,如果下一个任务是分类,它将只需要数据集的一个要素,节省了几乎 50%的空间和所需的计算。在图像中,您可以清楚地看到 PCA 的核心:它只是输入数据集到左侧图像中以橙色绘制的变换基础的投影。你不确定吗?让我们测试一下:
In:print "The blue point is in", test_point[0, :]
print "After the transformation is in", test_point_pca[0, :]
print "Since (X-MEAN) * PCA_MATRIX = ", np.dot(test_point - \
pca.mean_, pca_comp)
Out:The blue point is in [[ 5 -2]]
After the transformation is in [-2.34969911 -6.2575445 ]
Since (X-MEAN) * PCA_MATRIX = [[-2.34969911 -6.2575445 ]
现在,我们来挖掘核心问题:如何从训练集中生成 T?它应该包含正交向量,向量应该根据它们可以解释的方差量(即观测矩阵携带的能量或信息)进行排序。已经实现了很多解决方案,但是最常见的实现是基于奇异值分解 ( 奇异值分解)。
奇异值分解是一种将任意矩阵 M 分解成三个具有特殊性质的矩阵的技术,这三个矩阵的乘法又返回了 M :
具体来说,给定 M ,一个由 m 行和 n 列组成的矩阵,等价的结果元素如下:
- U 是矩阵 m x m (正方形矩阵),它是酉的,它的列构成了正交的基。同样,它们被命名为左奇异向量,或者输入奇异向量,它们是矩阵乘积
的特征向量。
是一个矩阵 m x n ,它的对角线上只有非零元素。这些值被称为奇异值,都是非负的,并且都是
和
的特征值。
- w 是酉矩阵 n x n (方阵),它的列构成一个正交基,它们被命名为右(或输出)奇异向量。同样,它们是矩阵乘积
的特征向量。
为什么需要这样?解决方案非常简单:主成分分析的目标是尝试并估计输入数据集方差较大的方向。为此,我们首先需要去除每个特征的均值,然后对协方差矩阵进行运算。
假设,通过用 SVD 分解矩阵 X ,我们得到了矩阵 W 的列,它们是协方差的主成分(即,我们要寻找的矩阵 T ),的对角线包含由主成分解释的方差, U 的列是主成分。这就是为什么主成分分析总是用奇异值分解来完成。
现在我们来看一个真实的例子。让我们在 Iris 数据集上进行测试,提取前两个主成分(即从一个由四个特征组成的数据集传递到一个由两个特征组成的数据集):
In:from sklearn import datasets
iris = datasets.load_iris()
X = iris.data
y = iris.target
print "Iris dataset contains", X.shape[1], "features"
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X)
print "After PCA, it contains", X_pca.shape[1], "features"
print "The variance is [% of original]:", \
sum(pca.explained_variance_ratio_)
plt.scatter(X_pca[:, 0], X_pca[:, 1], c=y, edgecolors='none')
plt.title('First 2 principal components of Iris dataset')
plt.show()
Out:Iris dataset contains 4 features
After PCA, it contains 2 features
The variance is [% of original]: 0.977631775025
这是对过程输出的分析:
- 解释的方差几乎是来自输入的原始方差的 98%。特征数量减少了一半,但只有 2%的信息不在输出中,希望只是噪音。
- 从视觉观察来看,组成 Iris 数据集的不同类似乎是相互分离的。这意味着,在这样一个约简集上工作的分类器在准确性方面将具有相当的性能,但是训练和运行预测将更快。
作为第二点的证明,让我们现在尝试训练和测试两个分类器,一个使用原始数据集,另一个使用约简集,并打印它们的准确性:
In:from sklearn.linear_model import SGDClassifier
from sklearn.cross_validation import train_test_split
from sklearn.metrics import accuracy_score
def test_classification_accuracy(X_in, y_in):
X_train, X_test, y_train, y_test = \
train_test_split(X_in, y_in, random_state=101, \
train_size=0.50)
clf = SGDClassifier('log', random_state=101)
clf.fit(X_train, y_train)
return accuracy_score(y_test, clf.predict(X_test))
print "SGDClassifier accuracy on Iris set:", \
test_classification_accuracy(X, y)
print "SGDClassifier accuracy on Iris set after PCA (2 components):", \
test_classification_accuracy(X_pca, y)
Out:SGDClassifier accuracy on Iris set: 0.586666666667
SGDClassifier accuracy on Iris set after PCA (2 components): 0.72
如您所见,这种技术不仅降低了学习者在链中的复杂性和空间,而且有助于实现泛化(完全像 Ridge 或 Lasso 正则化)。
现在,如果您不确定输出中应该有多少组件,通常根据经验,选择能够解释至少 90%(或 95%)输入差异的最小数量。从经验来看,这样的选择通常会确保只有噪音被切断。
到目前为止,一切似乎都很完美:我们找到了一个减少特征数量的很好的解决方案,构建了一些具有很高预测能力的特征,我们也有一个经验法则来猜测它们的正确数量。现在让我们检查一下这个解决方案的可伸缩性:我们正在研究当观察和特征的数量增加时,它是如何伸缩的。首先要注意的是,主成分分析的核心部分——奇异值分解算法不是随机的;因此,它需要整个矩阵,以便能够提取其主成分。现在,让我们看看在一些具有越来越多的特征和观察的合成数据集上,主成分分析在实践中是如何可扩展的。我们将执行完全(无损)分解(实例化对象 PCA 时的参数是None,因为要求较少数量的特征不会影响性能(这只是对 SVD 的输出矩阵进行切片的问题)。
在下面的代码中,我们首先创建包含 10,000 个点和 20,50,100,250,1,000 和 2,500 个要由 PCA 处理的特征的矩阵。然后,我们创建具有 100 个特征以及 1、5、10、25、50 和 100,000 个要用主成分分析处理的观察值的矩阵:
In:import time
def check_scalability(test_pca):
pylab.rcParams['figure.figsize'] = (10, 4)
# FEATURES
n_points = 10000
n_features = [20, 50, 100, 250, 500, 1000, 2500]
time_results = []
for n_feature in n_features:
X, _ = make_blobs(n_points, n_features=n_feature, \
random_state=101)
pca = copy.deepcopy(test_pca)
tik = time.time()
pca.fit(X)
time_results.append(time.time()-tik)
plt.subplot(1, 2, 1)
plt.plot(n_features, time_results, 'o--')
plt.title('Feature scalability')
plt.xlabel('Num. of features')
plt.ylabel('Training time [s]')
# OBSERVATIONS
n_features = 100
n_observations = [1000, 5000, 10000, 25000, 50000, 100000]
time_results = []
for n_points in n_observations:
X, _ = make_blobs(n_points, n_features=n_features, \
random_state=101)
pca = copy.deepcopy(test_pca)
tik = time.time()
pca.fit(X)
time_results.append(time.time()-tik)
plt.subplot(1, 2, 2)
plt.plot(n_observations, time_results, 'o--')
plt.title('Observations scalability')
plt.xlabel('Num. of training observations')
plt.ylabel('Training time [s]')
plt.show()
check_scalability(PCA(None))
Out:
可以清楚地看到,基于 SVD 的 PCA 是不可伸缩的:如果特征数量线性增加,训练算法所需的时间就会呈指数级增加。此外,处理具有几百个观察值的矩阵所需的时间变得太高,并且(图中未显示)内存消耗使得该问题对于家用计算机(具有 16gb 或更少的内存)不可行。似乎很明显,基于奇异值分解的主成分分析不是大数据的解决方案;幸运的是,近年来引入了许多变通方法。在接下来的几节中,你会发现它们的简短介绍。
随机主成分分析
这种技术的正确名称应该是基于随机化奇异值分解的主成分分析,但它已经以随机化主成分分析的名称而流行。随机化背后的核心思想是所有主成分的冗余;事实上,如果方法的目标是降维,那么应该期望在输出中只需要几个向量(K 个主向量)。通过关注寻找最佳 K 个主向量的问题,该算法的规模更大。注意,在这个算法中,K——要输出的主成分个数——是一个关键参数:设置太大,性能不会比 PCA 好;将它设置得太低,用结果向量解释的方差将太低。
如同在 PCA 中一样,我们希望找到包含观测值 X 的矩阵的近似值,例如;我们还希望矩阵 Q 具有 K 正交列(它们将被称为主成分)。有了奇异值分解,我们现在可以计算小矩阵
的分解。正如我们所证明的,这不会花很长时间。作为
,通过取
,我们现在有了基于低秩奇异值分解
的 X 的截断近似。
从数学上看,这似乎很完美,但仍有两点缺失:随机化有什么作用?如何得到矩阵 Q ?两个问题都在这里回答:提取高斯随机矩阵,计算 Y 为
。然后 Y 进行 QR 分解,创建
,这里是 Q ,我们要找的 K 正交列的矩阵。
这个分解下面的数学相当重,幸运的是一切都已经在 Scikit-learn 中实现了,所以你不需要去弄清楚如何处理高斯随机变量等等。让我们先看看用随机化主成分分析计算完全(无损)分解时,它的性能有多差:
In:from sklearn.decomposition import RandomizedPCA
check_scalability(RandomizedPCA(None))
表现比经典 PCA 差;在事实上,当要求一组简化的组件时,这种转换非常有效。现在来看看 K=20 时的表现:
In:check_scalability(RandomizedPCA(20))
不出所料,计算非常快;在不到一秒钟的时间内,该算法能够执行最复杂的因子分解。
检查结果和算法,我们仍然注意到一些奇怪的事情:训练数据集 X 必须都适合内存才能被分解,即使在随机化主成分分析的情况下。是否有一个在线版本的主成分分析能够增量拟合主向量,而无需将整个数据集存储在内存中?是的,有——增量主成分分析。
增量主成分分析
增量主成分分析,或称小批量主成分分析,是主成分分析的在线版本。算法的核心非常简单:将该批数据初步拆分为具有相同数量观测值的小批量。(唯一的限制是每个小批量的观察数量应该大于特征的数量。)然后,将第一个小批量居中(去除平均值),并执行其奇异值分解,存储主成分。然后,当下一个小批量进入流程时,它首先居中,然后与从上一个小批量中提取的主要成分堆叠在一起(它们作为附加观察值插入)。现在,执行另一个奇异值分解,并用新的主分量覆盖主分量。这个过程一直持续到最后一个小批量:对于每个小批量,首先是对中,然后是堆叠,最后是奇异值分解。这样做,而不是一个大奇异值分解,我们执行的小奇异值分解和小批量的数量一样多。
正如你所理解的,这种技术并没有优于随机化的主成分分析,但它的目标是在不适合内存的数据集上需要主成分分析时提供一种解决方案(或唯一的解决方案)。增量 PCA 不是为了赢得速度挑战而运行,而是为了限制内存消耗;内存使用在整个训练过程中是恒定的,可以通过设置小批量来调整。根据经验,内存占用量与小批量大小的平方大致相同。
作为一个代码示例,现在让我们检查增量 PCA 如何处理大数据集,在我们的示例中,该数据集由 1000 万个观察值和 100 个特征组成。以前的算法都不能做到这一点,除非你想让你的计算机崩溃(或者见证内存和存储磁盘之间的大量交换)。使用增量主成分分析,这样的任务就变成了小菜一碟,而且考虑到所有因素,这个过程并不那么慢(请注意,我们正在进行完全无损的分解,消耗了稳定的内存量):
In:from sklearn.decomposition import IncrementalPCA
X, _ = make_blobs(100000, n_features=100, random_state=101)
pca = IncrementalPCA(None, batch_size=1000)
tik = time.time()
for i in range(100):
pca.partial_fit(X)
print "PCA on 10M points run with constant memory usage in ", \
time.time() - tik, "seconds"
Out:PCA on 10M points run with constant memory usage in 155.642718077 seconds
稀疏主成分分析
稀疏主成分分析的运行方式不同于以前的算法;它不是使用应用在协方差矩阵上的奇异值分解来操作特征约简(居中后),而是对该矩阵进行类似特征选择的操作,找到最佳重建数据的稀疏分量集。与套索正则化一样,稀疏度可以通过对系数的惩罚(或约束)来控制。
关于主成分分析,稀疏主成分分析不能保证得到的分量是正交的,但是结果更容易解释,因为主向量实际上是输入数据集的一部分。此外,它在特征数量方面是可伸缩的:如果当特征数量变大时(假设超过 1000 个),PCA 及其可伸缩版本被卡住,稀疏 PCA 在速度方面仍然是一个最佳解决方案,这要归功于解决 Lasso 问题的内部方法,通常基于 Lars 或坐标下降。(记住套索试图最小化系数的 L1 范数。)此外,当特征的数量大于观察的数量时,例如,一些图像数据集,这很好。
现在让我们看看它如何在包含 10,000 个要素的 25,000 个观测数据集中工作。对于这个例子,我们使用的是SparsePCA算法的小批量版本,它确保了恒定的内存使用,并且能够处理大规模数据集,最终大于可用内存(注意,批量版本被命名为SparsePCA,但不支持在线训练):
In:from sklearn.decomposition import MiniBatchSparsePCA
X, _ = make_blobs(25000, n_features=10000, random_state=101)
tik = time.time()
pca = MiniBatchSparsePCA(20, method='cd', random_state=101, \
n_iter=1000)
pca.fit(X)
print "SparsePCA on matrix", X.shape, "done in ", time.time() - \
tik, "seconds"
Out:
SparsePCA on matrix (25000, 10000) done in 41.7692570686 seconds
在大约 40 秒内,SparsePCA能够使用恒定的内存量产生解决方案。
与 H2O 进行主成分分析
我们也可以使用 H2O 提供的 PCA 实现。(我们在之前的章节已经看到了 H2O,并在书中提到了它。)
有了 H2O,我们首先需要用init方法打开服务器。然后,我们将数据集转储到一个文件中(准确地说,是一个 CSV 文件),最后运行 PCA 分析。最后一步,我们关闭服务器。
我们正在一些迄今为止最大的数据集上尝试这种实现——一个包含 10 万个观测值和 100 个要素,另一个包含 10K 观测值和 2,500 个要素:
In: import h2o
from h2o.transforms.decomposition import H2OPCA
h2o.init(max_mem_size_GB=4)
def testH2O_pca(nrows, ncols, k=20):
temp_file = tempfile.NamedTemporaryFile().name
X, _ = make_blobs(nrows, n_features=ncols, random_state=101)
np.savetxt(temp_file, np.c_[X], delimiter=",")
del X
pca = H2OPCA(k=k, transform="NONE", pca_method="Power")
tik = time.time()
pca.train(x=range(100), \
training_frame=h2o.import_file(temp_file))
print "H2OPCA on matrix ", (nrows, ncols), \
" done in ", time.time() - tik, "seconds"
os.remove(temp_file)
testH2O_pca(100000, 100)
testH2O_pca(10000, 2500)
h2o.shutdown(prompt=False)
Out:[...]
H2OPCA on matrix (100000, 100) done in 12.9560530186 seconds
[...]
H2OPCA on matrix (10000, 2500) done in 10.1429388523 seconds
正如你所看到的,在这两种情况下,H2O 确实表现得非常快,与 Scikit-learn 相当(如果不是超越的话)。
聚类–K 均值
K-means 是一种无监督算法,它以相等的方差创建 K 个不相交的点簇,最小化失真(也称为惯性)。
仅给定一个参数 K,代表要创建的聚类数,K-means 算法创建 K 组点 S 1 、S 2 、…、S K ,每个点由其质心表示:C 1 、C 2 、…、C K 。通用形心,C i ,仅仅是与簇 Si 相关联的点的样本的平均值,以便最小化簇内距离。系统的输出如下:
-
聚类 S 1 、S 2 、…、S K 的组成,即组成与聚类号 1、2、…、K 相关联的训练集的点的集合
-
每个簇的质心,C 1 ,C 2 ,…,C K 。质心可用于未来的关联。
-
The distortion introduced by the clustering, computed as follows:
这个等式表示在 K-means 算法中本质上完成的优化:质心被选择为最小化簇内失真,即每个输入点和该点所关联的簇的质心之间的距离的欧几里德范数之和。换句话说,该算法试图拟合最佳矢量量化。
K-means 算法的训练阶段也被称为劳埃德算法,以最早提出该算法的斯图尔特·劳埃德的名字命名。这是一种迭代算法,由两个阶段组成,一遍又一遍地迭代,直到收敛(失真达到最小)。它是广义的期望最大化 ( EM )算法的变体,作为第一步,为分数的期望 ( E )创建函数,而最大化 ( M )步骤计算使分数最大化的参数。(注意,在这个公式中,我们试图实现相反的结果,即失真最小化。)这是它的公式:
-
The expectation step: In this step, the points in the training set are assigned to the closest centroid:
这一步也被称为赋值或矢量量化。
-
The maximization step: The centroid of each cluster is moved to the middle of the cluster by averaging the points composing it:
此步骤也称为更新步骤。
执行这两个步骤直到收敛(点在它们的簇中是稳定的),或者直到算法达到预设的迭代次数。请注意,对于每个合成,失真不会在整个训练阶段增加(不同于基于随机梯度下降的方法);因此,在这个算法中,迭代次数越多,结果越好。
现在让我们看看它在虚拟二维数据集上的样子。我们首先创建一组 1000 个点,集中在相对于原点对称的四个位置。每个集群,每个构造,都有相同的差异:
In:import matplotlib
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
In:from sklearn.datasets.samples_generator import make_blobs
centers = [[1, 1], [1, -1], [-1, -1], [-1, 1]]
X, y = make_blobs(n_samples=1000, centers=centers,
cluster_std=0.5, random_state=101)
现在让我们绘制数据集。为了让事情变得更简单,我们将使用不同的颜色对集群进行着色:
In:plt.scatter(X[:,0], X[:,1], c=y, edgecolors='none', alpha=0.9)
plt.show()
现在让我们运行 K-means 并检查每次迭代中发生了什么。为此,我们将迭代停止到 1、2、3 和 4 次迭代,并绘制点及其相关聚类(颜色编码)以及质心、失真(在标题中)和决策边界(也称为沃罗诺伊单元)。质心的初始选择是随机的,即在训练的期望阶段,在第一次迭代中选择四个训练点作为质心:
In:pylab.rcParams['figure.figsize'] = (10.0, 8.0)
from sklearn.cluster import KMeans
for n_iter in range(1, 5):
cls = KMeans(n_clusters=4, max_iter=n_iter, n_init=1,
init='random', random_state=101)
cls.fit(X)
# Plot the voronoi cells
plt.subplot(2, 2, n_iter)
h=0.02
xx, yy = np.meshgrid(np.arange(-3, 3, h), np.arange(-3, 3, h))
Z = cls.predict(np.c_[xx.ravel(), \
yy.ravel()]).reshape(xx.shape)
plt.imshow(Z, interpolation='nearest', cmap=plt.cm.Accent, \
extent=(xx.min(), xx.max(), yy.min(), yy.max()), \
aspect='auto', origin='lower')
plt.scatter(X[:,0], X[:,1], c=cls.labels_, \
edgecolors='none', alpha=0.7)
plt.scatter(cls.cluster_centers_[:,0], \
cls.cluster_centers_[:,1], \
marker='x', color='r', s=100, linewidths=4)
plt.title("iter=%s, distortion=%s" %(n_iter, \
int(cls.inertia_)))
plt.show()
可以看到,随着迭代次数的增加,失真越来越小。对于这个虚拟数据集,似乎通过几次迭代(五次迭代),我们已经达到了收敛。
初始化方法
求 K 均值中失真的全局最小值是一个 NP 难问题;此外,与随机梯度下降的完全一样,该方法容易收敛到局部极小值,尤其是在维数较高的情况下。为了避免此类行为并限制最大迭代次数,您可以使用以下对策:
- 使用不同的初始条件多次运行该算法。在 Scikit-learn 中,
KMeans类有n_init参数,该参数控制用不同的质心种子运行 K-means 算法的次数。最后,选择确保较低失真的模型。如果有多个可用的内核,可以通过将n_jobs参数设置为需要剥离的作业数量来并行运行该过程。请注意,内存消耗与并行作业的数量呈线性关系。 - 比起随机选择训练点,更喜欢 k-means++初始化(默认为
KMeans类)。K-means++初始化选择彼此之间相距的点;这应该确保质心能够在空间的均匀子空间中形成簇。也证明了这个事实保证了更有可能找到最优解。
*## K-均值假设
K-means 依赖于假设每个聚类都有一个(超)球形,也就是说它没有拉长的形状(像箭头一样),所有的聚类内部都有相同的方差,并且它们的大小相当(或者非常远)。
所有这些假设都可以通过强大的特征预处理步骤来保证;主成分分析、核主成分分析、特征归一化和采样可以是一个很好的开始。
现在让我们看看当不满足 K 均值背后的假设时会发生什么:
In:pylab.rcParams['figure.figsize'] = (5.0, 10.0)
from sklearn.datasets import make_moons
# Oblong/elongated sets
X, _ = make_moons(n_samples=1000, noise=0.1, random_state=101)
cls = KMeans(n_clusters=2, random_state=101)
y_pred = cls.fit_predict(X)
plt.subplot(3, 1, 1)
plt.scatter(X[:, 0], X[:, 1], c=y_pred, edgecolors='none')
plt.scatter(cls.cluster_centers_[:,0], cls.cluster_centers_[:,1],
marker='x', color='r', s=100, linewidths=4)
plt.title("Elongated clusters")
# Different variance between clusters
centers = [[-1, -1], [0, 0], [1, 1]]
X, _ = make_blobs(n_samples=1000, cluster_std=[0.1, 0.4, 0.1],
centers=centers, random_state=101)
cls = KMeans(n_clusters=3, random_state=101)
y_pred = cls.fit_predict(X)
plt.subplot(3, 1, 2)
plt.scatter(X[:, 0], X[:, 1], c=y_pred, edgecolors='none')
plt.scatter(cls.cluster_centers_[:,0], cls.cluster_centers_[:,1],
marker='x', color='r', s=100, linewidths=4)
plt.title("Unequal Variance between clusters")
# Unevenly sized blobs
centers = [[-1, -1], [1, 1]]
centers.extend([[0,0]]*20)
X, _ = make_blobs(n_samples=1000, centers=centers,
cluster_std=0.28, random_state=101)
cls = KMeans(n_clusters=3, random_state=101)
y_pred = cls.fit_predict(X)
plt.subplot(3, 1, 3)
plt.scatter(X[:, 0], X[:, 1], c=y_pred, edgecolors='none')
plt.scatter(cls.cluster_centers_[:,0], cls.cluster_centers_[:,1],
marker='x', color='r', s=100, linewidths=4)
plt.title("Unevenly Sized Blobs")
plt.show()
在前面所有的例子中,聚类操作并不完美,输出了错误且不稳定的结果。
到目前为止,我们已经假设确切知道哪个是确切的 K,即我们期望在聚类操作中使用的聚类数量。实际上,在现实问题中,这并不总是正确的。我们经常使用无监督学习方法来发现数据的底层结构,包括组成数据集的聚类数量。让我们看看当我们试图在一个简单的虚拟数据集上用一个错误的 K 运行 K-means 时会发生什么;我们将尝试较低的 K 和较高的 K:
In:pylab.rcParams['figure.figsize'] = (10.0, 4.0)
X, _ = make_blobs(n_samples=1000, centers=3, random_state=101)
for K in [2, 3, 4]:
cls = KMeans(n_clusters=K, random_state=101)
y_pred = cls.fit_predict(X)
plt.subplot(1, 3, K-1)
plt.title("K-means, K=%s" % K)
plt.scatter(X[:, 0], X[:, 1], c=y_pred, edgecolors='none')
plt.scatter(cls.cluster_centers_[:,0], cls.cluster_centers_[:,1],
marker='x', color='r', s=100, linewidths=4)
plt.show()
如您所见,如果没有猜中正确的 K,即使对于这个简单的虚拟数据集,结果也是大错特错的。在下一节中,我们将解释一些最佳选择 k 的技巧。
最佳钾的选择
如果满足 K 均值背后的假设,有几种方法可以检测最佳 K。其中一些是基于输出的交叉验证和度量;它们可以用在所有的聚类方法上,但是只有当一个基本事实可用时(它们被称为监督度量)。其他一些是基于聚类算法的内在参数,并且可以通过存在或不存在基础事实(也称为无监督度量)来独立使用。不幸的是,它们都不能确保找到正确结果的 100%准确性。
监督度量需要一个基本事实(包含集合中的真实关联),并且它们通常与网格搜索分析相结合来理解最佳 k。这些度量中的一些是从等价的分类中导出的,但是它们允许有不同数量的无序集合作为预测标签。我们要看的第一个叫做同质化;正如你所料,它给出了预测的簇中有多少只包含一个类的点的度量。这是一种基于熵的度量,是分类精度的聚类等价。它是 0(最差)和 1(最好)之间的度量界限;它的数学公式如下:
这里, H(C|K) 是给定所提出的聚类分配的类分布的条件熵, H(C) 是类的熵。当聚类没有提供新信息时,H(C|K) 最大,等于H(C);当每个集群只包含一个类的成员时,它为零。
与之相连的是分类的精确度和召回率,还有完整性分数:它给出了一个度量,关于一个类的所有成员被分配到同一个聚类的程度。甚至这一个也在 0(最差)和 1(最好)之间,它的数学公式深深基于熵:
这里, H(K|C) 是给定类的建议聚类分布的条件熵, H(K) 是聚类的熵。
最后,相当于分类任务的 f1 分数,V-测度是同质性和完备性的调和平均值:
让我们回到第一个数据集(四个对称的有噪声的聚类),并尝试看看这些分数是如何操作的,以及它们是否能够突出最佳 K 来使用:
In:pylab.rcParams['figure.figsize'] = (6.0, 4.0)
from sklearn.metrics import homogeneity_completeness_v_measure
centers = [[1, 1], [1, -1], [-1, -1], [-1, 1]]
X, y = make_blobs(n_samples=1000, centers=centers,
cluster_std=0.5, random_state=101)
Ks = range(2, 10)
HCVs = []
for K in Ks:
y_pred = KMeans(n_clusters=K, random_state=101).fit_predict(X)
HCVs.append(homogeneity_completeness_v_measure(y, y_pred))
plt.plot(Ks, [el[0] for el in HCVs], 'r', label='Homogeneity')
plt.plot(Ks, [el[1] for el in HCVs], 'g', label='Completeness')
plt.plot(Ks, [el[2] for el in HCVs], 'b', label='V measure')
plt.ylim([0, 1])
plt.legend(loc=4)
plt.show()
在剧情上,最初( K < 4 )完整性高,但同质性低;对于 K > 4 来说,则相反:同质性高,但完备性低。在这两种情况下,V 值都很低。相反,对于 K=4 ,所有度量都达到了最大值,这表明这是集群数量 K 的最佳值。
除了这些被监督的度量之外,还有其他被命名为无监督的度量,它们不需要一个基本的事实,只是基于学习者本身。
在这个部分,我们首先要看到的是肘击法,应用于扭曲。这很简单,不需要任何数学:你只需要画出许多 K-means 模型不同 K 的失真,然后选择一个增加 K 不会在解中引入低得多的失真的模型。在 Python 中,这很容易实现:
In:Ks = range(2, 10)
Ds = []
for K in Ks:
cls = KMeans(n_clusters=K, random_state=101)
cls.fit(X)
Ds.append(cls.inertia_)
plt.plot(Ks, Ds, 'o-')
plt.xlabel("Value of K")
plt.ylabel("Distortion")
plt.show()
如你所料,失真下降到 K=4 ,然后慢慢下降。这里,最好的 K 是 4。
我们将看到的另一个无监督的度量是轮廓。它更复杂,但也比之前的试探法更强大。在很高的层次上,它衡量一个观测值与指定的聚类有多接近(相似),它与附近聚类的数据匹配有多松散(不相似)。剪影得分为 1 表示所有数据都在最佳聚类中,而-1 表示完全错误的聚类结果。由于 Scikit-learn 实现,使用 Python 代码获得这样的度量非常容易:
In:from sklearn.metrics import silhouette_score
Ks = range(2, 10)
Ds = []
for K in Ks:
cls = KMeans(n_clusters=K, random_state=101)
Ds.append(silhouette_score(X, cls.fit_predict(X)))
plt.plot(Ks, Ds, 'o-')
plt.xlabel("Value of K")
plt.ylabel("Silhouette score")
plt.show()
即使在这种情况下,我们也得出了相同的结论:K 的最佳值是 4,因为轮廓分数随着 K 的越来越高而越来越低。
换算 K 均值-小批量
现在让我们测试一下 K 均值的可伸缩性。从 UCI 的网站上,我们为这个任务选择了一个合适的数据集:美国 1990 年人口普查数据。这个数据集包含近 250 万个观察值和 68 个分类(但已经是数字编码的)属性。没有丢失数据,文件是 CSV 格式。每个观察都包含个人的 ID(在聚类之前要删除)和其他关于性别、收入、婚姻状况、工作等的信息。
注
更多关于数据集的信息可以在http://archive . ics . UCI . edu/ml/datasets/US+Census+Data+% 281990% 29或 Meek,Thiesson 和 Heckerman (2001)在《机器学习研究杂志》上发表的题为应用于聚类的学习曲线方法的论文中找到。
首先,您必须下载包含数据集的文件,并将其存储在临时目录中。请注意,它的大小为 345 兆字节,因此在慢速连接上下载可能需要很长时间:
In:import urllib
import os.path
url = "http://archive.ics.uci.edu/ml/machine-learning-databases/census1990-mld/USCensus1990.data.txt"
census_csv_file = "/tmp/USCensus1990.data.txt"
import os.path
if not os.path.exists(census_csv_file):
testfile = urllib.URLopener()
testfile.retrieve(url, census_csv_file)
现在,让我们运行一些测试,记录训练 K 均值学习者所需的时间,K 等于 4、8 和 12,数据集包含 20K、200K 和 0.5M 个观察值。由于我们不想让机器的内存饱和,因此我们将只读取前 500K 行,并删除包含用户标识符的列。最后,让我们为完整的绩效评估绘制培训时间图:
In:piece_of_dataset = pd.read_csv(census_csv_file, iterator=True).get_chunk(500000).drop('caseid', axis=1).as_matrix()
time_results = {4: [], 8:[], 12:[]}
dataset_sizes = [20000, 200000, 500000]
for dataset_size in dataset_sizes:
print "Dataset size:", dataset_size
X = piece_of_dataset[:dataset_size,:]
for K in [4, 8, 12]:
print "K:", K
cls = KMeans(K, random_state=101)
timeit = %timeit -o -n1 -r1 cls.fit(X)
time_results[K].append(timeit.best)
plt.plot(dataset_sizes, time_results[4], 'r', label='K=4')
plt.plot(dataset_sizes, time_results[8], 'g', label='K=8')
plt.plot(dataset_sizes, time_results[12], 'b', label='K=12')
plt.xlabel("Training set size")
plt.ylabel("Training time")
plt.legend(loc=0)
plt.show()
Out:Dataset size: 20000
K: 4
1 loops, best of 1: 478 ms per loop
K: 8
1 loops, best of 1: 1.22 s per loop
K: 12
1 loops, best of 1: 1.76 s per loop
Dataset size: 200000
K: 4
1 loops, best of 1: 6.35 s per loop
K: 8
1 loops, best of 1: 10.5 s per loop
K: 12
1 loops, best of 1: 17.7 s per loop
Dataset size: 500000
K: 4
1 loops, best of 1: 13.4 s per loop
K: 8
1 loops, best of 1: 48.6 s per loop
K: 12
1 loops, best of 1: 1min 5s per loop
很明显,给定图和实际时间,训练时间随着 K 和训练集大小线性增加,但是对于大的 K 和训练大小,这种关系变成非线性。对许多 Ks 的整个训练集进行详尽的搜索似乎是不可扩展的。
幸运的是,有一个基于小批量的在线版 K-means,已经在 Scikit-learn 中实现并命名为MiniBatchKMeans。让我们在前一个单元格最慢的情况下尝试一下,也就是 K=12 。使用经典的 K-means,对 500,000 个样本(约占整个数据集的 20%)的训练花费了一分多钟;让我们看看在线小批量版本的性能,将批量大小设置为 1000,并从数据集导入 50000 个观察值的组块。作为输出,我们绘制了训练时间与训练阶段已经通过的组块数量的关系图:
In:from sklearn.cluster import MiniBatchKMeans
import time
cls = MiniBatchKMeans(12, batch_size=1000, random_state=101)
ts = []
tik = time.time()
for chunk in pd.read_csv(census_csv_file, chunksize=50000):
cls.partial_fit(chunk.drop('caseid', axis=1))
ts.append(time.time()-tik)
plt.plot(range(len(ts)), ts)
plt.xlabel('Training batches')
plt.ylabel('time [s]')
plt.show()
每个数据块的训练时间是线性的,在将近 20 秒的时间内对全部 250 万个观察数据集执行聚类。有了这个实现,我们可以运行一个完整的搜索来选择最佳的 K 使用肘方法对失真。让我们做一个网格搜索,K 从 4 到 12,并绘制失真:
In:Ks = list(range(4, 13))
ds = []
for K in Ks:
cls = MiniBatchKMeans(K, batch_size=1000, random_state=101)
for chunk in pd.read_csv(census_csv_file, chunksize=50000):
cls.partial_fit(chunk.drop('caseid', axis=1))
ds.append(cls.inertia_)
plt.plot(Ks, ds)
plt.xlabel('Value of K')
plt.ylabel('Distortion')
plt.show()
Out:
从图中来看,肘似乎与 K=8 对应。除了价值之外,我们想指出的是,由于批量实现,在不到几分钟的时间内,我们已经能够在一个大数据集上执行这种大规模操作;因此,如果数据集越来越大,请记住永远不要使用普通的 K 均值。
K-表示与 H2O
在这里,我们将 H2O 的 K-means 实现与 Scikit-learn 进行比较。更具体地说,我们将使用在 H2O 可用的 K-means 的对象,运行小批量实验。设置类似于主成分分析与 H2O 部分所示,实验与前一部分相同:
In:import h2o
from h2o.estimators.kmeans import H2OKMeansEstimator
h2o.init(max_mem_size_GB=4)
def testH2O_kmeans(X, k):
temp_file = tempfile.NamedTemporaryFile().name
np.savetxt(temp_file, np.c_[X], delimiter=",")
cls = H2OKMeansEstimator(k=k, standardize=True)
blobdata = h2o.import_file(temp_file)
tik = time.time()
cls.train(x=range(blobdata.ncol), training_frame=blobdata)
fit_time = time.time() - tik
os.remove(temp_file)
return fit_time
piece_of_dataset = pd.read_csv(census_csv_file, iterator=True).get_chunk(500000).drop('caseid', axis=1).as_matrix()
time_results = {4: [], 8:[], 12:[]}
dataset_sizes = [20000, 200000, 500000]
for dataset_size in dataset_sizes:
print "Dataset size:", dataset_size
X = piece_of_dataset[:dataset_size,:]
for K in [4, 8, 12]:
print "K:", K
fit_time = testH2O_kmeans(X, K)
time_results[K].append(fit_time)
plt.plot(dataset_sizes, time_results[4], 'r', label='K=4')
plt.plot(dataset_sizes, time_results[8], 'g', label='K=8')
plt.plot(dataset_sizes, time_results[12], 'b', label='K=12')
plt.xlabel("Training set size")
plt.ylabel("Training time")
plt.legend(loc=0)
plt.show()
testH2O_kmeans(100000, 100)
h2o.shutdown(prompt=False)
Out:
得益于 H2O 架构,其 K-means 的实现非常快,并且可扩展,能够在不到 30 秒的时间内对所有选定的 K 执行 500K 点数据集的聚类。
LDA
LDA 代表潜在狄利克雷分配,是分析文本文档集合的常用技术之一。
注
线性判别分析是另一种技术使用的首字母缩略词,它是一种有监督的分类方法。注意 LDA 是如何使用的,因为这两种算法之间没有联系。
对线性判别分析的完整数学解释需要概率建模的知识,这超出了本实用书的范围。相反,在这里,我们将为您提供模型背后最重要的直觉,以及如何在大规模数据集上实际应用该模型。
首先,LDA 用于数据科学的一个分支,称为文本挖掘,其重点是帮助学习者理解自然语言,例如,基于文本示例。具体来说,LDA 属于主题建模算法的范畴,因为它试图对文档中包含的主题进行建模。理想情况下,例如,LDA 能够理解一份文件是关于金融、政治还是宗教。然而,与分类器不同,它还能够量化文档中主题的存在。例如,让我们想想罗琳的《哈利·波特》小说。一个分类器将能够评估它的类别(奇幻小说);相反,LDA 能够理解其中有多少喜剧、戏剧、神秘、浪漫和冒险。而且,LDA 不需要任何标签;这是一种无监督的方法,并在内部构建输出类别或主题及其组成(即由组成主题的词集给出)。
在处理过程中,线性判别分析建立了每个文档的主题模型和每个主题的单词模型,建模为狄利克雷分布。尽管复杂性很高,但由于类似蒙特卡罗的迭代核心函数,输出稳定结果所需的处理时间并没有那么长。
LDA 模型很容易理解:每个文档被建模为主题的分布,每个主题被建模为单词的分布。分布假设具有狄利克雷先验(具有不同的参数,因为每个主题的字数通常不同于每个文档的主题数)。由于吉布斯采样,分布不应该被直接采样,而是迭代地获得它的精确近似。使用变分贝叶斯技术可以获得类似的结果,其中近似是用期望最大化方法生成的。
最终的线性判别分析模型是生成的(就像隐马尔可夫模型、朴素贝叶斯和受限玻尔兹曼机器一样),因此每个变量都可以被模拟和观察。
现在让我们看看它是如何在一个真实的数据集上工作的——20 新闻组数据集。它由 20 个新闻组中交换的电子邮件组成。让我们首先加载它,从回复的电子邮件中删除电子邮件的页眉、页脚和引号:
In:from sklearn.datasets import fetch_20newsgroups
documents = fetch_20newsgroups(remove=('headers', 'footers', \
'quotes'), random_state=101).data
检查数据集的大小(即有多少个文档),并打印其中一个文档,以查看一个文档实际由什么组成:
In:len(documents)
Out:11314
In:document_num = 9960
print documents[document_num]
Out:Help!!!
I have an ADB graphicsd tablet which I want to connect to my
Quadra 950\. Unfortunately, the 950 has only one ADB port and
it seems I would have to give up my mouse.
Please, can someone help me? I want to use the tablet as well as
the mouse (and the keyboard of course!!!).
Thanks in advance.
举个例子,一个人正在平板电脑上为他的视频插座寻求帮助。
现在,我们导入运行 LDA 所需的 Python 包。Gensim 包是最好的包之一,正如您将在本节末尾看到的,它也是非常可扩展的:
In:import gensim
from gensim.utils import simple_preprocess
from gensim.parsing.preprocessing import STOPWORDS
from nltk.stem import WordNetLemmatizer, SnowballStemmer
np.random.seed(101)
作为第一步,我们应该清理文本。一些步骤是必要的,这是典型的任何 NLP 文本处理:
- 标记化是将文本拆分成句子,将句子拆分成单词。最后,单词是低级的。此时,标点符号(和重音符号)被删除。
- 由少于三个字符组成的单词将被删除。(这一步删除了大多数首字母缩略词、表情符号和连词。)
- 英语停止词列表中出现的词被删除。这个列表中的单词非常常见,没有预测能力(如,an,so,then,have,等等)。
- 然后标记被引理化;第三人称的单词变成第一人称,过去和将来时态的动词变成现在(比如 goes、goes、goes 都变成了 go)。
- 最后,词干去除了词形变化,将单词还原为词根(例如,鞋变成鞋)。
在下面这段代码中,我们将完全这样做:尽可能地清理文本,并列出构成每个文本的单词。在单元格的末尾,我们可以看到该操作如何改变之前看到的文档:
In:lm = WordNetLemmatizer()
stemmer = SnowballStemmer("english")
def lem_stem(text):
return stemmer.stem(lm.lemmatize(text, pos='v'))
def tokenize_lemmatize(text):
return [lem_stem(token)
for token in gensim.utils.simple_preprocess(text)
if token not in gensim.parsing.preprocessing.STOPWORDS and len(token) > 3]
print tokenize_lemmatize(documents[document_num])
Out:[u'help', u'graphicsd', u'tablet', u'want', u'connect', u'quadra', u'unfortun', u'port', u'mous', u'help', u'want', u'tablet', u'mous', u'keyboard', u'cours', u'thank', u'advanc']
现在,作为下一步,让我们操作所有文档的清理步骤。在这之后,我们必须建立一个字典,包含一个单词在训练集中出现的次数。由于有了 Gensim 包,该操作非常简单:
In:processed_docs = [tokenize(doc) for doc in documents]
word_count_dict = gensim.corpora.Dictionary(processed_docs)
现在,当我们想要构建一个通用的快速解决方案时,让我们去掉所有非常罕见和非常常见的单词。例如,我们可以过滤掉所有出现少于 20 次(总共)且不超过 20%的文档中的单词:
In:word_count_dict.filter_extremes(no_below=20, no_above=0.2)
作为下一步,使用这样一组缩减的单词,我们现在为每个文档构建单词包模型;也就是说,对于每个文档,我们创建一个字典来报告单词的数量和出现次数:
In:bag_of_words_corpus = [word_count_dict.doc2bow(pdoc) \
for pdoc in processed_docs]
例如,让我们来看一下前面文档的单词包模型:
In:bow_doc1 = bag_of_words_corpus[document_num]
for i in range(len(bow_doc1)):
print "Word {} (\"{}\") appears {} time[s]" \
.format(bow_doc1[i][0], \
word_count_dict[bow_doc1[i][0]], bow_doc1[i][1])
Out:Word 178 ("want") appears 2 time[s]
Word 250 ("keyboard") appears 1 time[s]
Word 833 ("unfortun") appears 1 time[s]
Word 1037 ("port") appears 1 time[s]
Word 1142 ("help") appears 2 time[s]
Word 1543 ("quadra") appears 1 time[s]
Word 2006 ("advanc") appears 1 time[s]
Word 2124 ("cours") appears 1 time[s]
Word 2391 ("thank") appears 1 time[s]
Word 2898 ("mous") appears 2 time[s]
Word 3313 ("connect") appears 1 time[s]
现在,我们已经到达了算法的核心部分:运行 LDA。至于我们的决定,让我们要求 12 个主题(有 20 种不同的时事通讯,但有些是相似的):
In:lda_model = gensim.models.LdaMulticore(bag_of_words_corpus, num_topics=10, id2word=word_count_dict, passes=50)
注
如果你得到这样一个代码的错误,试着用gensim.models.LdaModel类而不是gensim.models.LdaMulticore来处理这个版本。
现在我们打印题目作文,即每个题目中出现的单词及其相对权重:
In:for idx, topic in lda_model.print_topics(-1):
print "Topic:{} Word composition:{}".format(idx, topic)
print
Out:
Topic:0 Word composition:0.015*imag + 0.014*version + 0.013*avail + 0.013*includ + 0.013*softwar + 0.012*file + 0.011*graphic + 0.010*program + 0.010*data + 0.009*format
Topic:1 Word composition:0.040*window + 0.030*file + 0.018*program + 0.014*problem + 0.011*widget + 0.011*applic + 0.010*server + 0.010*entri + 0.009*display + 0.009*error
Topic:2 Word composition:0.011*peopl + 0.010*mean + 0.010*question + 0.009*believ + 0.009*exist + 0.008*encrypt + 0.008*point + 0.008*reason + 0.008*post + 0.007*thing
Topic:3 Word composition:0.010*caus + 0.009*good + 0.009*test + 0.009*bike + 0.008*problem + 0.008*effect + 0.008*differ + 0.008*engin + 0.007*time + 0.006*high
Topic:4 Word composition:0.018*state + 0.017*govern + 0.015*right + 0.010*weapon + 0.010*crime + 0.009*peopl + 0.009*protect + 0.008*legal + 0.008*control + 0.008*drug
Topic:5 Word composition:0.017*christian + 0.016*armenian + 0.013*jesus + 0.012*peopl + 0.008*say + 0.008*church + 0.007*bibl + 0.007*come + 0.006*live + 0.006*book
Topic:6 Word composition:0.018*go + 0.015*time + 0.013*say + 0.012*peopl + 0.012*come + 0.012*thing + 0.011*want + 0.010*good + 0.009*look + 0.009*tell
Topic:7 Word composition:0.012*presid + 0.009*state + 0.008*peopl + 0.008*work + 0.008*govern + 0.007*year + 0.007*israel + 0.007*say + 0.006*american + 0.006*isra
Topic:8 Word composition:0.022*thank + 0.020*card + 0.015*work + 0.013*need + 0.013*price + 0.012*driver + 0.010*sell + 0.010*help + 0.010*mail + 0.010*look
Topic:9 Word composition:0.019*space + 0.011*inform + 0.011*univers + 0.010*mail + 0.009*launch + 0.008*list + 0.008*post + 0.008*anonym + 0.008*research + 0.008*send
Topic:10 Word composition:0.044*game + 0.031*team + 0.027*play + 0.022*year + 0.020*player + 0.016*season + 0.015*hockey + 0.014*leagu + 0.011*score + 0.010*goal
Topic:11 Word composition:0.075*drive + 0.030*disk + 0.028*control + 0.028*scsi + 0.020*power + 0.020*hard + 0.018*wire + 0.015*cabl + 0.013*instal + 0.012*connect
不幸的是,LDA 没有为每个主题提供名称;我们应该根据我们对算法结果的解释,自己手动完成。仔细检查了作文之后,我们可以把发现的题目命名如下:
|主题
|
名字
| | --- | --- | | Zero | 软件 | | one | 应用 | | Two | 论证 | | three | 运输 | | four | 政府 | | five | 宗教 | | six | 人员行动 | | seven | 中东 | | eight | 个人电脑设备 | | nine | 空间 | | Ten | 比赛 | | Eleven | 驱动 |
现在,让我们试着理解前面的文档中表示了哪些主题及其权重:
In:
for index, score in sorted( \
lda_model[bag_of_words_corpus[document_num]], \
key=lambda tup: -1*tup[1]):
print "Score: {}\t Topic: {}".format(score, lda_model.print_topic(index, 10))
Out:Score: 0.938887758964 Topic: 0.022*thank + 0.020*card + 0.015*work + 0.013*need + 0.013*price + 0.012*driver + 0.010*sell + 0.010*help + 0.010*mail + 0.010*look
最高分与主题 PC 设备相关。基于我们之前对文献集的了解,主题提取似乎表现得相当不错。
现在,让我们从整体上评估模型。困惑(或其对数)为我们提供了一个度量标准,以了解 LDA 在训练数据集上的表现:
In:print "Log perplexity of the model is", lda_model.log_perplexity(bag_of_words_corpus)
Out:Log perplexity of the model is -7.2985188569
在这种情况下,困惑度是 2-7.298,并且它与 LDA 模型能够生成测试集中的文档的(日志)可能性有关,给定这些文档的主题分布。困惑度越低,模型越好,因为这基本上意味着模型可以很好地重新生成文本。
现在,让我们尝试在一个看不见的文档上使用这个模型。为了简单起见,文档只包含了句子,高尔夫还是网球?哪项运动最好玩?:
In:unseen_document = "Golf or tennis? Which is the best sport to play?"
bow_vector = word_count_dict.doc2bow(\
tokenize_lemmatize(unseen_document))
for index, score in sorted(lda_model[bow_vector], \
key=lambda tup: -1*tup[1]):
print "Score: {}\t Topic: {}".format(score, \
lda_model.print_topic(index, 5))
Out:Score: 0.610691655136 Topic: 0.044*game + 0.031*team + 0.027*play + 0.022*year + 0.020*player
Score: 0.222640440339 Topic: 0.018*state + 0.017*govern + 0.015*right + 0.010*weapon + 0.010*crime
不出所料,得分较高的话题是关于“游戏”的话题,其次是得分相对较小的话题。
LDA 如何随着语料库的大小而缩放?幸运的是,非常好;该算法是迭代的,允许在线学习,类似于小批量学习。在线流程的关键是LdaModel(或LdaMulticore)提供的.update()方法。
我们将在由前 1000 个文档组成的原始语料库的子集上进行这个测试,并且我们将使用 50、100、200 和 500 个文档的批次来更新我们的 LDA 模型。对于每一个更新模型的小批量,我们将记录时间并将它们绘制在图表上:
In:small_corpus = bag_of_words_corpus[:1000]
batch_times = {}
for batch_size in [50, 100, 200, 500]:
print "batch_size =", batch_size
tik0 = time.time()
lda_model = gensim.models.LdaModel(num_topics=12, \
id2word=word_count_dict)
batch_times[batch_size] = []
for i in range(0, len(small_corpus), batch_size):
lda_model.update(small_corpus[i:i+batch_size], \
update_every=25, \
passes=1+500/batch_size)
batch_times[batch_size].append(time.time() - tik0)
Out:batch_size = 50
batch_size = 100
batch_size = 200
batch_size = 500
注意,我们已经在模型更新中设置了update_every和passes参数。这对于使模型在每次迭代时收敛并且不返回不收敛的模型是必要的。注意 500 是试探性选择的;如果您将其设置得更低,您将会收到 Gensim 关于模型不收敛的许多警告。
现在我们来绘制结果:
In:plt.plot(range(50, 1001, 50), batch_times[50], 'g', \
label='size 50')
plt.plot(range(100, 1001, 100), batch_times[100], 'b', \
label='size 100')
plt.plot(range(200, 1001, 200), batch_times[200], 'k', \
label='size 200')
plt.plot(range(500, 1001, 500), batch_times[500], 'r', \
label='size 500')
plt.xlabel("Training set size")
plt.ylabel("Training time")
plt.xlim([0, 1000])
plt.legend(loc=0)
plt.show()
Out:
批次越大,训练越快。(请记住,更新模型时,大批量需要较少的通过次数。)另一方面,批次越大,存储和处理语料库所需的内存量就越大。得益于小批量更新方法,LDA 能够扩展到处理数百万个文档的语料库。事实上,Gensim 包提供的实现能够在家用计算机上在几个小时内扩展和处理整个维基百科。如果你有足够的勇气亲自尝试,下面是完成任务的完整说明,由包的作者提供:
https://radimrehurek . com/genim/wiki . html
缩放 LDA–内存、中央处理器和机器
gensim 非常灵活,用于处理大型文本语料库;事实上,该库无需任何修改或额外下载即可扩展:
- 用 CPU 的数量,允许在单个节点上并行处理(用类,如第一个例子所示)。
- 随着观察次数的增加,允许基于小批量的在线学习。这可以通过
LdaModel和LdaMulticore中可用的update方法来实现(如前例所示)。 - 在集群上运行它,在集群中的节点之间分配工作负载,这要归功于 Python 库 Pyro4 和
models.lda_dispatcher(作为调度器)和models.lda_worker(作为工作进程)对象,这两个对象都由 Gensim 提供。
除了经典的 LDA 算法,Gensim 还提供了其分层版本,命名为分层狄利克雷处理 ( HDP )。使用这种算法,主题遵循多级结构,使用户能够更好地理解复杂的语料库(也就是说,一些文档是通用的,而一些特定于某个主题)。该模块相当新,截至 2015 年底,其可扩展性不如经典的 LDA。
总结
在本章中,我们介绍了三种流行的无监督学习者,它们能够扩展以应对大数据。第一种是主成分分析,它能够通过创建包含大部分方差的特征(即主特征)来减少特征的数量。K-means 是一种聚类算法,能够将相似的点组合在一起,并将它们与质心相关联。LDA 是对文本数据进行主题建模的有力方法,即对每个文档的主题和主题中出现的单词进行联合建模。
在下一章中,我们将介绍一些先进的和非常新的机器学习方法,它们仍然不是主流的一部分,对于小数据集来说自然很棒,但也适合处理大规模的机器学习。*
八、分布式环境——Hadoop 和 Spark
在本章中,我们将介绍一种处理数据的新方法,水平缩放。到目前为止,我们的注意力主要集中在独立机器上处理大数据;在这里,我们将介绍一些在机器集群上运行的方法。
具体来说,我们将首先说明我们需要集群来处理大数据的动机和环境。然后,我们将通过几个例子(HDFS、MapReduce 和 YARN)介绍 Hadoop 框架及其所有组件,最后,我们将介绍 Spark 框架及其 Python 接口——pySpark。
从单机到一堆节点
世界上存储的数据量呈指数级增长。如今,对于一个数据科学家来说,每天必须处理几万亿字节的数据并不是一个不寻常的要求。为了使事情更加复杂,通常数据来自许多不同的异构系统,业务的期望是在短时间内产生一个模型。
因此,处理大数据不仅仅是规模的问题,它实际上是一个三维现象。事实上,根据 3V 模型,基于大数据运行的系统可以使用三个(正交)标准进行分类:
- 第一个标准是系统归档处理数据的速度。虽然几年前,速度是指一个系统处理一批的速度;如今,速度表示系统是否能够提供流数据的实时输出。
- 第二个标准是容量,即有多少信息可供处理。它可以用行数、特征数或者仅仅是字节数来表示。对于数据流,容量表示到达系统的数据吞吐量。
- 最后一个标准是多样性,即数据源的类型。几年前,多样性受到结构化数据集的限制;如今,数据可以是结构化的(表格、图像等)、半结构化的(JSON、XML 等)和非结构化的(网页、社交数据等)。通常,大数据系统试图处理尽可能多的相关来源,混合各种来源。
除了这些标准,最近几年还出现了许多其他的 Vs,试图解释大数据的其他特征。其中一些如下:
- 准确性(提供数据中包含的异常、偏差和噪声的指示;最终,它的准确性)
- 易失性(表示数据可用于提取有意义信息的时间长度)
- 有效性(数据的正确性)
- 值(表示数据的投资回报)
最近几年,所有的 Vs 都大幅增长;现在,许多公司发现他们保留的数据具有巨大的价值,可以货币化,他们希望从中提取信息。技术挑战已经转移到拥有足够的存储和处理能力,以便能够快速、大规模地提取有意义的见解,并使用不同的输入数据流。
当前的计算机,即使是最新最贵的计算机,其磁盘、内存和中央处理器的数量也是有限的。每天处理万亿字节(或千兆字节)的信息看起来非常困难,这就产生了一个快速模型。此外,需要复制包含数据和处理软件的独立服务器;否则,它可能成为系统的单点故障。
因此,大数据的世界已经转向集群:它们由可变数量的不太昂贵的节点组成,并位于高速互联网连接上。通常,一些集群专用于存储数据(大硬盘、小 CPU 和低内存量),而其他集群专用于处理数据(强大的 CPU、中到大内存量和小硬盘)。此外,如果群集设置正确,它可以确保可靠性(无单点故障)和高可用性。
注意,当我们在分布式环境(像集群)中存储数据时,也要考虑 CAP 定理的局限性;在系统中,我们可以确保以下三个属性中的两个:
- 一致性:所有节点都能够同时向客户端提供相同的数据
- 可用性:对于成功和失败的请求,请求数据的客户端保证总是收到响应
- 分区容差:如果网络出现故障,所有节点都无法联系,系统可以继续工作
具体来说,CAP 定理的结果如下:
- 如果你放弃一致性,你将创建一个数据跨节点分布的环境,即使网络出现一些问题,系统仍然能够对每个请求提供响应,尽管不能保证对同一个问题的响应是相同的(可能不一致)。这种配置的典型例子是 DynamoDB、CouchDB 和 Cassandra。
- 如果您放弃可用性,您将创建一个分布式系统,该系统可能无法响应查询。这个类的例子是分布式缓存数据库,比如 Redis、MongoDb 和 MemcacheDb。
- 最后,如果你在分区容忍度上做了补充,你就陷入了不允许网络分裂的关系数据库的僵化模式中。这一类别包括 MySQL、Oracle 和 SQL Server。
为什么我们需要分布式框架?
构建集群最简单的方法是将一些节点用作存储节点,将其他节点用作处理节点。这个配置看起来非常简单,因为我们不需要复杂的框架来处理这种情况。事实上,许多小型集群正是以这种方式构建的:一对服务器处理数据(加上它们的副本),另一对服务器处理数据。
虽然这看起来是一个很好的解决方案,但由于许多原因,它并不常用:
- 它只适用于令人尴尬的并行算法。如果算法需要在处理服务器之间共享一个公共内存区域,则不能使用这种方法。
- 如果一个或多个存储节点死亡,则不能保证数据一致。(考虑一种情况,其中一个节点及其副本同时死亡,或者一个节点在尚未复制的写操作之后死亡。
- 如果一个处理节点死亡,我们就无法跟踪它正在执行的进程,从而很难在另一个节点上恢复处理。
- 如果网络出现故障,恢复正常后很难预测情况。
现在让我们计算节点故障的概率。是不是稀有到我们可以丢弃?我们是否应该考虑更具体的问题?解决方案很简单:让我们考虑一个 100 节点的集群,其中每个节点在第一年有 1%的故障概率(硬件和软件崩溃的累积)。这 100 个人第一年都活下来的概率有多大?假设每个服务器都是独立的(也就是说,每个节点都可以独立于所有其他节点崩溃,这只是一个乘法:
一开始的结果非常令人惊讶,但这解释了为什么大数据社区在过去十年中非常重视这个问题,并为集群管理开发了许多解决方案。从公式的结果来看,似乎崩溃事件(甚至不止一个)很有可能发生,这一事实要求必须提前想到这种情况,并妥善处理,以确保对数据的操作的连续性。此外,使用廉价的硬件或更大的集群,看起来几乎可以肯定至少有一个节点会出现故障。
型式
这里的学习点是,一旦你走向大数据企业,你必须对节点故障采取足够的对策;这是常态而不是例外,应该妥善处理,以确保运营的连续性。
到目前为止,绝大多数集群框架都使用名为分治的方法:
- 有专门用于数据节点的模块*,也有专门用于数据处理节点的模块*(也称为工人)。** *** 数据跨数据节点复制,一个节点是主节点,确保写入和读取操作都成功。* 处理步骤在工作节点之间分割。它们不共享任何状态(除非存储在数据节点中),它们的主节点确保所有任务都以正确的顺序积极执行。**
**### 注
稍后,我们将在本章中介绍 Apache Hadoop 框架;虽然现在已经是一个成熟的集群管理系统,但它仍然依赖于坚实的基础。在此之前,让我们在机器上设置正确的工作环境。
设置虚拟机
建立集群是一项漫长而艰难的工作;高级大数据工程师赚到的(高)工资不仅仅是下载和执行一个二进制应用,而是熟练而谨慎地让集群管理器适应想要的工作环境。这是一个艰难而复杂的操作;这可能需要很长时间,如果结果低于预期,整个企业(包括数据科学家和软件开发人员)将无法提高生产力。在开始构建集群之前,数据工程师必须了解节点、数据、将要执行的操作和网络的每个小细节。输出通常是平衡的、自适应的、快速的、可靠的集群,可以被公司所有的技术人员使用多年。
注
拥有少量非常强大的节点的集群比拥有许多不太强大的服务器的集群更好吗?答案应该逐案评估,它高度依赖于数据、处理算法、访问数据的人数、我们想要结果的速度、总体价格、可扩展性的健壮性、网络速度和许多其他因素。简单地说,做出最好的决定一点也不容易!
由于设置环境非常困难,我们作者更愿意为读者提供一个虚拟机映像,其中包含您在集群上尝试某些操作所需的一切。在接下来的几节中,您将学习如何在您的机器上设置一个客户操作系统,该系统包含一个集群的一个节点以及您在真实集群上找到的所有软件。
为什么只有一个节点?由于我们使用的框架不是轻量级的,我们决定采用集群的原子块,确保您在节点中找到的环境与您在现实世界中找到的环境完全相同。为了在您的计算机上运行虚拟机,您需要两个软件:Virtualbox 和游民。两者都是免费开源的。
虚拟盒
VirtualBox 是一个开源软件,用于虚拟化 Windows、macOS 和 Linux 主机上的一对多客户操作系统。从用户的角度来看,一台虚拟机看起来就像另一台运行在窗口中的计算机,拥有所有功能。
VirtualBox 因其高性能、简单、干净的图形用户界面 ( GUI )而变得非常受欢迎。使用 VirtualBox 启动、停止、导入和终止虚拟机只需点击一下鼠标。
从技术上讲,VirtualBox 是一个虚拟机管理程序,它支持创建和管理多个虚拟机 ( VM )包括许多版本的 Windows、Linux 和类似 BSD 的发行版。VirtualBox 运行的机器命名为主机,而虚拟机命名为来宾。注意主人和客人之间没有限制;例如,一个 Windows 主机可以运行 Windows(相同的版本、以前的版本或最近的版本)以及任何与 VirtualBox 兼容的 Linux 和 BSD 发行版。
Virtualbox 通常用于运行特定于操作系统的软件;有些软件只在 Windows 上运行或者只是特定版本的 Windows,有些只在 Linux 上可用,等等。另一个应用是在克隆的生产环境中模拟新功能;在实时(生产)环境中尝试修改之前,软件开发人员通常会在一个克隆上测试它,就像在 VirtualBox 上运行的那样。由于来宾与主机隔离,如果来宾出现问题(甚至格式化硬盘),这不会影响主机。要拿回它,在做任何危险的事情之前,只要克隆你的机器;你总是能及时找回它。
对于想从头开始的人,VirtualBox 支持虚拟硬盘(包括硬盘、光盘、DVD、软盘);这使得新操作系统的安装非常简单。例如,如果你想安装一个普通版本的 Linux Ubuntu 14.04,你首先要下载.iso文件。您可以简单地将其作为虚拟驱动器添加到 VirtualBox,而不是将其刻录在 CD/DVD 上。然后,由于简单的分步界面,您可以选择硬盘大小和来宾机器的功能(内存、CPU 数量、视频内存和网络连接)。在使用真实 bios 操作时,可以选择引导顺序:选择 CD/DVD 作为更高的优先级,一打开 guest 就可以开始安装 Ubuntu 的过程。
现在,让我们下载 VirtualBox 请记住为您的操作系统选择正确的版本。
注
要将其安装到您的计算机上,请按照 www.virtualbox.org/wiki/Downlo… T2 的说明进行操作。
在写这篇文章的时候,最新版本是 5.1。安装后,图形界面看起来像下面截图中的界面
我们强烈建议您看一看如何在您的机器上设置来宾机器。每个来宾机器将出现在窗口的左侧。(图中可以看到,在我们的电脑上,我们有三位被拦下的客人。)通过点击每个图标上的,右侧将出现虚拟化硬件的详细描述。在示例图像中,如果名为sparkbox_test的虚拟机(左边突出显示的那个)被打开,它将在一台虚拟计算机上运行,该计算机的硬件由一个 4GB 的内存、两个处理器、40GB 硬盘和一个带有 NAT 的连接到网络的 12MB 内存的视频卡组成。
流浪
游民是一个软件,配置虚拟环境在一个高水平。游民的核心部分是脚本功能,通常用于以编程方式创建和自动指定虚拟环境。游民使用 VirtualBox(以及其他虚拟器)来构建和配置虚拟机。
注
要安装它,请遵循 www.vagrantup.com/downloads.h… T2 的说明。
使用虚拟机
安装了 float 和 VirtualBox 后,现在就可以运行集群环境的节点了。创建一个空目录,并将以下游民命令插入名为Vagrantfile的新文件中:
Vagrant.configure("2") do |config|
config.vm.box = "sparkpy/sparkbox_test_1"
config.vm.hostname = "sparkbox"
config.ssh.insert_key = false
# Hadoop ResourceManager
config.vm.network :forwarded_port, guest: 8088, host: 8088, auto_correct: true
# Hadoop NameNode
config.vm.network :forwarded_port, guest: 50070, host: 50070, auto_correct: true
# Hadoop DataNode
config.vm.network :forwarded_port, guest: 50075, host: 50075, auto_correct: true
# Ipython notebooks (yarn and standalone)
config.vm.network :forwarded_port, guest: 8888, host: 8888, auto_correct: true
# Spark UI (standalone)
config.vm.network :forwarded_port, guest: 4040, host: 4040, auto_correct: true
config.vm.provider "virtualbox" do |v|
v.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
v.customize ["modifyvm", :id, "--natdnsproxy1", "on"]
v.customize ["modifyvm", :id, "--nictype1", "virtio"]
v.name = "sparkbox_test"
v.memory = "4096"
v.cpus = "2"
end
end
从上到下,第一行下载正确的虚拟机(我们作者创建并上传到存储库中)。然后,我们设置一些端口转发给来宾机;这样,您将能够访问虚拟机的一些 web 服务。最后,我们设置节点的硬件。
注
该配置是为专用于 4GB 内存和两个内核的虚拟机设置的。如果您的系统不能满足这些要求,请将v.memory和v.cpus值修改为对您的机器有益的值。请注意,如果您设置的配置不合适,下面的一些代码示例可能会失败。
现在,打开一个终端,导航到包含Vagrantfile的目录。在这里,使用以下命令启动虚拟机:
$ vagrant up
第一次,这个命令需要一段时间来下载(几乎是 2GB 下载)并构建正确的虚拟机结构。下一次,这个命令花费的时间更少,因为没有更多的东西可以下载。
在本地系统上打开虚拟机后,您可以按如下方式访问它:
$ vagrant ssh
该命令模拟 SSH 访问,最终您将进入虚拟机。
注
在 Windows 机器上,由于缺少 SSH 可执行文件,此命令可能会失败并出现错误。在这种情况下,下载安装一个 Windows 的 SSH 客户端,比如 Putty(www.putty.org/)、CygwinOpenssh(www.cygwin.com/)或者 Windows 的 Openssh(sshwindows.sourceforge.net/)。Unix 系统不应该受到这个问题的影响。
要关闭 if,您首先需要退出机器。从虚拟机内部,只需使用exit命令退出 SSH 连接,然后关闭虚拟机:
$ vagrant halt
注
虚拟机消耗资源。当您使用虚拟机所在目录中的vagranthalt命令完成工作时,请记住将其关闭。
前面的命令关闭虚拟机,就像您关闭服务器一样。要删除它并删除其所有内容,请使用vagrant destroy命令。小心使用:在破坏了机器之后,您将无法恢复其中的文件。
以下是在虚拟机中使用 IPython (Jupyter)笔记本的说明:
-
从包含
Vagrantfile的文件夹中启动vagrant up和vagrant ssh。您现在应该在虚拟机内部了。 -
现在,启动脚本:
vagrant@sparkbox:~$ ./start_hadoop.sh -
At this point, launch the following shell script:
vagrant@sparkbox:~$ ./start_jupyter_yarn.sh在本地机器上打开浏览器,指向
http://localhost:8888。
这是由集群节点支持的笔记本。要关闭笔记本电脑和虚拟机,请执行以下步骤:
-
要终止 Jupyter 控制台,请按 Ctrl + C (然后键入 Y 表示是)。
-
终止 Hadoop 框架如下:
vagrant@sparkbox:~$ ./stop_hadoop.sh -
使用以下命令退出虚拟机:
vagrant@sparkbox:~$ exit -
用
vagrant halt关闭 VirtualBox 机器。
Hadoop 生态系统
Apache Hadoop 是一个非常流行的软件框架,用于集群上的分布式存储和分布式处理。它的优势在于价格(它是免费的)、灵活性(它是开源的,虽然是用 Java 编写的,但它可以被其他编程语言使用)、可扩展性(它可以处理由成千上万个节点组成的集群)和健壮性(它的灵感来自谷歌发表的一篇论文,自 2011 年以来一直存在),使它成为处理和处理大数据的事实标准。此外,Apache 基金会的许多其他项目扩展了它的功能。
建筑
从逻辑上讲,Hadoop 由两部分组成:分布式存储(HDFS)和分布式处理(纱和 MapReduce)。虽然代码非常复杂,但整体架构相当容易理解。客户端可以通过两个专用模块访问存储和处理;然后,他们负责在所有理论节点上分配工作:
所有 Hadoop 模块都作为服务(或实例)运行,也就是说,一个物理或虚拟节点可以运行其中的许多模块。典型地,对于小集群,所有节点都运行分布式计算和处理服务;对于大型集群,最好将两个专门用于节点的功能分开。
我们将详细了解这两层提供的功能。
HDFS
Hadoop 分布式文件系统 ( HDFS )是一个容错分布式文件系统,旨在运行在商用低成本硬件上,并能够处理非常大的数据集(几百千兆字节到十六千兆字节)。虽然 HDFS 需要快速的网络连接来跨节点传输数据,但是延迟不可能像传统文件系统那样低(可能在几秒钟的数量级);因此,HDFS 专为批量处理和高吞吐量而设计。每个 HDFS 节点包含文件系统数据的一部分;相同的数据也在其他实例中复制,这确保了高吞吐量访问和容错。
HDFS 的建筑是主从式的。如果主节点(名称节点)出现故障,则有一个辅助/备份节点准备接管。所有其他实例都是从属实例(数据节点);如果其中一个失败了,没有问题,因为 HDFS 在设计时就考虑到了这一点。
数据节点包含数据块:保存在 HDFS 的每个文件被分成块(或区块),通常每个 64MB,然后在一组数据节点中分发和复制。
名称节点仅存储分布式文件系统中文件的元数据;它不存储任何实际数据,而只是正确指示如何访问它管理的多个数据节点中的文件。
请求读取文件的客户端应首先联系名称节点,该节点将返回一个表,其中包含块及其位置的有序列表(如在数据节点中)。此时,客户端应该单独联系数据节点,下载所有块并重建文件(通过将块附加在一起)。
相反,要写入文件,客户端应该首先联系名称节点,该节点将首先决定如何处理该请求,更新其记录,然后向客户端回复一个有序的数据节点列表,说明在何处写入文件的每个块。客户端现在将联系数据节点并将数据块上传到数据节点,如名称节点回复中所述。
名称空间查询(例如,列出目录内容、创建文件夹等)完全由名称节点通过访问其元数据信息来处理。
此外,名称节点还负责正确处理数据节点故障(如果没有接收到心跳数据包,它将被标记为死亡),并将其数据重新复制到其他节点。
尽管这些操作很长,很难健壮地实现,但是由于许多库和 HDFS shell,它们对用户来说是完全透明的。您在 HDFS 上操作的方式与您当前在文件系统上所做的非常相似,这是 Hadoop 的一大优势:隐藏复杂性,让用户简单地使用它。
现在让我们来看看 HDFS shell 和后来的 Python 库。
注
使用前面的说明打开虚拟机,并在您的计算机上启动 IPython 笔记本。
现在,打开一个新笔记本;由于每个笔记本都连接到 Hadoop 集群框架,因此此操作将比平时花费更多的时间。当笔记本准备使用时,你会看到右上角的**内核启动,请等待……**标志消失。
第一篇是关于 HDFS 贝壳的;因此,以下所有命令都可以在虚拟机的提示符或 shell 下运行。要在 IPython 笔记本中运行它们,所有这些都需要一个问号!,这是在笔记本中执行 bash 代码的一种简单方法。
以下命令行的共同点是可执行文件;我们将始终运行hdfs命令。它是访问和管理 HDFS 系统的主要界面,也是 HDFS shell 的主要命令。
我们从一份关于 HDFS 状况的报告开始。要获取分布式文件系统 ( dfs )及其数据节点的详细信息,请使用dfsadmin子命令:
In:!hdfs dfsadmin –report
Out:Configured Capacity: 42241163264 (39.34 GB)
Present Capacity: 37569168058 (34.99 GB)
DFS Remaining: 37378433024 (34.81 GB)
DFS Used: 190735034 (181.90 MB)
DFS Used%: 0.51%
Under replicated blocks: 0
Blocks with corrupt replicas: 0
Missing blocks: 0
-------------------------------------------------
Live datanodes (1):
Name: 127.0.0.1:50010 (localhost)
Hostname: sparkbox
Decommission Status : Normal
Configured Capacity: 42241163264 (39.34 GB)
DFS Used: 190735034 (181.90 MB)
Non DFS Used: 4668290330 (4.35 GB)
DFS Remaining: 37380775936 (34.81 GB)
DFS Used%: 0.45%
DFS Remaining%: 88.49%
Configured Cache Capacity: 0 (0 B)
Cache Used: 0 (0 B)
Cache Remaining: 0 (0 B)
Cache Used%: 100.00%
Cache Remaining%: 0.00%
Xceivers: 1
Last contact: Tue Feb 09 19:41:17 UTC 2016
dfs 子命令允许使用一些众所周知的 Unix 命令来访问分布式文件系统并与之交互。例如,列出根目录的内容如下:
In:!hdfs dfs -ls /
Out:Found 2 items
drwxr-xr-x - vagrant supergroup 0 2016-01-30 16:33 /spark
drwxr-xr-x - vagrant supergroup 0 2016-01-30 18:12 /user
输出类似于 Linux 提供的ls命令,列出了权限、链接数量、拥有文件的用户和组、大小、上次修改的时间戳以及每个文件或目录的名称。
类似于df命令,我们可以调用-df参数来显示 HDFS 的可用磁盘空间量。-h选项将使输出更加易读(使用千兆字节和兆字节代替字节):
In:!hdfs dfs -df -h /
Out:Filesystem Size Used Available Use%
hdfs://localhost:9000 39.3 G 181.9 M 34.8 G 0%
类似于du,我们可以使用-du参数来显示根目录中包含的每个文件夹的大小。同样,-h将产生更具可读性的输出:
In:!hdfs dfs -du -h /
Out:178.9 M /spark
1.4 M /user
到目前为止,我们已经从 HDFS 提取了一些信息。现在让我们对分布式文件系统做一些操作,这会修改它。我们可以从创建一个名为-mkdir的文件夹开始。请注意,如果目录已经存在(与 Linux 中完全一样,使用mkdir命令),此操作可能会失败:
In:!hdfs dfs -mkdir /datasets
现在让我们将一些文件从节点的硬盘传输到分布式文件系统。在我们创建的虚拟机中,../datasets目录中已经有一个文本文件;让我们从网上下载一个文本文件。让我们将它们都移动到我们用前面的命令创建的 HDFS 目录中:
In:
!wget -q http://www.gutenberg.org/cache/epub/100/pg100.txt \
-O ../datasets/shakespeare_all.txt
!hdfs dfs -put ../datasets/shakespeare_all.txt \
/datasets/shakespeare_all.txt
!hdfs dfs -put ../datasets/hadoop_git_readme.txt \
/datasets/hadoop_git_readme.txt
导入成功吗?是的,我们没有任何错误。但是,为了消除任何疑问,让我们列出 HDFS 目录/数据集来查看这两个文件:
In:!hdfs dfs -ls /datasets
Out:Found 2 items
-rw-r--r-- 1 vagrant supergroup 1365 2016-01-31 12:41 /datasets/hadoop_git_readme.txt
-rw-r--r-- 1 vagrant supergroup 5589889 2016-01-31 12:41 /datasets/shakespeare_all.txt
要将一些文件连接到标准输出,我们可以使用-cat参数。在接下来的代码中,我们计算了文本文件中出现的新行。请注意,第一个命令通过管道传输到在本地计算机上运行的另一个命令中:
In:!hdfs dfs -cat /datasets/hadoop_git_readme.txt | wc –l
Out:30
实际上,通过-cat参数,我们可以连接本地机器和 HDFS 的多个文件。要查看它,现在让我们计算一下当存储在 HDFS 的文件与存储在本地机器上的文件连接在一起时,有多少新行出现。为了避免误解,我们可以使用完整的统一资源标识符 ( URI ),使用hdfs:方案引用 HDFS 的文件,使用file:方案引用本地文件:
In:!hdfs dfs -cat \
hdfs:///datasets/hadoop_git_readme.txt \
file:///home/vagrant/datasets/hadoop_git_readme.txt | wc –l
Out:60
为了在 HDFS 复制,我们可以使用-cp参数:
In : !hdfs dfs -cp /datasets/hadoop_git_readme.txt \
/datasets/copy_hadoop_git_readme.txt
要删除文件(或目录,用正确的选项),我们可以使用–rm参数。在这段代码中,我们删除了刚刚用前面的命令创建的文件。请注意,HDFS 有鞭打机制;因此,删除的文件实际上并没有从 HDFS 中删除,而只是移动到了一个特殊的目录中:
In:!hdfs dfs -rm /datasets/copy_hadoop_git_readme.txt
Out:16/02/09 21:41:44 INFO fs.TrashPolicyDefault: Namenode trash configuration: Deletion interval = 0 minutes, Emptier interval = 0 minutes.
Deleted /datasets/copy_hadoop_git_readme.txt
要清空经过反复研究的数据,命令如下:
In:!hdfs dfs –expunge
Out:16/02/09 21:41:44 INFO fs.TrashPolicyDefault: Namenode trash configuration: Deletion interval = 0 minutes, Emptier interval = 0 minutes.
要获得(获取)从 HDFS 到本地机器的文件,我们可以使用-get参数:
In:!hdfs dfs -get /datasets/hadoop_git_readme.txt \
/tmp/hadoop_git_readme.txt
要查看存储在 HDFS 的文件,我们可以使用-tail参数。请注意,在 HDFS 没有 head 功能,因为它可以使用cat来完成,然后将结果输入本地 head 命令。至于尾部,HDFS 外壳只显示最后一千字节的数据:
In:!hdfs dfs -tail /datasets/hadoop_git_readme.txt
Out:ntry, of
encryption software. BEFORE using any encryption software, please
check your country's laws, regulations and policies concerning the
import, possession, or use, and re-export of encryption software, to
see if this is permitted. See <http://www.wassenaar.org/> for more
information.
[...]
hdfs命令是 HDFS 的主要入口点,但是它很慢,从 Python 中调用系统命令并读取输出非常繁琐。为此,有一个 Python 库“蛇咬”,它包装了许多分布式文件系统操作。不幸的是,该库不像 HDFS 外壳那样完整,并且绑定到一个名称节点。要在本地机器上安装,只需使用pip install snakebite。
要实例化客户端对象,我们应该提供名称节点的 IP(或其别名)和端口。在我们提供的虚拟机中,它运行在端口 9000 上:
In:from snakebite.client import Client
client = Client("localhost", 9000)
要打印一些关于 HDFS 的信息,客户端对象有serverdefaults方法:
In:client.serverdefaults()
Out:{'blockSize': 134217728L,
'bytesPerChecksum': 512,
'checksumType': 2,
'encryptDataTransfer': False,
'fileBufferSize': 4096,
'replication': 1,
'trashInterval': 0L,
'writePacketSize': 65536}
要在根目录中列出文件和目录,我们可以使用ls方法。结果是一个字典列表,每个文件一个,包含信息,如权限、上次修改的时间戳等。在这个例子中,我们只对路径(即名称)感兴趣:
In:for x in client.ls(['/']):
print x['path']
Out:/datasets
/spark
/user
与前面的代码完全一样,蛇咬客户端有du(用于磁盘使用)和df(用于磁盘空闲)方法可用。请注意,许多方法(如du)返回生成器,这意味着它们需要被消费(如迭代器或列表)才能执行:
In:client.df()
Out:{'capacity': 42241163264L,
'corrupt_blocks': 0L,
'filesystem': 'hdfs://localhost:9000',
'missing_blocks': 0L,
'remaining': 37373218816L,
'under_replicated': 0L,
'used': 196237268L}
In:list(client.du(["/"]))
Out:[{'length': 5591254L, 'path': '/datasets'},
{'length': 187548272L, 'path': '/spark'},
{'length': 1449302L, 'path': '/user'}]
至于 HDFS shell 示例,我们现在将尝试计算与蛇咬出现在同一文件中的换行符。注意.cat方法返回一个生成器:
In:
for el in client.cat(['/datasets/hadoop_git_readme.txt']):
print el.next().count("\n")
Out:30
现在让我们从 HDFS 删除一个文件。同样,请注意delete方法返回一个生成器,并且执行永远不会失败,即使我们试图删除一个不存在的目录。事实上,蛇咬不会引发异常,只是在输出字典中向用户发出操作失败的信号:
In:client.delete(['/datasets/shakespeare_all.txt']).next()
Out:{'path': '/datasets/shakespeare_all.txt', 'result': True}
现在,让我们将一个文件从 HDFS 复制到本地文件系统。观察输出是发电机,需要查看输出字典,看操作是否成功:
In:
(client
.copyToLocal(['/datasets/hadoop_git_readme.txt'],
'/tmp/hadoop_git_readme_2.txt')
.next())
Out:{'error': '',
'path': '/tmp/hadoop_git_readme_2.txt',
'result': True,
'source_path': '/datasets/hadoop_git_readme.txt'}
最后,创建一个目录并删除所有匹配字符串的文件:
In:list(client.mkdir(['/datasets_2']))
Out:[{'path': '/datasets_2', 'result': True}]
In:client.delete(['/datasets*'], recurse=True).next()
Out:{'path': '/datasets', 'result': True}
在 HDFS 存档的代码在哪里?将 HDFS 文件复制到另一个文件的代码在哪里?嗯,这些功能还没有在蛇咬中实现。对于他们,我们将通过系统调用使用 HDFS 外壳。
MapReduce
MapReduce 是在最早的 Hadoop 版本中实现的编程模型。这是一个非常简单的模型,旨在并行批处理分布式集群上的大型数据集。MapReduce 的核心由两个可编程功能组成——一个执行过滤的映射器和一个执行聚合的缩减器——以及一个将对象从映射器移动到右侧缩减器的洗牌器。
注
谷歌在 2004 年发表了一篇关于 Mapreduce 的论文,几个月前它获得了一项专利。
具体来说,以下是 Hadoop 实现的 MapReduce 步骤:
-
Data chunker. Data is read from the filesystem and split into chunks. A chunk is a piece of the input dataset, typically either a fixed-size block (for example, a HDFS block read from a Data Node) or another more appropriate split.
例如,如果我们想计算一个文本文件中的字符、单词和行数,一个好的拆分可以是一行文本。
-
Mapper: From each chunk, a series of key-value pairs is generated. Each mapper instance applies the same mapping function on different chunks of data.
继续前面的例子,对于每一行,在这个步骤中生成三个键值对——一个包含该行中的字符数(键可以简单地是一个字符字符串),一个包含单词数(在这种情况下,键必须不同,让我们说单词),一个包含行数,它总是一个(在这种情况下,键可以是行)。
-
洗牌机:根据可用的缩减器的键和数量,洗牌机将具有相同键的所有键值对分配给相同的缩减器。典型地,这个操作是密钥的散列,取减数的模。这应确保每个减速器有相当数量的键。这个函数不是用户可编程的,而是由 MapReduce 框架提供的。
-
Reducer: Each reducer receives all the key-value pairs for a specific set of keys and can produce zero or more aggregate results.
在本例中,所有连接到字键的值都到达一个减速器;它的工作只是总结所有的价值。其他键也是如此,最终得到三个值:字符数、字数和行数。请注意,这些结果可能在不同的减速器上。
-
Output writer: The outputs of the reducers are written on the filesystem (or HDFS). In the default Hadoop configuration, each reducer writes a file (
part-r-00000is the output of the first reducer,part-r-00001of the second, and so on). To have a full list of results on a file, you should concatenate all of them.从视觉上看,该操作可以简单地传达和理解如下:
在映射步骤之后,每个映射器实例还可以运行一个可选步骤——合并器。如果可能的话,它基本上预测映射器上的减少步骤,并且通常用于减少要混洗的信息量,从而加快过程。在前面的示例中,如果映射器在(可选的)组合器步骤中处理输入文件的多行,它可以预聚合结果,输出较少数量的键值对。例如,如果映射器处理每个块中的 100 行文本,那么当信息可以聚合为三个时,为什么要输出 300 个键值对(100 个字符,100 个单词,100 行)?这实际上是组合器的目标。
在 Hadoop 提供的 MapReduce 实现中,洗牌操作是分布式的,优化了通信成本,并且每个节点可以运行多个映射器和缩减器,充分利用了节点上可用的硬件资源。此外,Hadoop 基础架构提供冗余和容错,因为同一任务可以分配给多个工作人员。
现在让我们看看它是如何工作的。尽管 Hadoop 框架是用 Java 编写的,但由于 Hadoop Streaming 实用程序,映射器和缩减器可以是任何可执行文件,包括 Python。Hadoop Streaming 使用管道和标准输入和输出来流式传输内容;因此,mappers 和 reducers 必须实现 stdin 的读取器和 stdout 上的键值写入器。
现在,打开虚拟机,打开一个新的 IPython 笔记本。即使在这种情况下,我们也会首先引入命令行方式来运行 Hadoop 提供的【MapReduce 作业,然后引入一个纯 Python 库。第一个例子正是我们所描述的:一个文本文件的字符、单词和行数的计数器。
首先,让我们将数据集插入 HDFS;我们将使用 Hadoop Git readme(一个包含随 Apache Hadoop 分发的 readme 文件的简短文本文件)和所有莎士比亚书籍的全文,由 Project Gutenberg 提供(虽然它只有 5MB,但包含了几乎 125K 行)。在第一个单元格中,我们将清理上一个实验的文件夹,然后,我们在数据集文件夹中下载包含莎士比亚参考书目的文件,最后,我们将两个数据集放在 HDFS 上:
In:!hdfs dfs -mkdir -p /datasets
!wget -q http://www.gutenberg.org/cache/epub/100/pg100.txt \
-O ../datasets/shakespeare_all.txt
!hdfs dfs -put -f ../datasets/shakespeare_all.txt /datasets/shakespeare_all.txt
!hdfs dfs -put -f ../datasets/hadoop_git_readme.txt /datasets/hadoop_git_readme.txt
!hdfs dfs -ls /datasets
现在,让我们创建包含映射器和缩减器的 Python 可执行文件。我们将在这里使用一个非常肮脏的黑客:我们将使用笔记本中的写操作来编写 Python 文件(并使它们可执行)。
映射器和缩减器都从 stdin 读取并写入 stdout(使用简单的打印命令)。具体来说,映射器从 stdin 中读取行,并打印字符数(除了换行符)、字数(通过在空白上拆分行)和行数的键值对,始终为 1。相反,缩减器会汇总每个键的值,并打印总计:
In:
with open('mapper_hadoop.py', 'w') as fh:
fh.write("""#!/usr/bin/env python
import sys
for line in sys.stdin:
print "chars", len(line.rstrip('\\n'))
print "words", len(line.split())
print "lines", 1
""")
with open('reducer_hadoop.py', 'w') as fh:
fh.write("""#!/usr/bin/env python
import sys
counts = {"chars": 0, "words":0, "lines":0}
for line in sys.stdin:
kv = line.rstrip().split()
counts[kv[0]] += int(kv[1])
for k,v in counts.items():
print k, v
""")
In:!chmod a+x *_hadoop.py
为了在工作中看到它,让我们在不使用 Hadoop 的情况下在本地尝试它。事实上,当映射器和还原器读写标准输入和输出时,我们可以把所有的东西放在一起。请注意,洗牌机可以由sort -k1,1命令代替,该命令使用第一个字段(即键)对输入字符串进行排序:
In:!cat ../datasets/hadoop_git_readme.txt | ./mapper_hadoop.py | sort -k1,1 | ./reducer_hadoop.py
Out:chars 1335
lines 31
words 179
现在让我们使用 Hadoop MapReduce 方法来获得相同的结果。首先,我们应该在 HDFS 创建一个能够存储结果的空目录。在这种情况下,我们创建了一个名为/tmp的目录,并以与作业输出相同的方式删除其中的任何名称(如果输出文件已经存在,Hadoop 将失败)。然后,我们使用正确的命令来运行 MapReduce 作业。该命令包括以下内容:
- 我们想要使用 Hadoop 流功能的事实(表示 Hadoop 流 jar 文件)
- 我们想要使用的映射器和缩减器(T0 和 T1 选项)
- 我们希望将这些文件作为本地文件分发给每个映射器(使用
–files选项) - 输入文件(
–input选项)和输出目录(–output选项)
In:!hdfs dfs -mkdir -p /tmp
!hdfs dfs -rm -f -r /tmp/mr.out
!hadoop jar /usr/local/hadoop/share/hadoop/tools/lib/hadoop-streaming-2.6.4.jar \
-files mapper_hadoop.py,reducer_hadoop.py \
-mapper mapper_hadoop.py -reducer reducer_hadoop.py \
-input /datasets/hadoop_git_readme.txt -output /tmp/mr.out
Out:[...]
16/02/04 17:12:22 INFO mapreduce.Job: Running job: job_1454605686295_0003
16/02/04 17:12:29 INFO mapreduce.Job: Job job_1454605686295_0003 running in uber mode : false
16/02/04 17:12:29 INFO mapreduce.Job: map 0% reduce 0%
16/02/04 17:12:35 INFO mapreduce.Job: map 50% reduce 0%
16/02/04 17:12:41 INFO mapreduce.Job: map 100% reduce 0%
16/02/04 17:12:47 INFO mapreduce.Job: map 100% reduce 100%
16/02/04 17:12:47 INFO mapreduce.Job: Job job_1454605686295_0003 completed successfully
[...]
Shuffle Errors
BAD_ID=0
CONNECTION=0
IO_ERROR=0
WRONG_LENGTH=0
WRONG_MAP=0
WRONG_REDUCE=0
[...]
16/02/04 17:12:47 INFO streaming.StreamJob: Output directory: /tmp/mr.out
输出很啰嗦;我们只是从中提取了三个重要部分。第一个指示 MapReduce 作业的进度,跟踪和估计完成操作所需的时间非常有用。第二部分突出显示了在作业期间可能发生的错误,最后一部分报告了终止的输出目录和时间戳。小文件(30 行)上的整个过程几乎花了半分钟!原因很简单:第一,Hadoop MapReduce 是为强大的大数据处理而设计的,并且包含大量开销,第二,理想的环境是强大机器的集群,而不是具有 4GB RAM 的虚拟化 VM。另一方面,这些代码可以在更大的数据集和强大机器的集群上运行,而不需要做任何改变。
我们不要马上看到结果。首先,让我们看一下 HDFS 的输出目录:
In:!hdfs dfs -ls /tmp/mr.out
Out:Found 2 items
-rw-r--r-- 1 vagrant supergroup 0 2016-02-04 17:12 /tmp/mr.out/_SUCCESS
-rw-r--r-- 1 vagrant supergroup 33 2016-02-04 17:12 /tmp/mr.out/part-00000
有两个文件:第一个是空的,名为_SUCCESS,表示 MapReduce 作业已经完成了目录中的写入阶段,第二个名为 part-00000,包含实际结果(因为我们在只有一个 Reduce 的节点上操作)。阅读此文件将为我们提供最终结果:
In:!hdfs dfs -cat /tmp/mr.out/part-00000
Out:chars 1335
lines 31
words 179
正如预期的那样,它们与前面显示的管道命令行相同。
虽然概念上很简单,但 Hadoop Streaming 并不是用 Python 代码运行 Hadoop 作业的最佳方式。为此,Pypy 上有很多可用的库;我们在这里展示的这个是最灵活和维护最开放的源代码之一—MrJob。它允许您在本地机器、Hadoop 集群或相同的云集群环境(如亚马逊弹性地图缩减)上无缝运行作业;它将所有代码合并到一个独立的文件中,即使需要多个 MapReduce 步骤(考虑迭代算法),并解释代码中的 Hadoop 错误。此外,安装非常简单;要在本地机器上安装 MrJob 库,只需使用pip install mrjob。
虽然 MrJob 是一款很棒的软件,但它在 IPython Notebook 上运行不太好,因为它需要一个主要功能。这里,我们需要在一个单独的文件中编写 MapReduce Python 代码,然后运行一个命令行。
我们从我们已经看过很多次的例子开始:计算文件中的字符、单词和行。首先,让我们使用 MrJob 功能编写 Python 文件;mappers 和 reducers】被包裹在MRJob的一个子类中。输入不是从 stdin 读取的,而是作为函数参数传递的,输出不是打印的,而是产生的(或返回的)。
多亏了 MrJob,整个 MapReduce 程序变成了几行代码:
In:
with open("MrJob_job1.py", "w") as fh:
fh.write("""
from mrjob.job import MRJob
class MRWordFrequencyCount(MRJob):
def mapper(self, _, line):
yield "chars", len(line)
yield "words", len(line.split())
yield "lines", 1
def reducer(self, key, values):
yield key, sum(values)
if __name__ == '__main__':
MRWordFrequencyCount.run()
""")
现在让我们在本地执行它(使用数据集的本地版本)。MrJob 库除了执行映射器和缩减器步骤(在本例中是本地)之外,还打印结果并清理临时目录:
In:!python MrJob_job1.py ../datasets/hadoop_git_readme.txt
Out: [...]
Streaming final output from /tmp/MrJob_job1.vagrant.20160204.171254.595542/output
"chars" 1335
"lines" 31
"words" 179
removing tmp directory /tmp/MrJob_job1.vagrant.20160204.171254.595542
要在 Hadoop 上运行相同的进程,只需运行相同的 Python 文件,这次在命令行中插入–r hadoop选项,MrJob 将使用 Hadoop MapReduce 和 HDFS 自动执行它。在这种情况下,记得指向输入文件的hdfs路径:
In:
!python MrJob_job1.py -r hadoop hdfs:///datasets/hadoop_git_readme.txt
Out:[...]
HADOOP: Running job: job_1454605686295_0004
HADOOP: Job job_1454605686295_0004 running in uber mode : false
HADOOP: map 0% reduce 0%
HADOOP: map 50% reduce 0%
HADOOP: map 100% reduce 0%
HADOOP: map 100% reduce 100%
HADOOP: Job job_1454605686295_0004 completed successfully
[...]
HADOOP: Shuffle Errors
HADOOP: BAD_ID=0
HADOOP: CONNECTION=0
HADOOP: IO_ERROR=0
HADOOP: WRONG_LENGTH=0
HADOOP: WRONG_MAP=0
HADOOP: WRONG_REDUCE=0
[...]
Streaming final output from hdfs:///user/vagrant/tmp/mrjob/MrJob_job1.vagrant.20160204.171255.073506/output
"chars" 1335
"lines" 31
"words" 179
removing tmp directory /tmp/MrJob_job1.vagrant.20160204.171255.073506
deleting hdfs:///user/vagrant/tmp/mrjob/MrJob_job1.vagrant.20160204.171255.073506 from HDFS
您将看到与之前看到的相同的 Hadoop Streaming 命令行输出以及结果。在这种情况下,用于存储结果的 HDFS 临时目录在作业终止后被删除。
现在,为了查看 MrJob 的灵活性,让我们尝试运行一个需要比一个 MapReduce 步骤更多的的流程。虽然是从命令行完成的,但这是一项非常困难的任务;事实上,您必须运行 MapReduce 的第一次迭代,检查错误,读取结果,然后启动 MapReduce 的第二次迭代,再次检查错误,最后读取结果。这听起来非常耗时,并且容易出错。由于 MrJob,这个操作非常容易:在代码中,可以创建一个 MapReduce 操作的级联,其中每个输出都是下一阶段的输入。
举个例子,让我们现在找到莎士比亚最常用的单词(使用 125 千行文件作为输入)。此操作不能在单个 MapReduce 步骤中完成;它需要至少两个。我们将基于 MapReduce 的两次迭代实现一个非常简单的算法:
- 数据分块器:就像默认的 MrJob 一样,输入文件在每一行被分割。
- 阶段 1-映射:为每个单词生成一个键映射元组;关键字是小写的单词,值总是 1。
- 阶段 1–减少:对于每个键(较低的单词),我们将所有值相加。输出会告诉我们这个单词在文本中出现了多少次。
- 阶段 2-映射:在这个步骤中,我们翻转键值元组,并将它们作为新键值对的值。为了强制一个缩减器拥有所有元组,我们为每个输出元组分配相同的键 None 。
- 阶段 2–减少:我们简单地丢弃唯一可用的关键字,并提取最大值,从而提取所有元组(计数、单词)的最大值。
In:
with open("MrJob_job2.py", "w") as fh:
fh.write("""
from mrjob.job import MRJob
from mrjob.step import MRStep
import re
WORD_RE = re.compile(r"[\w']+")
class MRMostUsedWord(MRJob):
def steps(self):
return [
MRStep(mapper=self.mapper_get_words,
reducer=self.reducer_count_words),
MRStep(mapper=self.mapper_word_count_one_key,
reducer=self.reducer_find_max_word)
]
def mapper_get_words(self, _, line):
# yield each word in the line
for word in WORD_RE.findall(line):
yield (word.lower(), 1)
def reducer_count_words(self, word, counts):
# send all (num_occurrences, word) pairs to the same reducer.
yield (word, sum(counts))
def mapper_word_count_one_key(self, word, counts):
# send all the tuples to same reducer
yield None, (counts, word)
def reducer_find_max_word(self, _, count_word_pairs):
# each item of word_count_pairs is a tuple (count, word),
yield max(count_word_pairs)
if __name__ == '__main__':
MRMostUsedWord.run()
""")
然后,我们可以决定在本地或 Hadoop 集群上运行它,获得相同的结果:威廉·莎士比亚最常用的单词是单词*,使用了 27K 多次。在这段代码中,我们只想输出结果;因此,我们使用--quiet选项启动作业:*
In:!python MrJob_job2.py --quiet ../datasets/shakespeare_all.txt
Out:27801 "the"
In:!python MrJob_job2.py -r hadoop --quiet hdfs:///datasets/shakespeare_all.txt
Out:27801 "the"
纱
借助 Hadoop 2(截至 2016 年的当前分支),在 HDFS 之上引入了一层,允许多个应用运行,例如,MapReduce 就是其中之一(针对批处理)。这一层的名称是又一个资源协商者 ( 纱)和其目标是管理集群中的资源管理。
纱线遵循主/从模式,由两个服务组成:资源管理器和节点管理器。
资源管理器是主控器,负责两件事:调度(分配资源)和应用管理(处理作业提交和跟踪其状态)。每个节点管理器都是架构的从属,是运行任务并向资源管理器报告的每个工作框架。
Hadoop 2 引入的纱层确保了以下几点:
- 多租户,即拥有多个引擎来使用 Hadoop
- 更好的集群利用率,因为任务的分配是动态的和可调度的
- 更好的可扩展性;纱不提供处理算法,它只是集群的资源管理器
- 与 MapReduce 的兼容性(Hadoop 1 中的较高层)
Spark
Apache Spark 是 Hadoop 的一个发展,在过去的几年里变得非常流行。与 Hadoop 及其 Java 和以批处理为中心的设计相反,Spark 能够以快速简单的方式产生迭代算法。此外,它有一套非常丰富的多种编程语言的 API,并且本机支持许多不同类型的数据处理(机器学习、流、图形分析、SQL 等)。
Apache Spark 是一个集群框架,旨在快速和通用地处理大数据。速度上的改进之一是,数据在每次作业后都保存在内存中,而不是像 Hadoop、MapReduce 和 HDFS 那样存储在文件系统中(除非您想这样做)。这使得迭代作业(如聚类 K-means 算法)越来越快,因为内存提供的延迟和带宽比物理磁盘性能更好。因此,运行 Spark 的集群需要为每个节点分配大量内存。
虽然 Spark 是在 Scala 中开发的(它和 Java 一样运行在 JVM 上),但它有多种编程语言的 API,包括 Java、Scala、Python 和 r .在本书中,我们将重点介绍 Python。
Spark 可以通过两种不同的方式运行:
- 独立模式:它运行在您的本地机器上。在这种情况下,最大并行化是本地机器的内核数量,可用内存量与本地机器完全相同。
- 集群模式:它在多个节点的集群上运行,使用一个集群管理器,如纱。在这种情况下,最大并行化是组成集群的所有节点上的核心数量,内存量是每个节点的内存量之和。
pySpark
为了使用 Spark 功能(或 pySpark,包含 Spark 的 Python APIs),我们需要实例化一个名为 SparkContext 的特殊对象。它告诉 Spark 如何访问集群,并包含一些特定于应用的参数。在虚拟机中提供的 IPython Notebook 中,该变量已经可用并命名为sc(这是 IPython Notebook 启动时的默认选项);现在让我们看看它包含了什么。
首先,打开一个新的 IPython 笔记本;准备好使用时,在第一个单元格中键入以下内容:
In:sc._conf.getAll()
Out:[(u'spark.rdd.compress', u'True'),
(u'spark.master', u'yarn-client'),
(u'spark.serializer.objectStreamReset', u'100'),
(u'spark.yarn.isPython', u'true'),
(u'spark.submit.deployMode', u'client'),
(u'spark.executor.cores', u'2'),
(u'spark.app.name', u'PySparkShell')]
它包含多个信息:最重要的是spark.master,在这种情况下设置为纱中的客户端,spark.executor.cores设置为 2 作为虚拟机的 CPU 数量, spark.app.name,应用的名称。应用的名称在共享(纱)集群时特别有用;转到ht.0.0.1:8088,可以检查应用的状态:
Spark 使用的数据模型被命名为弹性分布式数据集 ( RDD ),它是可以并行处理的元素的分布式集合。RDD 可以从现有集合(例如 Python 列表)或外部数据集创建,并作为文件存储在本地计算机、HDFS 或其他来源上。
现在让我们创建一个包含从 0 到 9 的整数的 RDD。为此,我们可以使用SparkContext对象提供的parallelize方法:
In:numbers = range(10)
numbers_rdd = sc.parallelize(numbers)
numbers_rdd
Out:ParallelCollectionRDD[1] at parallelize at PythonRDD.scala:423
如您所见,您不能简单地打印 RDD 内容,因为它被分成多个分区(并分布在集群中)。默认的分区数量是 CPU 数量的两倍(因此,在提供的虚拟机中是四个),但是可以使用并行化方法的第二个参数手动设置。
要打印出 RDD 中包含的数据,您应该调用collect方法。请注意,当在集群上运行时,该操作会收集节点上的所有数据;因此,节点应该有足够的内存来容纳它:
In:numbers_rdd.collect()
Out:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
要获得部分预览,请使用take方法指示您想要查看的元素数量。请注意,由于它是一个分布式数据集,因此不能保证元素的顺序与我们插入时的顺序相同:
In:numbers_rdd.take()
Out:[0, 1, 2, 3]
要读取文本文件,我们可以使用 Spark Context 提供的textFile方法。它允许读取 HDFS 文件和本地文件,并在换行符上拆分文本;因此,RDD 的第一个元素是文本文件的第一行(使用first方法)。请注意,如果您使用本地路径,组成集群的所有节点应该通过相同的路径访问相同的文件:
In:sc.textFile("hdfs:///datasets/hadoop_git_readme.txt").first()
Out:u'For the latest information about Hadoop, please visit our website at:'
In:sc.textFile("file:///home/vagrant/datasets/hadoop_git_readme.txt").first()
Out:u'For the latest information about Hadoop, please visit our website at:'
要将 RDD 的内容保存在磁盘上,可以使用 RDD 提供的saveAsTextFile方法。在这里,你可以使用多个目的地;在这个例子中,让我们把它保存在 HDFS,然后列出输出的内容:
In:numbers_rdd.saveAsTextFile("hdfs:///tmp/numbers_1_10.txt")
In:!hdfs dfs -ls /tmp/numbers_1_10.txt
Out:Found 5 items
-rw-r--r-- 1 vagrant supergroup 0 2016-02-12 14:18 /tmp/numbers_1_10.txt/_SUCCESS
-rw-r--r-- 1 vagrant supergroup 4 2016-02-12 14:18 /tmp/numbers_1_10.txt/part-00000
-rw-r--r-- 1 vagrant supergroup 4 2016-02-12 14:18 /tmp/numbers_1_10.txt/part-00001
-rw-r--r-- 1 vagrant supergroup 4 2016-02-12 14:18 /tmp/numbers_1_10.txt/part-00002
-rw-r--r-- 1 vagrant supergroup 8 2016-02-12 14:18 /tmp/numbers_1_10.txt/part-00003
Spark 为每个分区写一个文件,与 MapReduce 完全一样,为每个 Reduce 写一个文件。这种方式加快了保存时间,因为每个分区都是独立保存的,但是在一个节点的集群中,这使得读取更加困难。
在写入文件之前,我们可以将所有的分区设为 1 吗?或者,一般来说,我们可以减少一个 RDD 的分区数量吗?答案是肯定的,通过 RDD 提供的coalesce方法,传递我们想要的分区数量作为参数。传递1会强制 RDD 位于一个独立的分区中,并且在保存时,只生成一个输出文件。请注意,即使保存在本地文件系统上也会发生这种情况:每个分区都会创建一个文件。请注意,在由多个节点组成的集群环境中这样做并不能确保所有节点看到相同的输出文件:
In:
numbers_rdd.coalesce(1) \
.saveAsTextFile("hdfs:///tmp/numbers_1_10_one_file.txt")
In : !hdfs dfs -ls /tmp/numbers_1_10_one_file.txt
Out:Found 2 items
-rw-r--r-- 1 vagrant supergroup 0 2016-02-12 14:20 /tmp/numbers_1_10_one_file.txt/_SUCCESS
-rw-r--r-- 1 vagrant supergroup 20 2016-02-12 14:20 /tmp/numbers_1_10_one_file.txt/part-00000
In:!hdfs dfs -cat /tmp/numbers_1_10_one_file.txt/part-00000
Out:0
1
2
3
4
5
6
7
8
9
In:numbers_rdd.saveAsTextFile("file:///tmp/numbers_1_10.txt")
In:!ls /tmp/numbers_1_10.txt
Out:part-00000 part-00001 part-00002 part-00003 _SUCCESS
RDD 只支持两种类型的操作:
- 转换将数据集转换为不同的数据集。变换的输入和输出都是 RDDs 因此,可以将多个转换链接在一起,接近函数式编程。此外,转换是懒惰的,也就是说,它们不会直接计算结果。
- 操作从 RDDs 返回值,例如元素的总和和计数,或者只收集所有元素。当需要输出时,操作是执行(惰性)转换链的触发器。
典型的 Spark 程序是一系列的转换,最后是一个动作。默认情况下,每次运行操作时都会执行 RDD 上的所有转换(即,不保存每个转换后的中间状态)。但是,只要您想要缓存转换后的元素的值,就可以使用persist方法(在 RDD 上)覆盖这种行为。persist方法允许内存和磁盘的持久性。
在下一个例子中,我们将对 RDD 中包含的所有值进行平方,然后对它们求和;这个算法可以通过一个映射器(正方形元素)后跟一个缩减器(对数组求和)来执行。根据 Spark 的说法,map方法是一个转换器,因为它只是逐个元素地转换数据;reduce是一个动作,因为它从所有元素中一起创造价值。
让我们一步一步地解决这个问题,看看我们可以有多种操作方式。首先,从映射开始:我们首先定义一个返回输入参数平方的函数,然后我们将这个函数传递给 RDD 的map方法,最后我们收集 RDD 的元素:
In:
def sq(x):
return x**2
numbers_rdd.map(sq).collect()
Out:[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
虽然输出正确,sq功能占用了大量空间;得益于 Python 的 lambda 表达式,我们可以以这种方式更简洁地重写转换:
In:numbers_rdd.map(lambda x: x**2).collect()
Out:[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
请记住:为什么我们需要打对方付费电话来打印转换后的 RDD 的价值?这是因为map法不会春天来行动,而只会懒洋洋地被评价。而reduce法,则是一个动作;因此,将 reduce 步骤添加到前面的 RDD 应该会输出一个值。对于 map,reduce 将一个函数作为参数,该函数应该有两个参数(左值和右值),并且应该返回值。即使在这种情况下,也可以是用def定义的详细函数或lambda函数:
In:numbers_rdd.map(lambda x: x**2).reduce(lambda a,b: a+b)
Out:285
更简单的是,我们可以使用 sum 动作代替 reducer:
In:numbers_rdd.map(lambda x: x**2).sum()
Out:285
到目前为止,我们已经展示了一个非常简单的 pySpark 示例。想想引擎盖下发生了什么:数据集首先被加载并跨集群分区,然后映射操作在分布式环境中运行,然后所有分区被折叠在一起生成结果(sum 或 reduce),最后打印在 IPython Notebook 上。这是一个巨大的任务,但被 pySpark 变得超级简单。
现在让我们前进一步,介绍键值对;虽然 RDDs 可以包含任何类型的对象(到目前为止,我们已经看到了整数和文本行),但是当元素是由两个元素组成的元组时,可以进行一些操作:键和值。
举个例子,让我们先把 RDD 的数字按赔率和均价分组,然后分别计算两组的总和。至于 MapReduce 模型,最好是用一个键(odd或even)映射每个数字,然后对每个键使用求和操作进行约简。
我们可以从 map 操作开始:让我们首先创建一个标记数字的函数,如果自变量数字为偶数,则输出even,否则输出odd。然后,创建键值映射,为每个数字创建键值对,其中键是标签,值是数字本身:
In:
def tag(x):
return "even" if x%2==0 else "odd"
numbers_rdd.map(lambda x: (tag(x), x) ).collect()
Out:[('even', 0),
('odd', 1),
('even', 2),
('odd', 3),
('even', 4),
('odd', 5),
('even', 6),
('odd', 7),
('even', 8),
('odd', 9)]
为了单独减少每个键,我们现在可以使用reduceByKey方法(这不是 Spark 动作)。作为一个参数,我们应该传递我们应该应用于所有每个键的值的函数;在这种情况下,我们将对它们进行总结。最后,我们应该调用 collect 方法来打印结果:
In:
numbers_rdd.map(lambda x: (tag(x), x) ) \
.reduceByKey(lambda a,b: a+b).collect()
Out:[('even', 20), ('odd', 25)]
现在,让我们列出 Spark 中可用的一些最重要的方法;这不是一个详尽的指南,但只是包括了最常用的。
我们从转变开始;它们可以应用于 RDD,产生 RDD:
-
map(function):返回每个元素通过函数形成的 RDD。 -
flatMap(function): This returns an RDD formed by flattening the output of the function for each element of the input RDD. It's used when each value at the input can be mapped to 0 or more output elements.例如,为了计算每个单词在文本中出现的次数,我们应该将每个单词映射到一个键值对(单词是键,
1是值),以这种方式为文本的每个输入行生成多个键值元素: -
filter(function):返回由函数返回真的所有值组成的数据集。 -
sample(withReplacement, fraction, seed):这启动了 RDD,允许你创建一个采样的 RDD(有或没有替换),其长度是输入长度的一小部分。 -
distinct():这将返回一个包含输入 RDD 的不同元素的 RDD。 -
coalesce(numPartitions):这减少了 RDD 的分区数量。 -
repartition(numPartitions):这改变了 RDD 的分区数量。这种方法总是通过网络打乱所有数据。 -
groupByKey():这创建了一个 RDD,其中,对于每个键,值是输入数据集中具有该键的一系列值。 -
reduceByKey(function):通过按键聚合输入的 RDD,然后将减少功能应用于每组的值。 -
sortByKey(ascending):这将按照升序或降序对 RDD 中的元素进行键排序。 -
union(otherRDD):这将两个 rdd 合并在一起。 -
intersection(otherRDD):这个返回一个 RDD,由输入和参数 RDD 中出现的值组成。 -
join(otherRDD): This returns a dataset where the key-value inputs are joined (on the key) to the argument RDD.类似于 SQL 中的连接函数,也有可用的方法:
cartesian、leftOuterJoin、rightOuterJoin和fullOuterJoin。
现在,让我们概述一下 pySpark 中最受欢迎的动作。注意动作通过链中的所有变压器触发 RDD 的处理:
reduce(function):这聚集了 RDD 的元素,产生一个输出值count():这会返回 RDD 元素的计数countByKey():这将返回一个 Python 字典,其中每个键都与该键在 RDD 中的元素数量相关联collect():这个在本地返回变换后的 RDD 中的所有元素first():这会返回 RDD 的第一个值take(N):这个返回 RDD 的第一个 N 值takeSample(withReplacement, N, seed):这将返回 RDD 的 N 元素的引导,有或没有替换,最终使用提供的随机种子作为参数takeOrdered(N, ordering):在按值(升序或降序)排序之后,这将返回 RDD 中的顶部 N 元素saveAsTextFile(path):这将 RDD 作为一组文本文件保存在指定的目录中
还有一些既不是转换器也不是动作的方法:
cache():这个隐藏了 RDD 的元素;因此,基于同一 RDD 的未来计算可以将此作为起点persist(storage):这与缓存相同,但是您可以指定将 RDD 的元素(内存、磁盘或两者)存储在哪里unpersist():此撤销持久化或缓存操作
现在让我们尝试用 Hadoop 复制我们在关于 MapReduce 一节中看到的例子。使用 Spark,算法应该如下:
- 输入文件在 RDD 上被读取和并行化。这个操作可以用 Spark 上下文提供的
textFile方法来完成。 - 对于输入文件的每一行,返回三个键值对:一个包含字符数,一个包含字数,最后一个包含行数。在 Spark 中,这是一个 flatMap 操作,因为每条输入线生成三个输出。
- 对于每个键,我们总结所有的值。这可以通过
reduceByKey方法来完成。 - 最后,收集结果。在这种情况下,我们可以使用
collectAsMap方法收集 RDD 中的键值对,并返回一个 Python 字典。注意,这是一个动作;因此,执行 RDD 链并返回结果。
In:
def emit_feats(line):
return [("chars", len(line)), \
("words", len(line.split())), \
("lines", 1)]
print (sc.textFile("/datasets/hadoop_git_readme.txt")
.flatMap(emit_feats)
.reduceByKey(lambda a,b: a+b)
.collectAsMap())
Out:{'chars': 1335, 'lines': 31, 'words': 179}
与 MapReduce 实现相比,我们可以立即注意到这种方法的巨大速度。这是因为所有数据集都存储在内存中,而不在 HDFS。其次,这是一个纯 Python 实现,我们不需要调用外部命令行或库——pySpark 是独立的。
现在让我们在包含莎士比亚文本的较大文件上处理这个例子,以提取最流行的单词。在 Hadoop MapReduce 实现中,它需要两个地图缩减步骤,因此在 HDFS 上需要四个写/读步骤。在 pySpark 中,我们可以在 RDD 实现所有这些:
- 使用
textFile方法在 RDD 上读取输入文件并将其并行化。 - 对于每一行,提取所有的单词。对于这个操作,我们可以使用 flatMap 方法和一个正则表达式。
- 文本中的每个单词(也就是 RDD 的每个元素)现在被映射到一个键值对:键是较低的单词,值总是
1。这是地图操作。 - 通过一个
reduceByKey的调用,我们计算每个单词(键)在文本中出现的次数(RDD)。输出是键值对,其中键是单词,值是单词在文本中出现的次数。 - 我们翻转按键和价值观,创造一个新的 RDD。这是一个地图操作。
- 我们按降序对 RDD 进行排序,并提取第一个元素。这是一个动作,可以用
takeOrdered方法一次操作完成。
In:import re
WORD_RE = re.compile(r"[\w']+")
print (sc.textFile("/datasets/shakespeare_all.txt")
.flatMap(lambda line: WORD_RE.findall(line))
.map(lambda word: (word.lower(), 1))
.reduceByKey(lambda a,b: a+b)
.map(lambda (k,v): (v,k))
.takeOrdered(1, key = lambda x: -x[0]))
Out:[(27801, u'the')]
结果与我们使用 Hadoop 和 MapReduce 的结果相同,但在这种情况下,计算花费的时间要少得多。我们实际上可以进一步改进解决方案,将第二步和第三步合并在一起(flat map-为每个单词创建一个键值对,其中键是较低的单词,值是出现的次数),将第五步和第六步合并在一起(获取第一个元素,并根据它们的值对 RDD 中的元素进行排序,即该对中的第二个元素):
In:
print (sc.textFile("/datasets/shakespeare_all.txt")
.flatMap(lambda line: [(word.lower(), 1) for word in WORD_RE.findall(line)])
.reduceByKey(lambda a,b: a+b)
.takeOrdered(1, key = lambda x: -x[1]))
Out:[(u'the', 27801)]
要检查处理的状态,您可以使用 Spark 用户界面:这是一个图形界面,显示由 Spark 逐步运行的作业。要访问用户界面,您应该首先弄清楚 pySpark IPython 应用的名称,在 bash shell 中搜索您启动笔记本的名称(通常是application_<number>_<number>形式),然后将浏览器指向页面:http://localhost:8088/proxy/application_<number>_<number>
结果是类似于下图。它包含所有在 spark 中运行的作业(作为 IPython Notebook 单元格),您还可以将执行计划可视化为有向无环图 ( DAG ):
总结
在本章中,我们介绍了一些能够在由多个节点组成的集群上运行分布式作业的原语。我们已经看到了 Hadoop 框架及其所有组件、特性和限制,然后我们展示了 Spark 框架。
在下一章中,我们将深入挖掘 Spark,展示如何在分布式环境中进行数据科学。***
九、Spark 实用机器学习
在前一章中,我们看到了 Spark 数据处理的主要功能。在这一章中,我们将与 Spark 一起关注一个真实数据问题的数据科学。在本章中,您将学习以下主题:
- 如何在集群的节点间共享变量
- 如何从结构化(CSV)和半结构化(JSON)文件创建数据帧,将其保存在磁盘上并加载
- 如何使用类似 SQL 的语法来选择、过滤、连接、分组和聚合数据集,从而使预处理变得极其容易
- 如何处理数据集中丢失的数据
- Spark 中有哪些现成的算法可用于特征工程,以及如何在实际场景中使用它们
- 哪些学习者可用,以及如何在分布式环境中衡量他们的表现
- 如何在集群中运行超参数优化的交叉验证
为本章设置虚拟机
由于机器学习需要大量的计算能力,为了节省一些资源(尤其是内存),我们将在本章中使用不被 save 支持的 Spark 环境。这种操作模式被命名为独立模式,并创建一个没有集群功能的 Spark 节点;所有的处理都将在驱动程序机器上进行,不会被共享。不用担心;我们将在本章中看到的代码也将在集群环境中工作。
要以这种方式操作,请执行以下步骤:
- 使用
vagrant up命令打开虚拟机。 - 当虚拟机准备就绪时,使用
vagrant ssh访问虚拟机。 - 通过
./start_jupyter.sh从虚拟机内部使用 IPython 笔记本启动 Spark 独立模式。 - 打开指向
http://localhost:8888的浏览器。
要关闭它,使用 Ctrl + C 键退出 IPython 笔记本,vagrant halt关闭虚拟机。
注
请注意,即使在这种配置下,您也可以通过以下网址访问 Spark 用户界面(至少在运行 IPython 笔记本时):
http://localhost:4040
跨集群节点共享变量
当我们在分布式环境中工作时,有时需要跨节点共享信息,以便所有节点都可以使用一致的变量进行操作。Spark 通过提供两种变量来处理这种情况:只读变量和只写变量。通过不再确保共享变量既可读又可写,它也降低了一致性要求,让管理这种情况的艰苦工作落在了开发人员的肩上。通常,解决方案会很快达成,因为 Spark 真的很灵活,适应性很强。
广播只读变量
广播变量是驱动节点共享的变量,也就是我们配置中运行 IPython Notebook 的节点,所有节点都在集群中。它是一个只读变量,因为该变量由一个节点广播,如果另一个节点更改了它,则永远不会读回。
现在让我们看一下它是如何在一个简单的例子中工作的:我们想要对一个只包含性别信息的数据集进行一次热编码。准确地说,虚拟数据集包含的特征可以是男性 M 、女性 F 或未知的 U (如果信息缺失)。具体来说,我们希望所有节点都使用一个定义好的单向编码,如下面的字典中所列:
In:one_hot_encoding = {"M": (1, 0, 0),
"F": (0, 1, 0),
"U": (0, 0, 1)
}
现在让我们试着一步一步来。
最简单的解决方案(尽管它不起作用)是并行化虚拟数据集(或者从磁盘中读取它),然后使用 RDD 上的 map 方法和 lambda 函数将性别映射到它的编码元组:
In:(sc.parallelize(["M", "F", "U", "F", "M", "U"])
.map(lambda x: one_hot_encoding[x])
.collect())
Out:
[(1, 0, 0), (0, 1, 0), (0, 0, 1), (0, 1, 0), (1, 0, 0), (0, 0, 1)]
这个解决方案在本地有效,但是不会在真实分布式环境中运行,因为所有节点的工作空间中都没有one_hot_encoding变量。一个快速的解决方法是将 Python 字典包含在映射函数中(这是分布式的),就像我们在这里所做的那样:
In:
def map_ohe(x):
ohe = {"M": (1, 0, 0),
"F": (0, 1, 0),
"U": (0, 0, 1)
}
return ohe[x]
sc.parallelize(["M", "F", "U", "F", "M", "U"]).map(map_ohe).collect()
Out:
[(1, 0, 0), (0, 1, 0), (0, 0, 1), (0, 1, 0), (1, 0, 0), (0, 0, 1)] here are you I love you hello hi is that email with all leave formal minutes very worrying A hey
这样的解决方案在本地和服务器上都有效,但不是很好:我们混合了数据和流程,使得映射功能不可重用。如果映射函数引用一个广播变量会更好,这样它就可以与我们需要的任何映射一起使用,对数据集进行一次热编码。
为此,我们首先在映射函数内部广播 Python 字典(调用 Spark 上下文提供的broadcast方法,sc);使用它的.value属性,我们现在可以访问它。这样做之后,我们就有了一个通用的地图函数,可以在任何一个热门地图字典上工作:
In:bcast_map = sc.broadcast(one_hot_encoding)
def bcast_map_ohe(x, shared_ohe):
return shared_ohe[x]
(sc.parallelize(["M", "F", "U", "F", "M", "U"])
.map(lambda x: bcast_map_ohe(x, bcast_map.value))
.collect())
Out:
[(1, 0, 0), (0, 1, 0), (0, 0, 1), (0, 1, 0), (1, 0, 0), (0, 0, 1)]
把广播的变量想象成一个写在 HDFS 的文件。然后,当一个通用节点想要访问它时,它只需要 HDFS 路径(作为 map 方法的一个参数传递),并且您确信它们都将使用相同的路径读取相同的内容。当然,Spark 不使用 HDFS,而是它的内存变体。
注
广播变量保存在组成集群的所有节点的内存中;因此,他们从不共享大量可以填充他们的数据,这使得以下处理变得不可能。
要删除广播变量,请对广播变量使用unpersist方法。此操作将释放所有节点上该变量的内存:
In:bcast_map.unpersist()
累加器只写变量
在 Spark 簇中可以共享的其他变量是累加器。累加器是只写变量,可以加在一起,通常用于实现和或计数器。只有运行 IPython 笔记本的驱动程序节点可以读取它的值;所有其他节点都不能。
让我们用一个例子来看看它是如何工作的:我们想处理一个文本文件,并了解在处理它时有多少行是空的。当然,我们可以通过扫描数据集两次(使用两个 Spark 作业)来做到这一点:第一次对空行进行计数,第二次进行真正的处理,但这种解决方案不是很有效。
在第一个无效的解决方案中——使用两个独立的 Spark 作业提取空行的数量——我们可以读取文本文件,过滤空行,并对它们进行计数,如下所示:
In:print "The number of empty lines is:"
(sc.textFile('file:///home/vagrant/datasets/hadoop_git_readme.txt')
.filter(lambda line: len(line) == 0)
.count())
Out:The number of empty lines is:
6
相反,第二种解决方案更有效(也更复杂)。我们实例化一个累加器变量(初始值为0),并为处理输入文件的每一行时找到的每一个空行添加1(用一个映射)。同时,我们可以对每一行做一些处理;例如,在下面这段代码中,我们简单地为每一行返回1,以这种方式计算文件中的所有行。
在处理结束时,我们将有两条信息:第一条是行数,来自对变换后的 RDD 的count()动作的结果,第二条是累加器的value属性中包含的空行数。请记住,在扫描数据集一次后,这两个选项都可用:
In:accum = sc.accumulator(0)
def split_line(line):
if len(line) == 0:
accum.add(1)
return 1
tot_lines = (
sc.textFile('file:///home/vagrant/datasets/hadoop_git_readme.txt')
.map(split_line)
.count())
empty_lines = accum.value
print "In the file there are %d lines" % tot_lines
print "And %d lines are empty" % empty_lines
Out:In the file there are 31 lines
And 6 lines are empty
本质上,Spark 支持数字类型的累加器,默认操作是求和。通过多一点编码,我们可以把它变成更复杂的东西。
广播和累加器在一起——一个例子
虽然广播和累加器是简单且非常有限的变量(一个是只读的,另一个是只写的),但它们可以被主动用于创建非常复杂的操作。例如,让我们尝试在分布式环境中对 Iris 数据集应用不同的机器学习算法。我们将通过以下方式创建一个 Spark 作业:
- 数据集被读取并广播到所有节点(因为它足够小,可以放入内存中)。
- 每个节点将在数据集上使用不同的分类器,并在整个数据集上返回分类器名称及其准确度分数。请注意,在这个简单的例子中,为了让事情变得简单,我们将不做任何预处理、训练/测试分割或超参数优化。
- 如果分类器引发任何异常,错误的字符串表示以及分类器名称应该存储在累加器中。
- 最终的输出应该包含一个分类器的列表,这些分类器执行分类任务时没有错误,并且它们的准确性得分。
作为第一步,我们加载 Iris 数据集,并将其广播给集群中的所有节点:
In:from sklearn.datasets import load_iris
bcast_dataset = sc.broadcast(load_iris())
现在,让我们创建一个自定义累加器。它将包含一个元组列表,以字符串形式存储分类器名称和它经历的异常。自定义累加器是由AccumulatorParam类派生的,应该至少包含两个方法:zero(初始化时调用)和addInPlace(在累加器上调用 add 方法时调用)。
下面的代码显示了最简单的方法,随后将其初始化为一个空列表。请注意,加法运算有点棘手:我们需要组合两个元素,一个元组和一个列表,但我们不知道哪个元素是列表,哪个是元组;因此,我们首先确保这两个元素都是列表,然后我们可以以一种简单的方式(使用+运算符)继续连接它们:
In:from pyspark import AccumulatorParam
class ErrorAccumulator(AccumulatorParam):
def zero(self, initialList):
return initialList
def addInPlace(self, v1, v2):
if not isinstance(v1, list):
v1 = [v1]
if not isinstance(v2, list):
v2 = [v2]
return v1 + v2
errAccum = sc.accumulator([], ErrorAccumulator())
现在,让我们定义映射函数:每个节点应该在广播的 Iris 数据集上训练、测试和评估一个分类器。作为一个参数,该函数将接收分类器对象,并应该返回一个元组,该元组包含列表中包含的分类器名称及其准确度分数。
如果这样做引发了任何异常,分类器名称和异常作为字符串被添加到累加器中,并作为空列表返回:
In:
def apply_classifier(clf, dataset):
clf_name = clf.__class__.__name__
X = dataset.value.data
y = dataset.value.target
try:
from sklearn.metrics import accuracy_score
clf.fit(X, y)
y_pred = clf.predict(X)
acc = accuracy_score(y, y_pred)
return [(clf_name, acc)]
except Exception as e:
errAccum.add((clf_name, str(e)))
return []
终于,我们到达了工作的核心。我们现在从 Scikit-learn 实例化几个对象(为了测试累加器,其中一些不是分类器)。我们将把它们转换成 RDD,并应用我们在前一个单元格中创建的映射函数。由于返回值是一个列表,我们可以使用flatMap只收集没有陷入任何异常的映射器的输出:
In:from sklearn.linear_model import SGDClassifier
from sklearn.dummy import DummyClassifier
from sklearn.decomposition import PCA
from sklearn.manifold import MDS
classifiers = [DummyClassifier('most_frequent'),
SGDClassifier(),
PCA(),
MDS()]
(sc.parallelize(classifiers)
.flatMap(lambda x: apply_classifier(x, bcast_dataset))
.collect())
Out:[('DummyClassifier', 0.33333333333333331),
('SGDClassifier', 0.66666666666666663)]
不出所料,只有真正的 分类器包含在输出中。现在让我们看看哪些分类器产生了错误。不出所料,这里我们发现了前面输出中缺少的两个:
In:print "The errors are:"
errAccum.value
Out:The errors are:
[('PCA', "'PCA' object has no attribute 'predict'"),
('MDS', "Proximity must be 'precomputed' or 'euclidean'. Got euclidean instead")]
最后一步,让我们清理广播数据集:
In:bcast_dataset.unpersist()
请记住,在这个例子中,我们使用了一个可以广播的小数据集。在现实世界的大数据问题中,您需要从 HDFS 加载数据集,广播 HDFS 路径。
Spark 中的数据预处理
到目前为止,我们已经看到了如何从本地文件系统和 HDFS 加载文本数据。文本文件可以包含非结构化数据(如文本文档)或结构化数据(如 CSV 文件)。至于半结构化数据,就像包含 JSON 对象的文件一样,Spark 有特殊的例程能够将文件转换成数据帧,类似于 R 和 Python 熊猫中的数据帧。数据帧非常类似于 RDBMS 表,在 RDBMS 表中设置了模式。
JSON 文件和 Spark 数据帧
为了导入符合 JSON 的文件,我们应该首先创建一个 SQL 上下文,从本地 Spark Context 创建一个SQLContext对象:
In:from pyspark.sql import SQLContext
sqlContext = SQLContext(sc)
现在,让我们看看一个小 JSON 文件的内容(它是在游民虚拟机中提供的)。它是一个有六行三列的表的 JSON 表示,其中缺少一些属性(例如带有user_id=0的用户的gender属性):
In:!cat /home/vagrant/datasets/users.json
Out:{"user_id":0, "balance": 10.0}
{"user_id":1, "gender":"M", "balance": 1.0}
{"user_id":2, "gender":"F", "balance": -0.5}
{"user_id":3, "gender":"F", "balance": 0.0}
{"user_id":4, "balance": 5.0}
{"user_id":5, "gender":"M", "balance": 3.0}
使用sqlContext提供的read.json方法,我们已经有了格式良好的表,并且在一个变量中有所有正确的列名。输出变量被键入为 Spark 数据帧。要在一个漂亮的格式化表格中显示变量,使用它的show方法:
In:
df = sqlContext.read \
.json("file:///home/vagrant/datasets/users.json")
df.show()
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 10.0| null| 0|
| 1.0| M| 1|
| -0.5| F| 2|
| 0.0| F| 3|
| 5.0| null| 4|
| 3.0| M| 5|
+-------+------+-------+
此外,我们可以使用printSchema方法研究数据帧的模式。我们意识到,在读取 JSON 文件的时候,每一个列类型都已经被数据推断出来了(示例中user_id列包含长整数,性别列由字符串组成,余额为双浮点):
In:df.printSchema()
Out:root
|-- balance: double (nullable = true)
|-- gender: string (nullable = true)
|-- user_id: long (nullable = true)
就像关系数据库管理系统中的一个表一样,我们可以滑动并切割数据框中的数据,选择列并按属性过滤数据。在本例中,我们要打印性别未缺失且余额严格大于零的用户的余额、性别和user_id。为此,我们可以使用filter和select方法:
In:(df.filter(df['gender'] != 'null')
.filter(df['balance'] > 0)
.select(['balance', 'gender', 'user_id'])
.show())
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 1.0| M| 1|
| 3.0| M| 5|
+-------+------+-------+
我们也可以用类似 SQL 的语言重写前面工作的每一部分。事实上,filter和select方法可以接受 SQL 格式的字符串:
In:(df.filter('gender is not null')
.filter('balance > 0').select("*").show())
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 1.0| M| 1|
| 3.0| M| 5|
+-------+------+-------+
我们也可以只使用对filter方法的一次调用:
In:df.filter('gender is not null and balance > 0').show()
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 1.0| M| 1|
| 3.0| M| 5|
+-------+------+-------+
处理缺失数据
数据预处理的一个常见问题是处理缺失数据。Spark 数据框,类似熊猫数据框,提供了一系列你可以在上面做的操作。例如,让数据集仅由完整的行组成的最简单的选择是丢弃包含缺失信息的行。为此,在 Spark 数据框中,我们首先必须访问数据框的na属性,然后调用drop方法。生成的表将只包含完整的行:
In:df.na.drop().show()
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 1.0| M| 1|
| -0.5| F| 2|
| 0.0| F| 3|
| 3.0| M| 5|
+-------+------+-------+
如果这样的操作删除了太多行,我们总是可以决定删除行时应该考虑哪些列(作为drop方法的扩充子集):
In:df.na.drop(subset=["gender"]).show()
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 1.0| M| 1|
| -0.5| F| 2|
| 0.0| F| 3|
| 3.0| M| 5|
+-------+------+-------+
此外,如果您想为每列设置默认值而不是删除行数据,可以使用fill方法,传递由列名(作为字典键)和默认值组成的字典来替换该列中缺失的数据(作为字典中键的值)。
举个例子,如果你想确保变量 balance(缺失的地方)设置为0,变量 gender(缺失的地方)设置为U,你可以简单的做以下操作:
In:df.na.fill({'gender': "U", 'balance': 0.0}).show()
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 10.0| U| 0|
| 1.0| M| 1|
| -0.5| F| 2|
| 0.0| F| 3|
| 5.0| U| 4|
| 3.0| M| 5|
+-------+------+-------+
在内存中分组和创建表格
要将函数应用于一组行(与 SQL GROUP BY的情况完全相同),可以使用两种类似的方法。在下面的例子中,我们想要计算每个性别的平均平衡:
In:(df.na.fill({'gender': "U", 'balance': 0.0})
.groupBy("gender").avg('balance').show())
Out:
+------+------------+
|gender|avg(balance)|
+------+------------+
| F| -0.25|
| M| 2.0|
| U| 7.5|
+------+------------+
到目前为止,我们已经使用了数据框,但是正如您所看到的,数据框方法和 SQL 命令之间的距离很小。实际上,使用 Spark,可以将 DataFrame 注册为一个 SQL 表,以充分享受 SQL 的强大功能。该表保存在内存中,并以类似于 RDD 的方式分发。
要注册该表,我们需要提供一个名称,它将在未来的 SQL 命令中使用。在这种情况下,我们决定将其命名为users:
In:df.registerTempTable("users")
通过调用 Spark sql上下文提供的sql方法,我们可以运行任何符合 SQL 的表:
In:sqlContext.sql("""
SELECT gender, AVG(balance)
FROM users
WHERE gender IS NOT NULL
GROUP BY gender""").show()
Out:
+------+-----+
|gender| _c1|
+------+-----+
| F|-0.25|
| M| 2.0|
+------+-----+
毫不奇怪,命令输出的表(以及users表本身)属于 Spark 数据帧类型:
In:type(sqlContext.table("users"))
Out:pyspark.sql.dataframe.DataFrame
数据框、表和关系数据库紧密相连,RDD 方法可以用在数据框上。请记住,数据框的每一行都是 RDD 元素。让我们详细看看这个,首先收集整个表:
In:sqlContext.table("users").collect()
Out:[Row(balance=10.0, gender=None, user_id=0),
Row(balance=1.0, gender=u'M', user_id=1),
Row(balance=-0.5, gender=u'F', user_id=2),
Row(balance=0.0, gender=u'F', user_id=3),
Row(balance=5.0, gender=None, user_id=4),
Row(balance=3.0, gender=u'M', user_id=5)]
In:
a_row = sqlContext.sql("SELECT * FROM users").first()
a_row
Out:Row(balance=10.0, gender=None, user_id=0)
输出是一个Row对象列表(它们看起来像 Python 的namedtuple)。让我们深入挖掘一下:Row包含多个属性,可以作为属性或字典键访问;也就是说,要从第一行中取出余额,我们可以选择以下两种方式:
In:print a_row['balance']
print a_row.balance
Out:10.0
10.0
还有,Row可以用Row的asDict方法收集成 Python 字典。结果包含作为键的属性名和作为字典值的属性值:
In:a_row.asDict()
Out:{'balance': 10.0, 'gender': None, 'user_id': 0}
将预处理后的数据帧或 RDD 写入磁盘
要将数据帧或 RDD 写入磁盘,我们可以使用写入方法。我们有多种格式可供选择;在这种情况下,我们将把它保存为本地机器上的 JSON 文件:
In:(df.na.drop().write
.save("file:///tmp/complete_users.json", format='json'))
检查本地文件系统上的输出,我们立即看到一些与我们预期不同的东西:这个操作创建了多个文件(part-r-…)。
它们中的每一个都包含一些序列化为 JSON 对象的行,将它们合并在一起将创建全面的输出。由于 Spark 是为处理大型分布式文件而设计的,因此针对该文件调整了写入操作,每个节点写入完整 RDD 的一部分:
In:!ls -als /tmp/complete_users.json
Out:total 28
4 drwxrwxr-x 2 vagrant vagrant 4096 Feb 25 22:54 .
4 drwxrwxrwt 9 root root 4096 Feb 25 22:54 ..
4 -rw-r--r-- 1 vagrant vagrant 83 Feb 25 22:54 part-r-00000-...
4 -rw-rw-r-- 1 vagrant vagrant 12 Feb 25 22:54 .part-r-00000-...
4 -rw-r--r-- 1 vagrant vagrant 82 Feb 25 22:54 part-r-00001-...
4 -rw-rw-r-- 1 vagrant vagrant 12 Feb 25 22:54 .part-r-00001-...
0 -rw-r--r-- 1 vagrant vagrant 0 Feb 25 22:54 _SUCCESS
4 -rw-rw-r-- 1 vagrant vagrant 8 Feb 25 22:54 ._SUCCESS.crc
为了读回它,我们不需要创建一个独立的文件——即使在读操作中有多个片段也可以。一个 JSON 文件也可以在一个 SQL 查询的FROM子句中读取。现在,让我们尝试在不创建中间数据帧的情况下,打印刚刚写在磁盘上的 JSON:
In:sqlContext.sql(
"SELECT * FROM json.`file:///tmp/complete_users.json`").show()
Out:
+-------+------+-------+
|balance|gender|user_id|
+-------+------+-------+
| 1.0| M| 1|
| -0.5| F| 2|
| 0.0| F| 3|
| 3.0| M| 5|
+-------+------+-------+
除了 JSON,还有另一种格式在处理结构化大数据集时非常流行:Parquet 格式。拼花是 Hadoop 生态系统中可用的柱状存储格式;它对数据进行压缩和编码,并且可以使用嵌套结构:所有这些特性都使它非常高效。
保存和加载与 JSON 非常相似,即使在这种情况下,该操作也会产生多个写入磁盘的文件:
In:df.na.drop().write.save(
"file:///tmp/complete_users.parquet", format='parquet')
In:!ls -als /tmp/complete_users.parquet/
Out:total 44
4 drwxrwxr-x 2 vagrant vagrant 4096 Feb 25 22:54 .
4 drwxrwxrwt 10 root root 4096 Feb 25 22:54 ..
4 -rw-r--r-- 1 vagrant vagrant 376 Feb 25 22:54 _common_metadata
4 -rw-rw-r-- 1 vagrant vagrant 12 Feb 25 22:54 ._common_metadata..
4 -rw-r--r-- 1 vagrant vagrant 1082 Feb 25 22:54 _metadata
4 -rw-rw-r-- 1 vagrant vagrant 20 Feb 25 22:54 ._metadata.crc
4 -rw-r--r-- 1 vagrant vagrant 750 Feb 25 22:54 part-r-00000-...
4 -rw-rw-r-- 1 vagrant vagrant 16 Feb 25 22:54 .part-r-00000-...
4 -rw-r--r-- 1 vagrant vagrant 746 Feb 25 22:54 part-r-00001-...
4 -rw-rw-r-- 1 vagrant vagrant 16 Feb 25 22:54 .part-r-00001-...
0 -rw-r--r-- 1 vagrant vagrant 0 Feb 25 22:54 _SUCCESS
4 -rw-rw-r-- 1 vagrant vagrant 8 Feb 25 22:54 ._SUCCESS.crc
处理 Spark 数据帧
到目前为止,我们已经描述了如何从 JSON 和 Parquet 文件中加载数据帧,但是没有描述如何从现有的 RDD 文件中创建它们。为此,您只需要为 RDD 中的每个记录创建一个Row对象,并调用 SQL 上下文的createDataFrame方法。最后,您可以将其注册为临时表,以充分利用 SQL 语法的强大功能:
In:from pyspark.sql import Row
rdd_gender = \
sc.parallelize([Row(short_gender="M", long_gender="Male"),
Row(short_gender="F", long_gender="Female")])
(sqlContext.createDataFrame(rdd_gender)
.registerTempTable("gender_maps"))
In:sqlContext.table("gender_maps").show()
Out:
+-----------+------------+
|long_gender|short_gender|
+-----------+------------+
| Male| M|
| Female| F|
+-----------+------------+
注
这也是操作 CSV 文件的首选方式。首先用sc.textFile读取文件;然后使用split方法、Row构造函数和createDataFrame方法,创建最终的数据框。
当内存中有多个数据帧或可以从磁盘加载的数据帧时,您可以加入并使用经典关系数据库管理系统中可用的所有操作。在本例中,我们可以将从 RDD 创建的数据框与我们存储的拼花文件中包含的用户数据集连接起来。结果令人震惊:
In:sqlContext.sql("""
SELECT balance, long_gender, user_id
FROM parquet.`file:///tmp/complete_users.parquet`
JOIN gender_maps ON gender=short_gender""").show()
Out:
+-------+-----------+-------+
|balance|long_gender|user_id|
+-------+-----------+-------+
| 3.0| Male| 5|
| 1.0| Male| 1|
| 0.0| Female| 3|
| -0.5| Female| 2|
+-------+-----------+-------+
在 web UI 中,每个 SQL 查询在 SQL 选项卡下被映射为一个虚拟的有向无环图 ( DAG ) 。这对于跟踪工作进度和理解查询的复杂性非常有用。在执行前面的 JOIN 查询时,您可以清楚地看到两个分支正在进入同一个BroadcastHashJoin块:第一个来自 RDD,第二个来自拼花文件。然后,以下块只是选定列上的一个投影:
由于表在内存中,最后要做的是清理释放用来保存它们的内存。通过调用sqlContext提供的tableNames方法,我们得到了当前内存中所有表的列表。然后,要释放它们,我们可以用dropTempTable用表的名字作为参数。超过这一点,对这些表的任何进一步引用都将返回一个错误:
In:sqlContext.tableNames()
Out:[u'gender_maps', u'users']
In:
for table in sqlContext.tableNames():
sqlContext.dropTempTable(table)
从 Spark 1.3 开始,在进行数据科学运算时,DataFrame 是对数据集进行运算的首选方式。
带 Spark 的机器学习
在这里,我们到达了您工作的主要任务:创建一个模型来预测数据集中缺失的一个或多个属性。为此,我们使用了一些机器学习建模,Spark 可以在这种情况下为我们提供一只大手。
MLlib 是 Spark 机器学习库;虽然它是用 Scala 和 Java 构建的,但它的功能在 Python 中也是可用的。它包含分类、回归和推荐学习器,一些用于降维和特征选择的例程,并且具有许多用于文本处理的功能。它们都能够处理巨大的数据集,并利用集群中所有节点的能力来实现目标。
截至目前(2016 年),它由两个主要包组成:mllib,在 RDDs 上运行,ml,在 DataFrames 上运行。由于后者表现良好,并且是数据科学中最流行的数据表示方式,开发人员选择贡献和改进ml分支,让前者保留下来,但不再进一步开发。乍一看,MLlib 似乎是一个完整的库,但是在开始使用 Spark 之后,您会注意到默认包中既没有统计库,也没有数字库。在这里,SciPy 和 NumPy 来帮助您,它们再次成为数据科学的基础!
在本节中,我们将尝试探索新的 pyspark.ml包的功能;截至目前,与最先进的 Scikit-learn 库相比,它仍处于早期阶段,但它在未来肯定有很大的潜力。
注
Spark 是一个高级的、分布式的、复杂的软件,应该只在大数据上使用,并且有多个节点的集群;事实上,如果数据集可以放在内存中,使用其他库(如 Scikit-learn 或类似的库)会更方便,这些库只关注问题的数据科学方面。在小数据集的单个节点上运行 Spark 可能比 Scikit-learn 等效算法慢五倍。
KDD 99 数据集上的 Spark
让我们使用一个真实的数据集来进行这个探索:KDD99 数据集。竞赛的目标是创建一个网络入侵检测系统,能够识别哪些网络流量是恶意的,哪些不是。而且,数据集中有很多不同的攻击;目标是使用数据集中包含的数据包流的特征来准确预测它们。
作为数据集上的一个边节点,在发布后的最初几年里,为入侵检测系统开发出色的解决方案非常有用。如今,由于这一点,数据集中包含的所有攻击都非常容易检测,因此不再用于入侵检测系统的开发。
例如,的特征是协议(tcp、icmp 和 udp)、服务(http、smtp 等)、数据包的大小、协议中活动的标志、成为根的尝试次数等。
注
更多关于 KDD99 挑战赛和数据集的信息可在kdd.ics.uci.edu/databases/k…获得。
虽然这是一个经典的多类分类问题,但我们将深入研究它,向您展示如何在 Spark 中执行此任务。为了保持清洁,我们将使用新的 IPython 笔记本。
读取数据集
首先让我们下载并解压数据集。我们将非常保守,只使用原始训练数据集的 10%(75MB,未压缩),因为我们所有的分析都在一个小型虚拟机上运行。如果您想尝试一下,可以取消对以下代码片段中的行的注释,并下载完整的训练数据集(750MB 未压缩)。我们使用 bash 命令下载训练数据集、测试(47MB)和特征名:
In:!rm -rf ../datasets/kdd*
# !wget -q -O ../datasets/kddtrain.gz \
# http://kdd.ics.uci.edu/databases/kddcup99/kddcup.data.gz
!wget -q -O ../datasets/kddtrain.gz \
http://kdd.ics.uci.edu/databases/kddcup99/kddcup.data_10_percent.gz
!wget -q -O ../datasets/kddtest.gz \
http://kdd.ics.uci.edu/databases/kddcup99/corrected.gz
!wget -q -O ../datasets/kddnames \
http://kdd.ics.uci.edu/databases/kddcup99/kddcup.names
!gunzip ../datasets/kdd*gz
现在,打印前几行以了解格式。很明显,这是一个没有标题的经典 CSV,每行末尾都有一个点。此外,我们可以看到一些字段是数字的,但其中一些是文本的,目标变量包含在最后一个字段中:
In:!head -3 ../datasets/kddtrain
Out:
0,tcp,http,SF,181,5450,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,8,8,0.00,0.00,0.00,0.00,1.00,0.00,0.00,9,9,1.00,0.00,0.11,0.00,0.00,0.00,0.00,0.00,normal.
0,tcp,http,SF,239,486,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,8,8,0.00,0.00,0.00,0.00,1.00,0.00,0.00,19,19,1.00,0.00,0.05,0.00,0.00,0.00,0.00,0.00,normal.
0,tcp,http,SF,235,1337,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,8,8,0.00,0.00,0.00,0.00,1.00,0.00,0.00,29,29,1.00,0.00,0.03,0.00,0.00,0.00,0.00,0.00,normal.
要创建带有命名字段的数据框,我们应该首先读取kddnames文件中包含的头。目标字段将简单命名为target。
读取并解析文件后,我们打印出问题的特征数量(记住目标变量不是特征)和它们的前 10 个名称:
In:
with open('../datasets/kddnames', 'r') as fh:
header = [line.split(':')[0]
for line in fh.read().splitlines()][1:]
header.append('target')
print "Num features:", len(header)-1
print "First 10:", header[:10]
Out:Num features: 41
First 10: ['duration', 'protocol_type', 'service', 'flag', 'src_bytes', 'dst_bytes', 'land', 'wrong_fragment', 'urgent', 'hot']
现在让我们创建两个独立的关系数据库,一个用于训练数据,另一个用于测试数据:
In:
train_rdd = sc.textFile('file:///home/vagrant/datasets/kddtrain')
test_rdd = sc.textFile('file:///home/vagrant/datasets/kddtest')
现在,我们需要解析每个文件的每一行来创建一个数据帧。首先,我们将 CSV 文件的每一行拆分为单独的字段,然后将每个数值转换为浮点,将每个文本值转换为字符串。最后,我们去掉每行末尾的点。
最后一步,使用sqlContext提供的createDataFrame方法,我们可以为训练和测试数据集创建两个带有命名列的 Spark 数据帧:
In:
def line_parser(line):
def piece_parser(piece):
if "." in piece or piece.isdigit():
return float(piece)
else:
return piece
return [piece_parser(piece) for piece in line[:-1].split(',')]
train_df = sqlContext.createDataFrame(
train_rdd.map(line_parser), header)
test_df = sqlContext.createDataFrame(
test_rdd.map(line_parser), header)
到目前为止,我们只写了 RDD 转换器;让我们引入一个操作,看看我们在数据集中有多少观察值,同时检查前面代码的正确性。
In:print "Train observations:", train_df.count()
print "Test observations:", test_df.count()
Out:Train observations: 494021
Test observations: 311029
尽管我们使用的是完整 KDD99 数据集的十分之一,但我们仍在进行 50 万次观测。乘以特征数 41,我们清楚地看到,我们将在包含 2000 多万个值的观察矩阵上训练我们的分类器。这对于 Spark 来说并不是一个很大的数据集(完整的 KDD99 也不是);世界各地的开发者已经在 petabytes 和十亿记录上使用它。如果数字看起来很大,不要害怕:Spark 旨在应对它们!
现在,让我们看看它在数据框的模式中是什么样子的。具体来说,我们想确定哪些字段是数字,哪些包含字符串(注意,为了简洁起见,结果被截断了):
In:train_df.printSchema()
Out:root
|-- duration: double (nullable = true)
|-- protocol_type: string (nullable = true)
|-- service: string (nullable = true)
|-- flag: string (nullable = true)
|-- src_bytes: double (nullable = true)
|-- dst_bytes: double (nullable = true)
|-- land: double (nullable = true)
|-- wrong_fragment: double (nullable = true)
|-- urgent: double (nullable = true)
|-- hot: double (nullable = true)
...
...
...
|-- target: string (nullable = true)
特征工程
从视觉分析来看,只有四个字段是字符串:protocol_type、service、flag和target(不出所料,这是多类目标标签)。
由于我们将使用基于树的分类器,我们希望将每个级别的文本编码为每个变量的数字。使用 Scikit-learn,这个操作可以通过一个sklearn.preprocessing.LabelEncoder对象来完成。在 Spark 中相当于pyspark.ml.feature套装的StringIndexer。
我们需要用 Spark 编码四个变量;然后我们必须将四个StringIndexer对象级联在一起:它们中的每一个都将对数据帧的特定列进行操作,输出一个带有附加列的数据帧(类似于映射操作)。映射是自动的,按频率排序:Spark 对所选列中每个级别的计数进行排序,将最受欢迎的级别映射为 0,下一个级别映射为 1,依此类推。请注意,通过此操作,您将遍历数据集一次,以统计每个级别的出现次数;如果您已经知道映射,那么广播它并使用map操作会更有效,如本章开头所示。
类似地,我们可以使用一个热编码器来生成一个数值观察矩阵。在单热编码器的情况下,我们会在数据帧中有多个输出列,每个分类特征的每个级别有一个输出列。为此,Spark 提供了pyspark.ml.feature.OneHotEncoder课程。
注
更一般地说,包含在pyspark.ml.feature包中的所有类都用于从数据帧中提取、转换和选择特征。所有的在数据框中读取一些列,创建一些其他列。
从 Spark 1.6 开始,Python 中可用的特性操作包含在以下详尽的列表中(所有这些都可以在pyspark.ml.feature包中找到)。名称应该是直观的,除了其中的几个,将在内联或稍后的文本中解释:
- 对于文本输入(理想情况下):
- 阿富汗国防军和以色列国防军
- 标记器及其基于正则表达式的实现
- Word2vec
- 停止词移除器
- Ngram
- 对于分类特征:
- 字符串索引和它的逆编码器,索引字符串
- OneHotEncoder
- 矢量分度器(现成的分类到数字分度器)
- 对于其他输入:
- 二值化器
- 污染控制局(Pollution Control Agency)
- 多项式展开
- 规格化器、标准缩放器和最小最大缩放器
- 桶化器(桶化特征的值)
- elemontwiseproduct _ 将列相乘)
- 通用:
- SQLTransformer(实现由 SQL 语句定义的转换,将 DataFrame 称为名为
__THIS__的表) - 公式(使用 R 型语法选择列)
- 向量装配器(从多列创建特征向量)
- SQLTransformer(实现由 SQL 语句定义的转换,将 DataFrame 称为名为
回到例子,我们现在想把每个分类变量中的级别编码为离散的数字。正如我们已经解释过的,为此,我们将为每个变量使用一个StringIndexer对象。此外,我们可以使用一个 ML 管道,并将它们设置为它的阶段。
然后,为了适合所有的索引器,你只需要调用管道的fit方法。在内部,它将按顺序适合所有分段对象。当它完成拟合操作时,会创建一个新对象,我们可以将其称为拟合管道。调用这个新对象的transform方法将依次调用所有的分段元素(已经拟合),每个都在前一个完成之后。在这段代码中,您将看到管道正在运行。请注意,变压器构成了管道。因此,由于不存在任何操作,因此实际上不会执行任何操作。在输出数据框中,您会注意到四个额外的列,其名称与原始分类列相同,但带有_cat后缀:
In:from pyspark.ml import Pipeline
from pyspark.ml.feature import StringIndexer
cols_categorical = ["protocol_type", "service", "flag","target"]
preproc_stages = []
for col in cols_categorical:
out_col = col + "_cat"
preproc_stages.append(
StringIndexer(
inputCol=col, outputCol=out_col, handleInvalid="skip"))
pipeline = Pipeline(stages=preproc_stages)
indexer = pipeline.fit(train_df)
train_num_df = indexer.transform(train_df)
test_num_df = indexer.transform(test_df)
让我们进一步研究管道。在这里,我们将看到管道中的阶段:不合适的管道和已安装的管道。请注意,Spark 和 Scikit-learn 有很大的区别:在 Scikit-learn 中,fit和transform是在同一个对象上调用的,而在 Spark 中,fit方法会产生一个新的对象(通常,它的名称会添加一个Model后缀,就像对于Pipeline和PipelineModel一样),在这里您可以调用transform方法。这种差异源于闭包——一个合适的对象很容易在进程和集群之间分布:
In:print pipeline.getStages()
print
print pipeline
print indexer
Out:
[StringIndexer_432c8aca691aaee949b8, StringIndexer_4f10bbcde2452dd1b771, StringIndexer_4aad99dc0a3ff831bea6, StringIndexer_4b369fea07873fc9c2a3]
Pipeline_48df9eed31c543ba5eba
PipelineModel_46b09251d9e4b117dc8d
让我们看看第一个观察,也就是 CSV 文件中的第一行,经过管道后是如何变化的。请注意,我们在这里使用了一个操作,因此管道和管道模型中的所有阶段都会被执行:
In:print "First observation, after the 4 StringIndexers:\n"
print train_num_df.first()
Out:First observation, after the 4 StringIndexers:
Row(duration=0.0, protocol_type=u'tcp', service=u'http', flag=u'SF', src_bytes=181.0, dst_bytes=5450.0, land=0.0, wrong_fragment=0.0, urgent=0.0, hot=0.0, num_failed_logins=0.0, logged_in=1.0, num_compromised=0.0, root_shell=0.0, su_attempted=0.0, num_root=0.0, num_file_creations=0.0, num_shells=0.0, num_access_files=0.0, num_outbound_cmds=0.0, is_host_login=0.0, is_guest_login=0.0, count=8.0, srv_count=8.0, serror_rate=0.0, srv_serror_rate=0.0, rerror_rate=0.0, srv_rerror_rate=0.0, same_srv_rate=1.0, diff_srv_rate=0.0, srv_diff_host_rate=0.0, dst_host_count=9.0, dst_host_srv_count=9.0, dst_host_same_srv_rate=1.0, dst_host_diff_srv_rate=0.0, dst_host_same_src_port_rate=0.11, dst_host_srv_diff_host_rate=0.0, dst_host_serror_rate=0.0, dst_host_srv_serror_rate=0.0, dst_host_rerror_rate=0.0, dst_host_srv_rerror_rate=0.0, target=u'normal', protocol_type_cat=1.0, service_cat=2.0, flag_cat=0.0, target_cat=2.0)
生成的数据框架看起来非常完整,也很容易理解:所有变量都有名称和值。我们立即注意到分类特征仍然存在,例如,我们有protocol_type(分类)和protocol_type_cat(从分类映射而来的变量的数字版本)。
从数据框中提取一些列就像在一个 SQL 查询中使用SELECT一样简单。现在让我们为所有的数字特征建立一个名称列表:从标题中找到的名称开始,我们移除分类的,并用数字派生的来替换它们。最后,由于我们只需要特征,我们移除了目标变量及其数值导出的等价物:
In:features_header = set(header) \
- set(cols_categorical) \
| set([c + "_cat" for c in cols_categorical]) \
- set(["target", "target_cat"])
features_header = list(features_header)
print features_header
print "Total numerical features:", len(features_header)
Out:['num_access_files', 'src_bytes', 'srv_count', 'num_outbound_cmds', 'rerror_rate', 'urgent', 'protocol_type_cat', 'dst_host_same_srv_rate', 'duration', 'dst_host_diff_srv_rate', 'srv_serror_rate', 'is_host_login', 'wrong_fragment', 'serror_rate', 'num_compromised', 'is_guest_login', 'dst_host_rerror_rate', 'dst_host_srv_serror_rate', 'hot', 'dst_host_srv_count', 'logged_in', 'srv_rerror_rate', 'dst_host_srv_diff_host_rate', 'srv_diff_host_rate', 'dst_host_same_src_port_rate', 'root_shell', 'service_cat', 'su_attempted', 'dst_host_count', 'num_file_creations', 'flag_cat', 'count', 'land', 'same_srv_rate', 'dst_bytes', 'num_shells', 'dst_host_srv_rerror_rate', 'num_root', 'diff_srv_rate', 'num_failed_logins', 'dst_host_serror_rate']
Total numerical features: 41
这里VectorAssembler类来帮助我们构建特征矩阵。我们只需要传递要作为参数选择的列和要在数据框中创建的新列。我们决定将输出列简单命名为features。我们将这种转换应用于训练集和测试集,然后我们只选择我们感兴趣的两个列— features和target_cat:
In:from pyspark.ml.feature import VectorAssembler
assembler = VectorAssembler(
inputCols=features_header,
outputCol="features")
Xy_train = (assembler
.transform(train_num_df)
.select("features", "target_cat"))
Xy_test = (assembler
.transform(test_num_df)
.select("features", "target_cat"))
此外,VectorAssembler的默认行为是产生DenseVectors或SparseVectors。在这种情况下,由于features的向量包含许多零,所以它返回一个稀疏向量。要查看输出内容,我们可以打印第一行。请注意,这是一个动作。因此,作业会在打印结果之前执行:
In:Xy_train.first()
Out:Row(features=SparseVector(41, {1: 181.0, 2: 8.0, 6: 1.0, 7: 1.0, 20: 9.0, 21: 1.0, 25: 0.11, 27: 2.0, 29: 9.0, 31: 8.0, 33: 1.0, 39: 5450.0}), target_cat=2.0)
训练学习者
最后,我们到达了任务的热点部分:训练一个分类器。分类器包含在pyspark.ml.classification包中,对于这个例子,我们使用的是随机森林。
从 Spark 1.6 开始,使用 Python 接口的分类器的广泛列表如下:
- 分类(第
pyspark.ml.classification包):LogisticRegressionDecisionTreeClassifierGBTClassifier(基于决策树分类的梯度增强实现)RandomForestClassifierNaiveBayesMultilayerPerceptronClassifier
请注意,不是所有的都能够处理多类问题,并且可能有不同的参数;请务必查看与使用版本相关的文档。除了分类器,Spark 1.6 中使用 Python 接口实现的其他学习者如下:
- 聚类(即
pyspark.ml.clustering包):- KMeans
- 回归(第
pyspark.ml.regression包):- 加速失效时间存活回归
- 决策树回归器
- 基于回归树的梯度增强回归实现
- 等渗
- 线性回归
- 随机森林回归器
- 推荐人(即
pyspark.ml.recommendation套餐):- 基于交替最小二乘法的协同过滤推荐系统
让我们回到 KDD99 挑战赛的目标。现在是实例化一个随机森林分类器并设置其参数的时候了。要设置的参数有featuresCol(包含特征矩阵的列)、labelCol(包含目标标签的数据框的列)、种子(使实验可复制的随机种子)和maxBins(树的每个节点中用于分裂点的最大箱数)。森林中树木数量的默认值为 20,每棵树最大深度为五级。此外,默认情况下,该分类器在数据框中创建三个输出列:rawPrediction(存储每个可能标签的预测分数)、probability(存储每个标签的可能性)和prediction(最可能标签):
In:from pyspark.ml.classification import RandomForestClassifier
clf = RandomForestClassifier(
labelCol="target_cat", featuresCol="features",
maxBins=100, seed=101)
fit_clf = clf.fit(Xy_train)
即使在这种情况下,训练的分类器也是不同的对象。与之前完全一样,训练好的分类器与带Model后缀的分类器命名相同:
In:print clf
print fit_clf
Out:RandomForestClassifier_4797b2324bc30e97fe01
RandomForestClassificationModel (uid=rfc_44b551671c42) with 20 trees
在训练好的分类器对象上,即RandomForestClassificationModel,可以调用transform方法。现在我们预测训练数据集和测试数据集上的标签,并打印测试数据集的第一行;正如分类器中设置的那样,预测将出现在名为prediction的栏中:
In:Xy_pred_train = fit_clf.transform(Xy_train)
Xy_pred_test = fit_clf.transform(Xy_test)
In:print "First observation after classification stage:"
print Xy_pred_test.first()
Out:First observation after classification stage:
Row(features=SparseVector(41, {1: 105.0, 2: 1.0, 6: 2.0, 7: 1.0, 20: 254.0, 27: 1.0, 29: 255.0, 31: 1.0, 33: 1.0, 35: 0.01, 39: 146.0}), target_cat=2.0, rawPrediction=DenseVector([0.0109, 0.0224, 19.7655, 0.0123, 0.0099, 0.0157, 0.0035, 0.0841, 0.05, 0.0026, 0.007, 0.0052, 0.002, 0.0005, 0.0021, 0.0007, 0.0013, 0.001, 0.0007, 0.0006, 0.0011, 0.0004, 0.0005]), probability=DenseVector([0.0005, 0.0011, 0.9883, 0.0006, 0.0005, 0.0008, 0.0002, 0.0042, 0.0025, 0.0001, 0.0004, 0.0003, 0.0001, 0.0, 0.0001, 0.0, 0.0001, 0.0, 0.0, 0.0, 0.0001, 0.0, 0.0]), prediction=2.0)
评估学习者的表现
任何数据科学任务的下一步是检查学习者在训练和测试集上的表现。对于这个任务,我们将使用 F1 分数,因为它是一个很好的度量标准,结合了精度和召回性能。
评估指标包含在pyspark.ml.evaluation包中;在为数不多的选择中,我们使用一个来评估多类分类器:MulticlassClassificationEvaluator。作为参数,我们提供了度量(精确度、召回率、准确度、f1 分数等)以及包含真实标签和预测标签的列的名称:
In:
from pyspark.ml.evaluation import MulticlassClassificationEvaluator
evaluator = MulticlassClassificationEvaluator(
labelCol="target_cat", predictionCol="prediction",
metricName="f1")
print "F1-score train set:", evaluator.evaluate(Xy_pred_train)
print "F1-score test set:", evaluator.evaluate(Xy_pred_test)
Out:F1-score train set: 0.992356962712
F1-score test set: 0.967512379842
获得的值非常高,并且在训练集和测试集上的性能有很大的差异。
除了多类分类器的评估器之外,回归器的评估器对象(其中度量可以是 MSE、RMSE、R2 或 MAE)和二进制分类器在同一个包中可用。
ML 管道的功率
到目前为止,我们已经一片一片地建立并显示了输出。也可以将所有操作级联,并将其设置为管道的阶段。事实上,我们可以在一个独立的管道中将到目前为止看到的东西(四个标签编码器、向量生成器和分类器)链接在一起,将其放在训练数据集上,最后在测试数据集上使用它来获得预测。
这种操作方式更有效,但你会失去逐步分析的探索力。作为数据科学家的读者被建议,只有当他们完全确定内部发生了什么,并且只构建生产模型时,才使用端到端管道。
为了显示管道相当于我们到目前为止所看到的,我们计算测试集上的 F1 分数并打印出来。不出所料,它的价值完全相同:
In:full_stages = preproc_stages + [assembler, clf]
full_pipeline = Pipeline(stages=full_stages)
full_model = full_pipeline.fit(train_df)
predictions = full_model.transform(test_df)
print "F1-score test set:", evaluator.evaluate(predictions)
Out:F1-score test set: 0.967512379842
在运行 IPython Notebook 的驱动程序节点上,我们也可以使用matplotlib库来可视化我们的分析结果。例如,为了显示分类结果的归一化混淆矩阵(由每个类的支持归一化),我们可以创建以下函数:
In:import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline
def plot_confusion_matrix(cm):
cm_normalized = \
cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]
plt.imshow(
cm_normalized, interpolation='nearest', cmap=plt.cm.Blues)
plt.title('Normalized Confusion matrix')
plt.colorbar()
plt.tight_layout()
plt.ylabel('True label')
plt.xlabel('Predicted label')
Spark 能够建立混淆矩阵,但是那个方法在pyspark.mllib包里。为了能够使用这个包中的方法,我们必须使用.rdd方法将数据帧转换成 RDD:
In:from pyspark.mllib.evaluation import MulticlassMetrics
metrics = MulticlassMetrics(
predictions.select("prediction", "target_cat").rdd)
conf_matrix = metrics.confusionMatrix()tArray()
plot_confusion_matrix(conf_matrix)
Out:
手动调谐
虽然 F1 得分接近 0.97,但归一化混淆矩阵显示类别严重不平衡,分类器刚刚学会如何正确分类最受欢迎的类别。为了提高结果,我们可以对每个类重新采样,试图更好地平衡训练数据集。
首先,让我们计算每个类的训练数据集中有多少个案例:
In:
train_composition = train_df.groupBy("target").count().rdd.collectAsMap()
train_composition
Out:
{u'back': 2203,
u'buffer_overflow': 30,
u'ftp_write': 8,
u'guess_passwd': 53,
u'neptune': 107201,
u'nmap': 231,
u'normal': 97278,
u'perl': 3,
...
...
u'warezmaster': 20}
这是强烈失衡的明显证据。我们可以尝试通过过采样稀有的类和过二次采样流行的类来提高性能。
在这个例子中,我们将创建一个训练数据集,其中每个类至少被表示 1000 次,但最多 25000 次。为此,让我们首先创建二次采样/过采样率,并在整个集群中广播,然后对训练数据集的每一行进行 flatMap,以对其进行正确的重新采样:
In:
def set_sample_rate_between_vals(cnt, the_min, the_max):
if the_min <= cnt <= the_max:
# no sampling
return 1
elif cnt < the_min:
# Oversampling: return many times the same observation
return the_min/float(cnt)
else:
# Subsampling: sometime don't return it
return the_max/float(cnt)
sample_rates = {k:set_sample_rate_between_vals(v, 1000, 25000)
for k,v in train_composition.iteritems()}
sample_rates
Out:{u'back': 1,
u'buffer_overflow': 33.333333333333336,
u'ftp_write': 125.0,
u'guess_passwd': 18.867924528301888,
u'neptune': 0.23320677978750198,
u'nmap': 4.329004329004329,
u'normal': 0.2569954152017928,
u'perl': 333.3333333333333,
...
...
u'warezmaster': 50.0}
In:bc_sample_rates = sc.broadcast(sample_rates)
def map_and_sample(el, rates):
rate = rates.value[el['target']]
if rate > 1:
return [el]*int(rate)
else:
import random
return [el] if random.random() < rate else []
sampled_train_df = (train_df
.flatMap(
lambda x: map_and_sample(x, bc_sample_rates))
.toDF()
.cache())
sampled_train_df数据框变量中重新采样的数据集也被缓存;我们将在超参数优化步骤中多次使用它。它应该很容易放入内存,因为行数比原来少:
In:sampled_train_df.count()
Out:97335
为了了解里面有什么,我们可以打印第一行。很快就能打印出价值,不是吗?当然是缓存了!
In:sampled_train_df.first()
Out:Row(duration=0.0, protocol_type=u'tcp', service=u'http', flag=u'SF', src_bytes=217.0, dst_bytes=2032.0, land=0.0, wrong_fragment=0.0, urgent=0.0, hot=0.0, num_failed_logins=0.0, logged_in=1.0, num_compromised=0.0, root_shell=0.0, su_attempted=0.0, num_root=0.0, num_file_creations=0.0, num_shells=0.0, num_access_files=0.0, num_outbound_cmds=0.0, is_host_login=0.0, is_guest_login=0.0, count=6.0, srv_count=6.0, serror_rate=0.0, srv_serror_rate=0.0, rerror_rate=0.0, srv_rerror_rate=0.0, same_srv_rate=1.0, diff_srv_rate=0.0, srv_diff_host_rate=0.0, dst_host_count=49.0, dst_host_srv_count=49.0, dst_host_same_srv_rate=1.0, dst_host_diff_srv_rate=0.0, dst_host_same_src_port_rate=0.02, dst_host_srv_diff_host_rate=0.0, dst_host_serror_rate=0.0, dst_host_srv_serror_rate=0.0, dst_host_rerror_rate=0.0, dst_host_srv_rerror_rate=0.0, target=u'normal')
现在,让我们使用我们创建的管道进行一些预测,并打印这个新解决方案的 F1 分数:
In:full_model = full_pipeline.fit(sampled_train_df)
predictions = full_model.transform(test_df)
print "F1-score test set:", evaluator.evaluate(predictions)
Out:F1-score test set: 0.967413322985
在 50 棵树的分类器上测试。为此,我们可以构建另一个管道(命名为refined_pipeline)并用新的分类器替换最后一个阶段。即使训练集被大幅缩减,表演看起来也是一样的:
In:clf = RandomForestClassifier(
numTrees=50, maxBins=100, seed=101,
labelCol="target_cat", featuresCol="features")
stages = full_pipeline.getStages()[:-1]
stages.append(clf)
refined_pipeline = Pipeline(stages=stages)
refined_model = refined_pipeline.fit(sampled_train_df)
predictions = refined_model.transform(test_df)
print "F1-score test set:", evaluator.evaluate(predictions)
Out:F1-score test set: 0.969943901769
交叉验证
我们可以继续进行手动优化,在穷尽尝试了许多不同的配置后,找到合适的型号。这样做,会导致大量的时间浪费(以及代码的可重用性),并且会过度填充测试数据集。相反,交叉验证是运行超参数优化的正确方法。现在让我们看看 Spark 是如何执行这一关键任务的。
首先,由于训练将被多次使用,我们可以缓存它。因此,让我们在所有转换之后缓存它:
In:pipeline_to_clf = Pipeline(
stages=preproc_stages + [assembler]).fit(sampled_train_df)
train = pipeline_to_clf.transform(sampled_train_df).cache()
test = pipeline_to_clf.transform(test_df)
带有交叉验证的超参数优化的有用类包含在pyspark.ml.tuning包中。两个要素是必不可少的:参数的网格图(可以用ParamGridBuilder构建)和实际的交叉验证程序(由CrossValidator类运行)。
在这个例子中,我们想要设置一些在交叉验证过程中不会改变的分类器参数。与 Scikit-learn 完全一样,它们是在创建分类对象时设置的(在本例中,是列名、种子和最大箱数)。
然后,感谢网格构建器,我们决定了交叉验证算法的每次迭代应该改变哪些参数。在示例中,我们希望检查分类性能,将林中每棵树的最大深度从 3 更改为 12(递增 3),并将林中的树数更改为 20 或 50。
最后,在设置了网格图、我们要测试的分类器和折叠数之后,我们启动交叉验证(使用fit方法)。参数评估器是必不可少的:它会告诉我们在交叉验证后保留哪个模型最好。请注意,该操作可能需要 15-20 分钟才能运行(在引擎盖下, 423=24 车型经过培训和测试):
In:
from pyspark.ml.tuning import ParamGridBuilder, CrossValidator
rf = RandomForestClassifier(
cacheNodeIds=True, seed=101, labelCol="target_cat",
featuresCol="features", maxBins=100)
grid = (ParamGridBuilder()
.addGrid(rf.maxDepth, [3, 6, 9, 12])
.addGrid(rf.numTrees, [20, 50])
.build())
cv = CrossValidator(
estimator=rf, estimatorParamMaps=grid,
evaluator=evaluator, numFolds=3)
cvModel = cv.fit(train)
最后,我们可以使用交叉验证的模型来预测标签,就像我们自己使用管道或分类器一样。在这种情况下,通过交叉验证选择的分类器的性能比前一种情况稍好,并允许我们突破 0.97 的障碍:
In:predictions = cvModel.transform(test)
print "F1-score test set:", evaluator.evaluate(predictions)
Out:F1-score test set: 0.97058134007
此外,通过绘制标准化混淆矩阵,您立即意识到该解决方案能够发现更广泛的攻击,甚至是不太流行的攻击:
In:metrics = MulticlassMetrics(predictions.select(
"prediction", "target_cat").rdd)
conf_matrix = metrics.confusionMa().toArray()
plot_confusion_matrix(conf_matrix)
Out:
最终清理
在这里,我们处于分类任务的末尾。请记住从缓存中删除您使用的所有变量和您创建的临时表:
In:bc_sample_rates.unpersist()
sampled_train_df.unpersist()
train.unpersist()
Spark 记忆清除后,我们可以关闭笔记本。
总结
这是这本书的最后一章。我们已经看到了如何在一群机器上进行大规模的数据科学。Spark 能够通过一个简单的界面,使用集群中的所有节点来训练和测试机器学习算法,非常类似于 Scikit-learn。事实证明,这种解决方案能够处理数十亿字节的信息,为观察子采样和在线学习创造了一个有效的替代方案。
要成为 Spark 和流处理方面的专家,我们强烈建议您阅读本书,掌握 Apache Spark ,迈克·弗兰普顿, Packt Publishing 。
如果你有足够的勇气去切换到 Spark 的主要编程语言 Scala,这本书就是这样一个过渡的最佳选择: Scala for Data Science,Pascal Bugnion,Packt Publishing 。