Spark 秘籍(三)
原文:
zh.annas-archive.org/md5/BF1FAE88E839F4D0A5A0FD250CEC5835译者:飞龙
第九章:使用 MLlib 进行无监督学习
本章将介绍如何使用 MLlib、Spark 的机器学习库进行无监督学习。
本章分为以下几个部分:
-
使用 k-means 进行聚类
-
使用主成分分析进行降维
-
使用奇异值分解进行降维
介绍
以下是维基百科对无监督学习的定义:
"在机器学习中,无监督学习的问题是尝试在未标记的数据中找到隐藏的结构。"
与监督学习相比,我们有标记数据来训练算法,在无监督学习中,我们要求算法自行找到结构。让我们来看下面的样本数据集:
从上图可以看出,数据点形成了两个簇,如下所示:
事实上,聚类是最常见的无监督学习算法类型。
使用 k-means 进行聚类
聚类分析或聚类是将数据分成多个组的过程,使得一组中的数据类似于其他组中的数据。
以下是聚类使用的一些示例:
-
市场细分:将目标市场分成多个细分,以便更好地满足每个细分的需求
-
社交网络分析:通过社交网络网站(如 Facebook)找到社交网络中一致的人群进行广告定位
-
数据中心计算集群:将一组计算机放在一起以提高性能
-
天文数据分析:理解天文数据和事件,如星系形成
-
房地产:根据相似特征识别社区
-
文本分析:将小说或散文等文本文档分成流派
k-means 算法最好通过图像来说明,所以让我们再次看看我们的样本图:
k-means 的第一步是随机选择两个点,称为聚类中心:
k-means 算法是一个迭代算法,分为两个步骤:
-
簇分配步骤:该算法将遍历每个数据点,并根据其距离更近的质心,将其分配给该质心,从而分配给它代表的簇
-
移动质心步骤:该算法将取每个质心并将其移动到簇中数据点的平均值
让我们看看在簇分配后我们的数据是什么样子:
现在让我们将聚类中心移动到簇中数据点的平均值,如下所示:
在这种情况下,一次迭代就足够了,进一步的迭代不会移动聚类中心。对于大多数真实数据,需要多次迭代才能将质心移动到最终位置。
k-means 算法需要输入一定数量的簇。
准备工作
让我们使用加利福尼亚州萨拉托加市的一些不同的住房数据。这次,我们将考虑地块面积和房价:
| 地块面积 | 房价(以千美元计) |
|---|---|
| --- | --- |
| 12839 | 2405 |
| 10000 | 2200 |
| 8040 | 1400 |
| 13104 | 1800 |
| 10000 | 2351 |
| 3049 | 795 |
| 38768 | 2725 |
| 16250 | 2150 |
| 43026 | 2724 |
| 44431 | 2675 |
| 40000 | 2930 |
| 1260 | 870 |
| 15000 | 2210 |
| 10032 | 1145 |
| 12420 | 2419 |
| 69696 | 2750 |
| 12600 | 2035 |
| 10240 | 1150 |
| 876 | 665 |
| 8125 | 1430 |
| 11792 | 1920 |
| 1512 | 1230 |
| 1276 | 975 |
| 67518 | 2400 |
| 9810 | 1725 |
| 6324 | 2300 |
| 12510 | 1700 |
| 15616 | 1915 |
| 15476 | 2278 |
| 13390 | 2497.5 |
| 1158 | 725 |
| 2000 | 870 |
| 2614 | 730 |
| 13433 | 2050 |
| 12500 | 3330 |
| 15750 | 1120 |
| 13996 | 4100 |
| 10450 | 1655 |
| 7500 | 1550 |
| 12125 | 2100 |
| 14500 | 2100 |
| 10000 | 1175 |
| 10019 | 2047.5 |
| 48787 | 3998 |
| 53579 | 2688 |
| 10788 | 2251 |
| 11865 | 1906 |
让我们将这些数据转换为一个名为saratoga.c sv的逗号分隔值(CSV)文件,并将其绘制为散点图:
找到簇的数量是一项棘手的任务。在这里,我们有视觉检查的优势,而对于超平面上的数据(超过三个维度),这是不可用的。让我们粗略地将数据分成四个簇,如下所示:
我们将运行 k-means 算法来做同样的事情,并看看我们的结果有多接近。
如何做…
- 将
sarataga.csv加载到 HDFS:
$ hdfs dfs -put saratoga.csv saratoga.csv
- 启动 Spark shell:
$ spark-shell
- 导入统计和相关类:
scala> import org.apache.spark.mllib.linalg.Vectors
scala> import org.apache.spark.mllib.clustering.KMeans
- 将
saratoga.csv作为 RDD 加载:
scala> val data = sc.textFile("saratoga.csv")
- 将数据转换为密集向量的 RDD:
scala> val parsedData = data.map( line => Vectors.dense(line.split(',').map(_.toDouble)))
- 为四个簇和五次迭代训练模型:
scala> val kmmodel= KMeans.train(parsedData,4,5)
- 将
parsedData收集为本地 scala 集合:
scala> val houses = parsedData.collect
- 预测第 0 个元素的簇:
scala> val prediction = kmmodel.predict(houses(0))
-
现在让我们比较 k-means 与我们单独完成的簇分配。k-means 算法从 0 开始给出簇 ID。一旦你检查数据,你会发现我们给出的 A 到 D 簇 ID 与 k-means 之间的以下映射:A=>3, B=>1, C=>0, D=>2。
-
现在,让我们从图表的不同部分挑选一些数据,并预测它属于哪个簇。
-
让我们看看房屋(18)的数据,占地面积为 876 平方英尺,售价为 665K 美元:
scala> val prediction = kmmodel.predict(houses(18))
resxx: Int = 3
- 现在,看看占地面积为 15,750 平方英尺,价格为 1.12 百万美元的房屋(35)的数据:
scala> val prediction = kmmodel.predict(houses(35))
resxx: Int = 1
- 现在看看房屋(6)的数据,占地面积为 38,768 平方英尺,售价为 2.725 百万美元:
scala> val prediction = kmmodel.predict(houses(6))
resxx: Int = 0
- 现在看看房屋(15)的数据,占地面积为 69,696 平方英尺,售价为 275 万美元:
scala> val prediction = kmmodel.predict(houses(15))
resxx: Int = 2
你可以用更多的数据测试预测能力。让我们进行一些邻域分析,看看这些簇承载着什么含义。簇 3 中的大多数房屋都靠近市中心。簇 2 中的房屋位于多山的地形上。
在这个例子中,我们处理了一组非常小的特征;常识和视觉检查也会导致相同的结论。k-means 算法的美妙之处在于它可以对具有无限数量特征的数据进行聚类。当你有原始数据并想了解数据中的模式时,它是一个很好的工具。
使用主成分分析进行降维
降维是减少维度或特征数量的过程。很多真实数据包含非常多的特征。拥有成千上万个特征并不罕见。现在,我们需要深入研究重要的特征。
降维有几个目的,比如:
-
数据压缩
-
可视化
当维度减少时,它会减少磁盘占用和内存占用。最后但同样重要的是;它可以帮助算法运行得更快。它还可以将高度相关的维度减少到一个维度。
人类只能可视化三个维度,但数据可以拥有更高的维度。可视化可以帮助发现数据中隐藏的模式。降维可以通过将多个特征压缩成一个特征来帮助可视化。
降维最流行的算法是主成分分析(PCA)。
让我们看看以下数据集:
假设目标是将这个二维数据分成一维。做法是找到一条我们可以将这些数据投影到的线。让我们找一条适合将这些数据投影的线:
这是与数据点具有最短投影距离的线。让我们通过从每个数据点到这条投影线的最短线来进一步解释:
另一种看待的方式是,我们必须找到一条线来投影数据,使得数据点到这条线的平方距离之和最小化。这些灰色线段也被称为投影误差。
准备好了
让我们来看看萨拉托加市的房屋数据的三个特征,即房屋大小、地块大小和价格。使用 PCA,我们将房屋大小和地块大小特征合并为一个特征—z。让我们称这个特征为房屋密度。
值得注意的是,并不总是可能赋予新特征以意义。在这种情况下,很容易,因为我们只有两个特征要合并,我们可以用常识来结合这两者的效果。在更实际的情况下,您可能有 1000 个特征要投影到 100 个特征。可能不可能给这 100 个特征中的每一个赋予现实生活中的意义。
在这个练习中,我们将使用 PCA 推导出房屋密度,然后我们将进行线性回归,看看这个密度如何影响房价。
在我们深入 PCA 之前有一个预处理阶段:特征缩放。当两个特征的范围相差很大时,特征缩放就会出现。在这里,房屋大小的范围在 800 平方英尺到 7000 平方英尺之间变化,而地块大小在 800 平方英尺到几英亩之间变化。
为什么我们之前不需要进行特征缩放?答案是我们真的不需要让特征处于一个公平的水平上。梯度下降是另一个特征缩放非常有用的领域。
有不同的特征缩放方法:
-
将特征值除以最大值,这将使每个特征处于
范围内
-
将特征值除以范围,即最大值减最小值
-
通过减去特征值的平均值,然后除以范围
-
通过减去特征值的平均值,然后除以标准差
我们将使用最佳的第四种选择来进行缩放。以下是我们将用于此示例的数据:
| 房屋大小 | 地块大小 | 缩放后的房屋大小 | 缩放后的地块大小 | 房屋价格(以 1000 美元计) |
|---|---|---|---|---|
| 2524 | 12839 | -0.025 | -0.231 | 2405 |
| 2937 | 10000 | 0.323 | -0.4 | 2200 |
| 1778 | 8040 | -0.654 | -0.517 | 1400 |
| 1242 | 13104 | -1.105 | -0.215 | 1800 |
| 2900 | 10000 | 0.291 | -0.4 | 2351 |
| 1218 | 3049 | -1.126 | -0.814 | 795 |
| 2722 | 38768 | 0.142 | 1.312 | 2725 |
| 2553 | 16250 | -0.001 | -0.028 | 2150 |
| 3681 | 43026 | 0.949 | 1.566 | 2724 |
| 3032 | 44431 | 0.403 | 1.649 | 2675 |
| 3437 | 40000 | 0.744 | 1.385 | 2930 |
| 1680 | 1260 | -0.736 | -0.92 | 870 |
| 2260 | 15000 | -0.248 | -0.103 | 2210 |
| 1660 | 10032 | -0.753 | -0.398 | 1145 |
| 3251 | 12420 | 0.587 | -0.256 | 2419 |
| 3039 | 69696 | 0.409 | 3.153 | 2750 |
| 3401 | 12600 | 0.714 | -0.245 | 2035 |
| 1620 | 10240 | -0.787 | -0.386 | 1150 |
| 876 | 876 | -1.414 | -0.943 | 665 |
| 1889 | 8125 | -0.56 | -0.512 | 1430 |
| 4406 | 11792 | 1.56 | -0.294 | 1920 |
| 1885 | 1512 | -0.564 | -0.905 | 1230 |
| 1276 | 1276 | -1.077 | -0.92 | 975 |
| 3053 | 67518 | 0.42 | 3.023 | 2400 |
| 2323 | 9810 | -0.195 | -0.412 | 1725 |
| 3139 | 6324 | 0.493 | -0.619 | 2300 |
| 2293 | 12510 | -0.22 | -0.251 | 1700 |
| 2635 | 15616 | 0.068 | -0.066 | 1915 |
| 2298 | 15476 | -0.216 | -0.074 | 2278 |
| 2656 | 13390 | 0.086 | -0.198 | 2497.5 |
| 1158 | 1158 | -1.176 | -0.927 | 725 |
| 1511 | 2000 | -0.879 | -0.876 | 870 |
| 1252 | 2614 | -1.097 | -0.84 | 730 |
| 2141 | 13433 | -0.348 | -0.196 | 2050 |
| 3565 | 12500 | 0.852 | -0.251 | 3330 |
| 1368 | 15750 | -0.999 | -0.058 | 1120 |
| 5726 | 13996 | 2.672 | -0.162 | 4100 |
| 2563 | 10450 | 0.008 | -0.373 | 1655 |
| 1551 | 7500 | -0.845 | -0.549 | 1550 |
| 1993 | 12125 | -0.473 | -0.274 | 2100 |
| 2555 | 14500 | 0.001 | -0.132 | 2100 |
| 1572 | 10000 | -0.827 | -0.4 | 1175 |
| 2764 | 10019 | 0.177 | -0.399 | 2047.5 |
| 7168 | 48787 | 3.887 | 1.909 | 3998 |
| 4392 | 53579 | 1.548 | 2.194 | 2688 |
| 3096 | 10788 | 0.457 | -0.353 | 2251 |
| 2003 | 11865 | -0.464 | -0.289 | 1906 |
让我们将经过缩放的房屋大小和经过缩放的房价数据保存为scaledhousedata.csv。
如何做到这一点…
- 将
scaledhousedata.csv加载到 HDFS:
$ hdfs dfs -put scaledhousedata.csv scaledhousedata.csv
- 启动 Spark shell:
$ spark-shell
- 导入统计和相关类:
scala> import org.apache.spark.mllib.linalg.Vectors
scala> import org.apache.spark.mllib.linalg.distributed.RowMatrix
- 将
saratoga.csv加载为一个 RDD:
scala> val data = sc.textFile("scaledhousedata.csv")
- 将数据转换为密集向量的 RDD:
scala> val parsedData = data.map( line => Vectors.dense(line.split(',').map(_.toDouble)))
- 从
parsedData创建一个RowMatrix:
scala> val mat = new RowMatrix(parsedData)
- 计算一个主成分:
scala> val pc= mat.computePrincipalComponents(1)
- 将行投影到由主成分张成的线性空间:
scala> val projected = mat.multiply(pc)
- 将投影的
RowMatrix转换回 RDD:
scala> val projectedRDD = projected.rows
- 将
projectedRDD保存回 HDFS:
scala> projectedRDD.saveAsTextFile("phdata")
现在我们将使用这个投影特征,我们决定称之为住房密度,将其与房价绘制在一起,看看是否出现任何新的模式:
- 将 HDFS 目录
phdata下载到本地目录phdata:
scala> hdfs dfs -get phdata phdata
- 修剪数据中的起始和结束括号,并将数据加载到 MS Excel 中,放在房价旁边。
以下是房价与住房密度的图表:
让我们按照以下数据画一些模式:
我们在这里看到了什么模式?从高密度到低密度住房的转移,人们愿意支付高昂的溢价。随着住房密度的降低,这种溢价趋于平稳。例如,人们愿意支付高额溢价,从公寓和联排别墅搬到独栋住宅,但是在一个可比的建成区域内,拥有 3 英亩地块大小的独栋住宅与拥有 2 英亩地块大小的独栋住宅的溢价并不会有太大的不同。
奇异值分解降维
通常,原始维度并不能最好地表示数据。正如我们在 PCA 中看到的,有时可以将数据投影到更少的维度,仍然保留大部分有用的信息。
有时,最好的方法是沿着展现大部分变化的特征对齐维度。这种方法有助于消除不代表数据的维度。
让我们再次看一下下图,它显示了两个维度上的最佳拟合线:
投影线显示了对原始数据的最佳近似,使用了一个维度。如果我们取灰线与黑线相交的点,并隔离黑线,我们将得到原始数据的减少表示,尽可能保留了尽可能多的变化,如下图所示:
让我们画一条垂直于第一投影线的线,如下图所示:
这条线尽可能多地捕捉了原始数据集的第二维度上的变化。它在近似原始数据方面做得不好,因为这个维度本来就变化较少。可以使用这些投影线来生成一组不相关的数据点,这些数据点将显示原始数据中一开始看不到的子分组。
这就是 SVD 的基本思想。将高维度、高变异性的数据点集合减少到一个更低维度的空间,更清晰地展现原始数据的结构,并按照变化最大到最小的顺序排列。SVD 非常有用的地方,尤其是对于 NLP 应用,是可以简单地忽略某个阈值以下的变化,从而大幅减少原始数据,确保保留原始关系的兴趣。
现在让我们稍微深入理论。SVD 基于线性代数中的一个定理,即一个矩阵 A 可以分解为三个矩阵的乘积——一个正交矩阵 U,一个对角矩阵 S,和一个正交矩阵 V 的转置。我们可以如下展示:
U和V是正交矩阵:
U的列是的正交归一化特征向量,V的列是
的正交归一化特征向量。S是一个对角矩阵,按降序包含来自U或V的特征值的平方根。
准备就绪
让我们看一个术语-文档矩阵的例子。我们将看两篇关于美国总统选举的新闻。以下是两篇文章的链接:
让我们用这两条新闻构建总统候选人矩阵:
让我们把这个矩阵放在一个 CSV 文件中,然后把它放在 HDFS 中。我们将对这个矩阵应用 SVD 并分析结果。
如何做…
- 将
scaledhousedata.csv加载到 HDFS 中:
$ hdfs dfs -put pres.csv scaledhousedata.csv
- 启动 Spark shell:
$ spark-shell
- 导入统计和相关类:
scala> import org.apache.spark.mllib.linalg.Vectors
scala> import org.apache.spark.mllib.linalg.distributed.RowMatrix
- 将
pres.csv加载为 RDD:
scala> val data = sc.textFile("pres.csv")
- 将数据转换为密集向量的 RDD:
scala> val parsedData = data.map( line => Vectors.dense(line.split(',').map(_.toDouble)))
- 从
parsedData创建RowMatrix:
scala> val mat = new RowMatrix(parsedData)
- 计算
svd:
scala> val svd = mat.computeSVD(2,true)
- 计算
U因子(特征向量):
scala> val U = svd.U
- 计算奇异值(特征值)矩阵:
scala> val s = svd.s
- 计算
V因子(特征向量):
scala> val s = svd.s
如果你看S,你会意识到它给 Npr 文章的评分比 Fox 文章高得多。
第十章:推荐系统
在本章中,我们将介绍以下内容:
-
使用显式反馈的协同过滤
-
使用隐式反馈的协同过滤
介绍
以下是维基百科对推荐系统的定义:
“推荐系统是信息过滤系统的一个子类,旨在预测用户对物品的‘评分’或‘偏好’。”
推荐系统近年来变得非常受欢迎。亚马逊用它们来推荐书籍,Netflix 用来推荐电影,Google 新闻用来推荐新闻故事。以下是一些推荐的影响的例子(来源:Celma,Lamere,2008):
-
Netflix 上观看的电影有三分之二是推荐的
-
谷歌新闻点击量的 38%是推荐的
-
亚马逊销售额的 35%是推荐的结果
正如我们在前几章中看到的,特征和特征选择在机器学习算法的有效性中起着重要作用。推荐引擎算法会自动发现这些特征,称为潜在特征。简而言之,有一些潜在特征决定了用户喜欢一部电影而不喜欢另一部电影。如果另一个用户具有相应的潜在特征,那么这个人也很可能对电影有相似的口味。
为了更好地理解这一点,让我们看一些样本电影评分:
| 电影 | Rich | Bob | Peter | Chris |
|---|---|---|---|---|
| Titanic | 5 | 3 | 5 | ? |
| GoldenEye | 3 | 2 | 1 | 5 |
| Toy Story | 1 | ? | 2 | 2 |
| Disclosure | 4 | 4 | ? | 4 |
| Ace Ventura | 4 | ? | 4 | ? |
我们的目标是预测用?符号表示的缺失条目。让我们看看是否能找到一些与电影相关的特征。首先,您将查看电影类型,如下所示:
| 电影 | 类型 |
|---|---|
| Titanic | 动作,爱情 |
| GoldenEye | 动作,冒险,惊悚 |
| Toy Story | 动画,儿童,喜剧 |
| Disclosure | 戏剧,惊悚 |
| Ace Ventura | 喜剧 |
现在每部电影可以根据每种类型进行评分,评分范围从 0 到 1。例如,GoldenEye不是一部主要的爱情片,所以它可能在爱情方面的评分为 0.1,但在动作方面的评分为 0.98。因此,每部电影可以被表示为一个特征向量。
注意
在本章中,我们将使用grouplens.org/datasets/mo…的 MovieLens 数据集。
InfoObjects 大数据沙箱中加载了 100k 部电影评分。您还可以从 GroupLens 下载 100 万甚至高达 1000 万的评分,以便分析更大的数据集以获得更好的预测。
我们将使用这个数据集中的两个文件:
u.data:这是一个以制表符分隔的电影评分列表,格式如下:
user id | item id | rating | epoch time
由于我们不需要时间戳,我们将从我们的配方数据中将其过滤掉
u.item:这是一个以制表符分隔的电影列表,格式如下:
movie id | movie title | release date | video release date | IMDb URL | unknown | Action | Adventure | Animation | Children's | Comedy | Crime | Documentary | Drama | Fantasy | Film-Noir | Horror | Musical | Mystery | Romance | Sci-Fi | Thriller | War | Western |
本章将介绍如何使用 MLlib 进行推荐,MLlib 是 Spark 的机器学习库。
使用显式反馈的协同过滤
协同过滤是推荐系统中最常用的技术。它有一个有趣的特性——它自己学习特征。因此,在电影评分的情况下,我们不需要提供有关电影是浪漫还是动作的实际人类反馈。
正如我们在介绍部分看到的,电影有一些潜在特征,比如类型,同样用户也有一些潜在特征,比如年龄,性别等。协同过滤不需要它们,并且自己找出潜在特征。
在这个例子中,我们将使用一种名为交替最小二乘法(ALS)的算法。该算法基于少量潜在特征解释电影和用户之间的关联。它使用三个训练参数:秩、迭代次数和 lambda(在本章后面解释)。找出这三个参数的最佳值的最佳方法是尝试不同的值,看哪个值的均方根误差(RMSE)最小。这个误差类似于标准差,但是它是基于模型结果而不是实际数据的。
准备工作
将从 GroupLens 下载的moviedata上传到hdfs中的moviedata文件夹:
$ hdfs dfs -put moviedata moviedata
我们将向这个数据库添加一些个性化评分,以便测试推荐的准确性。
你可以查看u.item来挑选一些电影并对其进行评分。以下是我选择的一些电影,以及我的评分。随意选择你想评分的电影并提供你自己的评分。
| 电影 ID | 电影名称 | 评分(1-5) |
|---|---|---|
| 313 | 泰坦尼克号 | 5 |
| 2 | 黄金眼 | 3 |
| 1 | 玩具总动员 | 1 |
| 43 | 揭秘 | 4 |
| 67 | 玩具总动员 | 4 |
| 82 | 侏罗纪公园 | 5 |
| 96 | 终结者 2 | 5 |
| 121 | 独立日 | 4 |
| 148 | 鬼与黑暗 | 4 |
最高的用户 ID 是 943,所以我们将把新用户添加为 944。让我们创建一个新的逗号分隔的文件p.data,其中包含以下数据:
944,313,5
944,2,3
944,1,1
944,43,4
944,67,4
944,82,5
944,96,5
944,121,4
944,148,4
如何做…
- 将个性化电影数据上传到
hdfs:
$ hdfs dfs -put p.data p.data
- 导入 ALS 和评分类:
scala> import org.apache.spark.mllib.recommendation.ALS
scala> import org.apache.spark.mllib.recommendation.Rating
- 将评分数据加载到 RDD 中:
scala> val data = sc.textFile("moviedata/u.data")
- 将
val data转换为评分的 RDD:
scala> val ratings = data.map { line =>
val Array(userId, itemId, rating, _) = line.split("\t")
Rating(userId.toInt, itemId.toInt, rating.toDouble)
}
- 将个性化评分数据加载到 RDD 中:
scala> val pdata = sc.textFile("p.data")
- 将数据转换为个性化评分的 RDD:
scala> val pratings = pdata.map { line =>
val Array(userId, itemId, rating) = line.split(",")
Rating(userId.toInt, itemId.toInt, rating.toDouble)
}
- 将评分与个性化评分结合:
scala> val movieratings = ratings.union(pratings)
- 使用秩为 5 和 10 次迭代以及 0.01 作为 lambda 构建 ALS 模型:
scala> val model = ALS.train(movieratings, 10, 10, 0.01)
-
让我们根据这个模型预测我对给定电影的评分会是多少。
-
让我们从原始的终结者开始,电影 ID 为 195:
scala> model.predict(sc.parallelize(Array((944,195)))).collect.foreach(println)
Rating(944,195,4.198642954004738)
由于我给终结者 2评了 5 分,这是一个合理的预测。
- 让我们尝试一下鬼,电影 ID 为 402:
scala> model.predict(sc.parallelize(Array((944,402)))).collect.foreach(println)
Rating(944,402,2.982213836456829)
这是一个合理的猜测。
- 让我们尝试一下鬼与黑暗,这是我已经评分的电影,ID 为 148:
scala> model.predict(sc.parallelize(Array((944,402)))).collect.foreach(println)
Rating(944,148,3.8629938805450035)
非常接近的预测,知道我给这部电影评了 4 分。
你可以将更多电影添加到train数据集中。还有 100 万和 1000 万的评分数据集可用,这将进一步完善算法。
使用隐式反馈的协同过滤
有时,可用的反馈不是评分的形式,而是音轨播放、观看的电影等形式。这些数据乍一看可能不如用户的明确评分好,但这更加详尽。
准备工作
我们将使用来自www.kaggle.com/c/msdchallenge/data的百万首歌数据。你需要下载三个文件:
-
kaggle_visible_evaluation_triplets -
kaggle_users.txt -
kaggle_songs.txt
现在执行以下步骤:
- 在
hdfs中创建一个songdata文件夹,并将所有三个文件放在这里:
$ hdfs dfs -mkdir songdata
- 将歌曲数据上传到
hdfs:
$ hdfs dfs -put kaggle_visible_evaluation_triplets.txt songdata/
$ hdfs dfs -put kaggle_users.txt songdata/
$ hdfs dfs -put kaggle_songs.txt songdata/
我们仍然需要做一些预处理。MLlib 中的 ALS 需要用户和产品 ID 都是整数。Kaggle_songs.txt文件有歌曲 ID 和其后的序列号,而Kaggle_users.txt文件没有。我们的目标是用相应的整数序列号替换triplets数据中的userid和songid。为此,请按照以下步骤操作:
- 将
kaggle_songs数据加载为 RDD:
scala> val songs = sc.textFile("songdata/kaggle_songs.txt")
- 将用户数据加载为 RDD:
scala> val users = sc.textFile("songdata/kaggle_users.txt")
- 将三元组(用户、歌曲、播放次数)数据加载为 RDD:
scala> val triplets = sc.textFile("songdata/kaggle_visible_evaluation_triplets.txt")
- 将歌曲数据转换为
PairRDD:
scala> val songIndex = songs.map(_.split("\\W+")).map(v => (v(0),v(1).toInt))
- 收集
songIndex作为 Map:
scala> val songMap = songIndex.collectAsMap
- 将用户数据转换为
PairRDD:
scala> val userIndex = users.zipWithIndex.map( t => (t._1,t._2.toInt))
- 收集
userIndex作为 Map:
scala> val userMap = userIndex.collectAsMap
我们需要songMap和userMap来替换三元组中的userId和songId。Spark 会根据需要自动在集群上提供这两个映射。这样做效果很好,但每次需要发送到集群时都很昂贵。
更好的方法是使用 Spark 的一个特性叫做broadcast变量。broadcast变量允许 Spark 作业在每台机器上保留一个只读副本的变量缓存,而不是在每个任务中传输一个副本。Spark 使用高效的广播算法来分发广播变量,因此网络上的通信成本可以忽略不计。
正如你可以猜到的,songMap和userMap都是很好的候选对象,可以包装在broadcast变量周围。执行以下步骤:
- 广播
userMap:
scala> val broadcastUserMap = sc.broadcast(userMap)
- 广播
songMap:
scala> val broadcastSongMap = sc.broadcast(songMap)
- 将
triplet转换为数组:
scala> val tripArray = triplets.map(_.split("\\W+"))
- 导入评分:
scala> import org.apache.spark.mllib.recommendation.Rating
- 将
triplet数组转换为评分对象的 RDD:
scala> val ratings = tripArray.map { case Array(user, song, plays) =>
val userId = broadcastUserMap.value.getOrElse(user, 0)
val songId = broadcastUserMap.value.getOrElse(song, 0)
Rating(userId, songId, plays.toDouble)
}
现在,我们的数据已经准备好进行建模和预测。
如何做…
- 导入 ALS:
scala> import org.apache.spark.mllib.recommendation.ALS
- 使用 ALS 构建一个具有 rank 10 和 10 次迭代的模型:
scala> val model = ALS.trainImplicit(ratings, 10, 10)
- 从三元组中提取用户和歌曲元组:
scala> val usersSongs = ratings.map( r => (r.user, r.product) )
- 为用户和歌曲元组做出预测:
scala> val predictions = model.predict(usersSongs)
它是如何工作的…
我们的模型需要四个参数才能工作,如下所示:
| 参数名称 | 描述 |
|---|---|
| Rank | 模型中的潜在特征数 |
| Iterations | 用于运行此因子分解的迭代次数 |
| Lambda | 过拟合参数 |
| Alpha | 观察交互的相对权重 |
正如你在梯度下降的情况下看到的,这些参数需要手动设置。我们可以尝试不同的值,但最好的值是 rank=50,iterations=30,lambda=0.00001,alpha=40。
还有更多…
快速测试不同参数的一种方法是在 Amazon EC2 上生成一个 Spark 集群。这样可以灵活地选择一个强大的实例来快速测试这些参数。我已经创建了一个名为com.infoobjects.songdata的公共 s3 存储桶,以便将数据传输到 Spark。
以下是您需要遵循的步骤,从 S3 加载数据并运行 ALS:
sc.hadoopConfiguration.set("fs.s3n.awsAccessKeyId", "<your access key>")
sc.hadoopConfiguration.set("fs.s3n.awsSecretAccessKey","<your secret key>")
val songs = sc.textFile("s3n://com.infoobjects.songdata/kaggle_songs.txt")
val users = sc.textFile("s3n://com.infoobjects.songdata/kaggle_users.txt")
val triplets = sc.textFile("s3n://com.infoobjects.songdata/kaggle_visible_evaluation_triplets.txt")
val songIndex = songs.map(_.split("\\W+")).map(v => (v(0),v(1).toInt))
val songMap = songIndex.collectAsMap
val userIndex = users.zipWithIndex.map( t => (t._1,t._2.toInt))
val userMap = userIndex.collectAsMap
val broadcastUserMap = sc.broadcast(userMap)
val broadcastSongMap = sc.broadcast(songMap)
val tripArray = triplets.map(_.split("\\W+"))
import org.apache.spark.mllib.recommendation.Rating
val ratings = tripArray.map{ v =>
val userId: Int = broadcastUserMap.value.get(v(0)).fold(0)(num => num)
val songId: Int = broadcastSongMap.value.get(v(1)).fold(0)(num => num)
Rating(userId,songId,v(2).toDouble)
}
import org.apache.spark.mllib.recommendation.ALS
val model = ALS.trainImplicit(ratings, 50, 30, 0.000001, 40)
val usersSongs = ratings.map( r => (r.user, r.product) )
val predictions =model.predict(usersSongs)
这些是在usersSongs矩阵上做出的预测。
第十一章:使用 GraphX 进行图处理
本章将介绍如何使用 GraphX 进行图处理,即 Spark 的图处理库。
本章分为以下几个部分:
-
图的基本操作
-
使用 PageRank
-
查找连接的组件
-
执行邻域聚合
介绍
图分析在我们的生活中比我们想象的更常见。以最常见的例子为例,当我们要求 GPS 找到到达目的地的最短路径时,它使用图处理算法。
让我们从理解图开始。图是顶点集合的表示,其中一些顶点对由边连接。当这些边从一个方向移动到另一个方向时,称为有向图或有向图。
GraphX 是用于图处理的 Spark API。它提供了一个围绕 RDD 的包装器,称为弹性分布式属性图。属性图是一个具有属性附加到每个顶点和边的有向多重图。
有两种类型的图——有向图(有向图)和常规图。有向图具有沿一个方向运行的边,例如,从顶点 A 到顶点 B。Twitter 的关注者是有向图的一个很好的例子。如果约翰是大卫的 Twitter 关注者,这并不意味着大卫是约翰的关注者。另一方面,Facebook 是常规图的一个很好的例子。如果约翰是大卫的 Facebook 朋友,大卫也是约翰的 Facebook 朋友。
多重图是允许具有多个边(也称为平行边)的图。由于 GraphX 中的每条边都有属性,因此每条边都有自己的标识。
传统上,对于分布式图处理,有两种类型的系统:
-
数据并行
-
图并行
GraphX 旨在将两者结合在一个系统中。GraphX API 使用户能够在不移动数据的情况下将数据同时视为图和集合(RDD)。
图的基本操作
在这个示例中,我们将学习如何创建图并对其进行基本操作。
准备工作
作为一个起始示例,我们将有三个顶点,分别代表加利福尼亚州的三个城市的市中心——圣克拉拉、弗里蒙特和旧金山。以下是这些城市之间的距离:
| 源 | 目的地 | 距离(英里) |
|---|---|---|
| 圣克拉拉,加利福尼亚 | 弗里蒙特,加利福尼亚 | 20 |
| 弗里蒙特,加利福尼亚 | 旧金山,加利福尼亚 | 44 |
| 旧金山,加利福尼亚 | 圣克拉拉,加利福尼亚 | 53 |
如何做…
- 导入与 GraphX 相关的类:
scala> import org.apache.spark.graphx._
scala> import org.apache.spark.rdd.RDD
- 将顶点数据加载到数组中:
scala> val vertices = Array((1L, ("Santa Clara","CA")),(2L, ("Fremont","CA")),(3L, ("San Francisco","CA")))
- 将顶点数组加载到顶点的 RDD 中:
scala> val vrdd = sc.parallelize(vertices)
- 将边数据加载到数组中:
scala> val edges = Array(Edge(1L,2L,20),Edge(2L,3L,44),Edge(3L,1L,53))
- 将数据加载到边的 RDD 中:
scala> val erdd = sc.parallelize(edges)
- 创建图:
scala> val graph = Graph(vrdd,erdd)
- 打印图的所有顶点:
scala> graph.vertices.collect.foreach(println)
- 打印图的所有边:
scala> graph.edges.collect.foreach(println)
- 打印边的三元组;通过向边添加源和目的地属性来创建三元组:
scala> graph.triplets.collect.foreach(println)
- 图的入度是它具有的内向边的数量。打印每个顶点的入度(作为
VertexRDD[Int]):
scala> graph.inDegrees
使用 PageRank
PageRank 衡量了图中每个顶点的重要性。PageRank 是由谷歌的创始人发起的,他们使用了这样一个理论,即互联网上最重要的页面是链接到它们的链接最多的页面。PageRank 还考虑了指向目标页面的页面的重要性。因此,如果给定的网页从排名较高的页面接收到传入链接,它将排名较高。
准备工作
我们将使用维基百科页面链接数据来计算页面排名。维基百科以数据库转储的形式发布其数据。我们将使用来自haselgrove.id.au/wikipedia.htm的链接数据,该数据以两个文件的形式存在:
-
links-simple-sorted.txt -
titles-sorted.txt
我已经将它们都放在了 Amazon S3 上,路径为s3n://com.infoobjects.wiki/links和s3n://com.infoobjects.wiki/nodes。由于数据量较大,建议您在 Amazon EC2 或本地集群上运行。沙箱可能会非常慢。
您可以使用以下命令将文件加载到hdfs中:
$ hdfs dfs -mkdir wiki
$ hdfs dfs -put links-simple-sorted.txt wiki/links.txt
$ hdfs dfs -put titles-sorted.txt wiki/nodes.txt
如何做…
- 导入与 GraphX 相关的类:
scala> import org.apache.spark.graphx._
- 从
hdfs加载边缘,使用 20 个分区:
scala> val edgesFile = sc.textFile("wiki/links.txt",20)
或者,从 Amazon S3 加载边缘:
scala> val edgesFile = sc.textFile("s3n:// com.infoobjects.wiki/links",20)
注意
links文件以“源链接:link1 link2…”的格式包含链接。
- 展平并将其转换为“link1,link2”格式,然后将其转换为
Edge对象的 RDD:
scala> val edges = edgesFile.flatMap { line =>
val links = line.split("\\W+")
val from = links(0)
val to = links.tail
for ( link <- to) yield (from,link)
}.map( e => Edge(e._1.toLong,e._2.toLong,1))
- 从
hdfs加载顶点,使用 20 个分区:
scala> val verticesFile = sc.textFile("wiki/nodes.txt",20)
- 或者,从 Amazon S3 加载边缘:
scala> val verticesFile = sc.textFile("s3n:// com.infoobjects.wiki/nodes",20)
- 为顶点提供索引,然后交换它以使其成为(索引,标题)格式:
scala> val vertices = verticesFile.zipWithIndex.map(_.swap)
- 创建
graph对象:
scala> val graph = Graph(vertices,edges)
- 运行 PageRank 并获取顶点:
scala> val ranks = graph.pageRank(0.001).vertices
- 由于排名是以(顶点 ID,pagerank)格式,因此交换它以使其成为(pagerank,顶点 ID)格式:
scala> val swappedRanks = ranks.map(_.swap)
- 排序以首先获取排名最高的页面:
scala> val sortedRanks = swappedRanks.sortByKey(false)
- 获取排名最高的页面:
scala> val highest = sortedRanks.first
- 前面的命令给出了顶点 ID,您仍然需要查找以查看具有排名的实际标题。让我们进行连接:
scala> val join = sortedRanks.join(vertices)
- 在将格式从(顶点 ID,(页面排名,标题))转换为(页面排名,(顶点 ID,标题))格式后,再次对连接的 RDD 进行排序:
scala> val final = join.map ( v => (v._2._1, (v._1,v._2._2))).sortByKey(false)
- 打印排名前五的页面
scala> final.take(5).collect.foreach(println)
这是输出应该是什么样子的:
(12406.054646736622,(5302153,United_States'_Country_Reports_on_Human_Rights_Practices))
(7925.094429748747,(84707,2007,_Canada_budget)) (7635.6564216408515,(88822,2008,_Madrid_plane_crash)) (7041.479913258444,(1921890,Geographic_coordinates)) (5675.169862343964,(5300058,United_Kingdom's))
查找连接的组件
连接的组件是原始图的子图(其顶点是原始图的顶点集的子集,其边是原始图的边集的子集),其中任何两个顶点都通过边或一系列边连接到彼此。
理解它的一种简单方法是看一下夏威夷的道路网络图。这个州有许多岛屿,它们之间没有通过道路连接。在每个岛屿内,大多数道路将相互连接。找到连接的组件的目标是找到这些集群。
连接的组件算法使用其最低编号的顶点的 ID 标记图的每个连接组件。
准备好了
我们将在这里为我们知道的集群构建一个小图,并使用连接的组件来对它们进行分隔。让我们看看以下数据:
| 追随者 | 跟随者 |
|---|---|
| 约翰 | 帕特 |
| 帕特 | 戴夫 |
| 加里 | 克里斯 |
| 克里斯 | 比尔 |
前面的数据是一个简单的数据,有六个顶点和两个集群。让我们将这些数据放在两个文件的形式中:nodes.csv和edges.csv。
以下是nodes.csv的内容:
1,John
2,Pat
3,Dave
4,Gary
5,Chris
6,Bill
以下是edges.csv的内容:
1,2,follows
2,3,follows
4,5,follows
5,6,follows
我们应该期望连接组件算法识别出两个集群,第一个由(1,约翰)标识,第二个由(4,加里)标识。
您可以使用以下命令将文件加载到hdfs中:
$ hdfs dfs -mkdir data/cc
$ hdfs dfs -put nodes.csv data/cc/nodes.csv
$ hdfs dfs -put edges.csv data/cc/edges.csv
如何做…
- 加载 Spark shell:
$ spark-shell
- 导入与 GraphX 相关的类:
scala> import org.apache.spark.graphx._
- 从
hdfs加载边缘:
scala> val edgesFile = sc.textFile("hdfs://localhost:9000/user/hduser/data/cc/edges.csv")
- 将
edgesFileRDD 转换为边的 RDD:
scala> val edges = edgesFile.map(_.split(",")).map(e => Edge(e(0).toLong,e(1).toLong,e(2)))
- 从
hdfs加载顶点:
scala> val verticesFile = sc.textFile("hdfs://localhost:9000/user/hduser/data/cc/nodes.csv")
- 映射顶点:
scala> val vertices = verticesFile.map(_.split(",")).map( e => (e(0).toLong,e(1)))
- 创建
graph对象:
scala> val graph = Graph(vertices,edges)
- 计算连接的组件:
scala> val cc = graph.connectedComponents
- 找到连接组件的顶点(这是一个子图):
scala> val ccVertices = cc.vertices
- 打印
ccVertices:
scala> ccVertices.collect.foreach(println)
如您在输出中所见,顶点 1,2,3 指向 1,而 4,5,6 指向 4。这两个都是它们各自集群中索引最低的顶点。
执行邻域聚合
GraphX 通过隔离每个顶点及其邻居来进行大部分计算。这使得在分布式系统上处理大规模图数据变得更加容易。这使得邻域操作非常重要。GraphX 有一种机制可以在每个邻域级别进行,即aggregateMessages方法。它分两步进行:
-
在第一步(方法的第一个函数)中,消息被发送到目标顶点或源顶点(类似于 MapReduce 中的 Map 函数)。
-
在第二步(方法的第二个函数)中,对这些消息进行聚合(类似于 MapReduce 中的 Reduce 函数)。
准备好了
让我们构建一个追随者的小数据集:
| 追随者 | 跟随者 |
|---|---|
| 约翰 | 巴拉克 |
| 帕特 | 巴拉克 |
| 加里 | 巴拉克 |
| 克里斯 | 米特 |
| 罗布 | 米特 |
我们的目标是找出每个节点有多少关注者。让我们以两个文件的形式加载这些数据:nodes.csv和edges.csv。
以下是nodes.csv的内容:
1,Barack
2,John
3,Pat
4,Gary
5,Mitt
6,Chris
7,Rob
以下是edges.csv的内容:
2,1,follows
3,1,follows
4,1,follows
6,5,follows
7,5,follows
您可以使用以下命令将文件加载到hdfs:
$ hdfs dfs -mkdir data/na
$ hdfs dfs -put nodes.csv data/na/nodes.csv
$ hdfs dfs -put edges.csv data/na/edges.csv
如何做…
- 加载 Spark shell:
$ spark-shell
- 导入与 GraphX 相关的类:
scala> import org.apache.spark.graphx._
- 从
hdfs加载边:
scala> val edgesFile = sc.textFile("hdfs://localhost:9000/user/hduser/data/na/edges.csv")
- 将边转换为边的 RDD:
scala> val edges = edgesFile.map(_.split(",")).map(e => Edge(e(0).toLong,e(1).toLong,e(2)))
- 从
hdfs加载顶点:
scala> val verticesFile = sc.textFile("hdfs://localhost:9000/user/hduser/data/cc/nodes.csv")
- 映射顶点:
scala> val vertices = verticesFile.map(_.split(",")).map( e => (e(0).toLong,e(1)))
- 创建
graph对象:
scala> val graph = Graph(vertices,edges)
- 通过向关注者发送消息,消息中包含每个关注者的关注者数量,即 1,然后添加关注者数量来进行邻域聚合:
scala> val followerCount = graph.aggregateMessages(Int), (a, b) => (a+b))
- 以(被关注者,关注者数量)的形式打印
followerCount:
scala> followerCount.collect.foreach(println)
您应该获得类似以下的输出:
(1,3)
(5,2)
第十二章:优化和性能调优
本章涵盖了在使用 Spark 时的各种优化和性能调优最佳实践。
本章分为以下几个配方:
-
优化内存
-
使用压缩以提高性能
-
使用序列化以提高性能
-
优化垃圾收集
-
优化并行级别
-
理解优化的未来-项目钨
介绍
在研究各种优化 Spark 的方法之前,最好先了解 Spark 的内部情况。到目前为止,我们已经在较高级别上看待了 Spark,重点是各种库提供的功能。
让我们重新定义一个 RDD。从外部来看,RDD 是一个分布式的不可变对象集合。在内部,它由以下五个部分组成:
-
一组分区(
rdd.getPartitions) -
对父 RDD 的依赖列表(
rdd.dependencies) -
计算分区的函数,给定其父级
-
分区器(可选)(
rdd.partitioner) -
每个分区的首选位置(可选)(
rdd.preferredLocations)
前三个是 RDD 重新计算所需的,以防数据丢失。当组合在一起时,称为血统。最后两个部分是优化。
一组分区是数据如何分布到节点的。在 HDFS 的情况下,这意味着InputSplits,它们大多与块相同(除非记录跨越块边界;在这种情况下,它将比块稍大)。
让我们重新审视我们的wordCount示例,以了解这五个部分。这是wordCount在数据集级别视图下的 RDD 图的样子:
基本上,流程如下:
- 将
words文件夹加载为 RDD:
scala> val words = sc.textFile("hdfs://localhost:9000/user/hduser/words")
以下是words RDD 的五个部分:
| 分区 | 每个 hdfs 输入拆分/块一个分区(org.apache.spark.rdd.HadoopPartition) |
|---|---|
| 依赖 | 无 |
| 计算函数 | 读取块 |
| 首选位置 | hdfs 块位置 |
| 分区器 | 无 |
- 将
wordsRDD 中的单词标记化,每个单词占一行:
scala> val wordsFlatMap = words.flatMap(_.split("\\W+"))
以下是wordsFlatMap RDD 的五个部分:
| 分区 | 与父 RDD 相同,即words(org.apache.spark.rdd.HadoopPartition) |
|---|---|
| 依赖 | 与父 RDD 相同,即words(org.apache.spark.OneToOneDependency) |
| 计算函数 | 计算父级并拆分每个元素并展平结果 |
| 首选位置 | 询问父母 |
| 分区器 | 无 |
- 将
wordsFlatMapRDD 中的每个单词转换为(单词,1)元组:
scala> val wordsMap = wordsFlatMap.map( w => (w,1))
以下是wordsMap RDD 的五个部分:
| 分区 | 与父 RDD 相同,即 wordsFlatMap(org.apache.spark.rdd.HadoopPartition) |
|---|---|
| 依赖 | 与父 RDD 相同,即 wordsFlatMap(org.apache.spark.OneToOneDependency) |
| 计算函数 | 计算父级并将其映射到 PairRDD |
| 首选位置 | 询问父母 |
| 分区器 | 无 |
- 将给定键的所有值减少并求和:
scala> val wordCount = wordsMap.reduceByKey(_+_)
以下是wordCount RDD 的五个部分:
| 分区 | 每个 reduce 任务一个(org.apache.spark.rdd.ShuffledRDDPartition) |
|---|---|
| 依赖 | 每个父级的 Shuffle 依赖(org.apache.spark.ShuffleDependency) |
| 计算函数 | 对洗牌数据进行加法 |
| 首选位置 | 无 |
| 分区器 | HashPartitioner(org.apache.spark.HashPartitioner) |
这是wordCount在分区级别视图下的 RDD 图的样子:
优化内存
Spark 是一个复杂的分布式计算框架,有许多组成部分。各种集群资源,如内存、CPU 和网络带宽,可能在各个点成为瓶颈。由于 Spark 是一个内存计算框架,内存的影响最大。
另一个问题是,Spark 应用程序通常使用大量内存,有时超过 100GB。这种内存使用量在传统的 Java 应用程序中并不常见。
在 Spark 中,有两个地方需要进行内存优化,即在驱动程序和执行程序级别。
您可以使用以下命令来设置驱动程序内存:
- Spark shell:
$ spark-shell --drive-memory 4g
- Spark 提交:
$ spark-submit --drive-memory 4g
您可以使用以下命令来设置执行程序内存:
- Spark shell:
$ spark-shell --executor-memory 4g
- Spark 提交:
$ spark-submit --executor-memory 4g
要理解内存优化,了解 Java 中内存管理的工作原理是一个好主意。对象驻留在 Java 堆中。堆在 JVM 启动时创建,并且可以根据需要调整大小(基于配置中分配的最小和最大大小,即-Xms和-Xmx)。
堆被分为两个空间或代:年轻空间和老年空间。年轻空间用于分配新对象。年轻空间包括一个称为伊甸园的区域和两个较小的幸存者空间。当幼儿园变满时,通过运行称为年轻收集的特殊过程来收集垃圾,其中所有已经存在足够长时间的对象都被提升到老年空间。当老年空间变满时,通过运行称为老年收集的过程来在那里收集垃圾。
幼儿园背后的逻辑是,大多数对象的寿命非常短。年轻收集旨在快速找到新分配的对象并将它们移动到老年空间。
JVM 使用标记和清除算法进行垃圾回收。标记和清除收集包括两个阶段。
在标记阶段,所有具有活动引用的对象都被标记为活动的,其余的被假定为垃圾收集的候选对象。在清除阶段,垃圾收集候选对象占用的空间被添加到空闲列表中,即它们可以分配给新对象。
标记和清除有两个改进。一个是并发标记和清除(CMS),另一个是并行标记和清除。CMS 专注于较低的延迟,而后者专注于更高的吞吐量。这两种策略都有性能权衡。CMS 不进行压缩,而并行垃圾收集器(GC)执行整个堆的压缩,这会导致暂停时间。作为经验法则,对于实时流处理,应该使用 CMS,否则使用并行 GC。
如果您希望同时具有低延迟和高吞吐量,Java 1.7 更新 4 之后还有另一个选项,称为垃圾优先 GC(G1)。G1 是一种服务器式垃圾收集器,主要用于具有大内存的多核机器。它计划作为 CMS 的长期替代品。因此,为了修改我们的经验法则,如果您使用 Java 7 及以上版本,只需使用 G1。
G1 将堆分成一组大小相等的区域,每个区域都是虚拟内存的连续范围。每个区域被分配了一个角色,如伊甸园、幸存者和老年。G1 执行并发全局标记阶段,以确定整个堆中对象的活动引用。标记阶段结束后,G1 知道哪些区域大部分是空的。它首先在这些区域中进行收集,从而释放更多的内存。
G1 选择的用于垃圾收集的区域使用疏散进行垃圾收集。G1 将对象从堆的一个或多个区域复制到堆上的单个区域,并且它既压缩又释放内存。这种疏散是在多个核心上并行执行的,以减少暂停时间并增加吞吐量。因此,每次垃圾收集循环都会减少碎片化,同时在用户定义的暂停时间内工作。
在 Java 中内存优化有三个方面:
-
内存占用
-
访问内存中的对象的成本
-
垃圾收集的成本
一般来说,Java 对象访问速度快,但占用的空间比其中的实际数据多得多。
使用压缩来提高性能
数据压缩涉及使用比原始表示更少的位对信息进行编码。压缩在大数据技术中发挥着重要作用。它使数据的存储和传输更加高效。
当数据经过压缩时,它变得更小,因此磁盘 I/O 和网络 I/O 都变得更快。它还节省了存储空间。每种优化都有成本,压缩的成本体现在增加的 CPU 周期上,用于压缩和解压缩数据。
Hadoop 需要将数据分割成块,无论数据是否经过压缩。只有少数压缩格式是可分割的。
大数据加载的两种最流行的压缩格式是 LZO 和 Snappy。 Snappy 不可分割,而 LZO 可以。另一方面,Snappy 是一种更快的格式。
如果压缩格式像 LZO 一样是可分割的,输入文件首先被分割成块,然后进行压缩。由于压缩发生在块级别,因此解压缩可以在块级别以及节点级别进行。
如果压缩格式不可分割,则压缩发生在文件级别,然后将其分割成块。在这种情况下,块必须合并回文件,然后才能进行解压缩,因此无法在节点级别进行解压缩。
对于支持的压缩格式,Spark 将自动部署编解码器进行解压缩,用户无需采取任何操作。
使用序列化来提高性能
序列化在分布式计算中起着重要作用。有两种支持序列化 RDD 的持久性(存储)级别:
-
MEMORY_ONLY_SER:将 RDD 存储为序列化对象。它将为每个分区创建一个字节数组 -
MEMORY_AND_DISK_SER:这类似于MEMORY_ONLY_SER,但会将不适合内存的分区溢出到磁盘
以下是添加适当持久性级别的步骤:
- 启动 Spark shell:
$ spark-shell
- 导入与之相关的
StorageLevel和隐式转换:
scala> import org.apache.spark.storage.StorageLevel._
- 创建一个 RDD:
scala> val words = sc.textFile("words")
- 持久化 RDD:
scala> words.persist(MEMORY_ONLY_SER)
尽管序列化大大减少了内存占用,但由于反序列化而增加了额外的 CPU 周期。
默认情况下,Spark 使用 Java 的序列化。由于 Java 序列化速度较慢,更好的方法是使用Kryo库。 Kryo要快得多,有时甚至比默认值紧凑 10 倍。
如何做到…
您可以通过在SparkConf中进行以下设置来使用Kryo:
- 通过设置
Kryo作为序列化器启动 Spark shell:
$ spark-shell --conf spark.serializer=org.apache.spark.serializer.KryoSerializer
Kryo自动注册大部分核心 Scala 类,但如果您想注册自己的类,可以使用以下命令:
scala> sc.getConf.registerKryoClasses(Array(classOf[com.infoobjects.CustomClass1],classOf[com.infoobjects.CustomClass2])
优化垃圾收集
如果有大量短寿命的 RDD,JVM 垃圾收集可能会成为一个挑战。 JVM 需要检查所有对象以找到需要进行垃圾回收的对象。垃圾收集的成本与 GC 需要检查的对象数量成正比。因此,使用更少的对象和使用更少对象的数据结构(更简单的数据结构,如数组)有助于减少垃圾收集的成本。
序列化在这里也很出色,因为一个字节数组只需要一个对象进行垃圾回收。
默认情况下,Spark 使用 60%的执行器内存来缓存 RDD,其余 40%用于常规对象。有时,您可能不需要 60%的 RDD,并且可以减少此限制,以便为对象创建提供更多空间(减少对 GC 的需求)。
如何做到…
您可以通过启动 Spark shell 并设置内存分数来将 RDD 缓存的内存分配设置为 40%:
$ spark-shell --conf spark.storage.memoryFraction=0.4
优化并行级别
优化并行级别对充分利用集群容量非常重要。在 HDFS 的情况下,这意味着分区的数量与InputSplits的数量相同,这与块的数量大致相同。
在本教程中,我们将介绍优化分区数量的不同方法。
如何做到…
在加载文件到 RDD 时指定分区数量,具体步骤如下:
- 启动 Spark shell:
$ spark-shell
- 使用自定义分区数量作为第二个参数加载 RDD:
scala> sc.textFile("hdfs://localhost:9000/user/hduser/words",10)
另一种方法是通过执行以下步骤更改默认并行度:
- 使用新的默认并行度值启动 Spark shell:
$ spark-shell --conf spark.default.parallelism=10
- 检查默认并行度的值:
scala> sc.defaultParallelism
注
您还可以使用 RDD 方法coalesce(numPartitions)来减少分区数量,其中numPartitions是您希望的最终分区数量。如果您希望数据在网络上重新分配,可以调用 RDD 方法repartition(numPartitions),其中numPartitions是您希望的最终分区数量。
了解优化的未来-项目钨
项目钨从 Spark 1.4 版本开始,旨在将 Spark 更接近裸金属。该项目的目标是大幅提高 Spark 应用程序的内存和 CPU 效率,并推动底层硬件的极限。
在分布式系统中,传统智慧一直是始终优化网络 I/O,因为这一直是最稀缺和最瓶颈的资源。这一趋势在过去几年已经改变。在过去 5 年中,网络带宽已经从每秒 1 千兆位增加到每秒 10 千兆位。
在类似的情况下,磁盘带宽已经从 50MB/s 增加到 500MB/s,SSD 的部署也越来越多。另一方面,CPU 时钟速度在 5 年前是~3GHz,现在仍然是一样的。这使得网络不再是瓶颈,而使 CPU 成为分布式处理中的新瓶颈。
注
另一个增加 CPU 性能负担的趋势是新的压缩数据格式,比如 Parquet。正如我们在本章的前几个示例中看到的,压缩和序列化会导致更多的 CPU 周期。这一趋势也推动了减少 CPU 周期成本的 CPU 优化的需求。
在类似的情况下,让我们看看内存占用。在 Java 中,GC 进行内存管理。GC 在将内存管理从程序员手中拿走并使其透明方面做得很好。为了做到这一点,Java 必须付出很大的开销,这大大增加了内存占用。例如,一个简单的字符串"abcd",在 Java 中应该占用 4 个字节,实际上占用了 48 个字节。
如果我们放弃 GC,像在 C 等低级编程语言中那样手动管理内存会怎样?自 Java 1.7 版本以来,Java 确实提供了一种方法来做到这一点,称为sun.misc.Unsafe。Unsafe 基本上意味着您可以构建长区域的内存而不进行任何安全检查。这是项目钨的第一个特点。
通过利用应用程序语义进行手动内存管理
通过利用应用程序语义进行手动内存管理,如果你不知道自己在做什么,这可能非常危险,但在 Spark 中是一种福音。我们利用数据模式(DataFrames)的知识直接布局内存。这不仅可以摆脱 GC 开销,还可以最小化内存占用。
第二点是将数据存储在 CPU 缓存与内存中。每个人都知道 CPU 缓存很棒,因为从主内存获取数据需要三个周期,而缓存只需要一个周期。这是项目钨的第二个特点。
使用算法和数据结构
算法和数据结构被用来利用内存层次结构,实现更多的缓存感知计算。
CPU 缓存是存储 CPU 下一个需要的数据的小内存池。CPU 有两种类型的缓存:指令缓存和数据缓存。数据缓存按照 L1、L2 和 L3 的层次结构排列:
-
L1 缓存是计算机中最快、最昂贵的缓存。它存储最关键的数据,是 CPU 查找信息的第一个地方。
-
L2 缓存比 L1 稍慢,但仍位于同一处理器芯片上。这是 CPU 查找信息的第二个地方。
-
L3 缓存仍然较慢,但由所有核心共享,例如 DRAM(内存)。
这些可以在以下图表中看到:
第三点是,Java 在字节码生成方面不太擅长,比如表达式求值。如果这种代码生成是手动完成的,效率会更高。代码生成是 Tungsten 项目的第三个特性。
代码生成
这涉及利用现代编译器和 CPU,以便直接在二进制数据上进行高效操作。目前,Tungsten 项目还处于起步阶段,在 1.5 版本中将有更广泛的支持。