Spark数据分析之第4课

#音乐推荐和Audioscrobbler数据集
#1. 数据集
http://www-etud.iro.umontreal.ca/~bergstrj/audioscrobbler_data.html
下载具体地址为:
http://www.iro.umontreal.ca/~lisa/datasets/profiledata_06-May-2005.tar.gz


#1.数据
user_artist_data.txt包含141000个用户和160万个艺术家,记录了约2420万条用户播放艺术家歌曲的信息,其中包含播放次数
数据集在artist_data.txt文件中给出了每位艺术家的ID和对应的名字。请注意,记录播放信息时,客户端应用提交的是艺术家的名字。
为了将拼写错误的艺术家ID或ID变体对应到该艺术家的规范ID,数据集提供了artist_alias.txt文件。




#2. 交替最小二乘(ALS)推荐算法
根据这个隐式反馈数据选择一个合适的推荐算法。这个数据集只记录了用户和歌曲之间的交互情况。
我们要找的算法是不需要用户和艺术家的属性信息,这类算法通常称为协同过滤算法。
举个例子,根据两个用户的年龄相同来判断他们可能有相似的爱好,这不叫协同过滤。相反,根据两个用户播放过很多相同的歌曲来判断他们可能都喜欢
某首歌,这才叫协同过滤。




本实例将用到潜在因素模型中的一种模型。潜在因素模型试图通过数量相对少的未被观察到的底层原因,来解释大量用户和产品之间可观察到的交互。
说的更明确一点,本实例使用的是一种矩阵分解模型。
数学上,这些算法将用户和产品数据当成一个大矩阵A,矩阵第i行和第j列上的元素都有值,代表用户i播放过艺术家j的音乐。矩阵A是稀疏的:A中大多数
元素都是0,因为相对于所有可能的用户-艺术家组合,只有很少一部分组合会出现在数据中。
算法将A分解为两个小矩阵X和Y的乘积。矩阵X和矩阵Y非常"瘦",因为A有很多行和列,但X和Y的行很多而列很少(列数用k表示)。这k个列就是潜在因素,用于
解释数据中的交互关系。




由于k的值很小,矩阵分解算法只能是某种近似。




将ALS算法用于隐性数据矩阵分解时,ALS矩阵分解要稍微复杂一点儿。它不是直接分解输入矩阵A,而是分解由0和1组成的矩阵P,当A中元素为正时,P中对应
元素为1,否则为0。A中的具体值后面会以权重的形式反映出来。




#3.准备数据
hdfs dfs -ls /user/ds
-rw-r--r--   1 hdfs supergroup    2932731 2016-05-25 11:49 /user/ds/artist_alias.txt
-rw-r--r--   1 hdfs supergroup   55963575 2016-05-25 11:49 /user/ds/artist_data.txt
-rw-r--r--   1 hdfs supergroup  426761761 2016-05-25 11:50 /user/ds/user_artist_data.txt
构建模型的第一步是了解数据,对数据进行解析或转换,以便在Spark中做分析。
Spark MLlib的ALS算法要求用户和产品的ID必须是数值型,并且是32位非负整数。这意味着大于Integer.MAX_VALUE(2147483647)的ID都是非法的。


scala> val rawUserArtistData = sc.textFile("/user/ds/user_artist_data.txt")
默认情况下,RDD为每个HDFS块生成一个分区,将HDFS块大小设为典型的128MB。由于HDFS文件大小为400多M,所有文件被拆分为4个分区。但是针对ALS这类需要
消耗更多的计算资源,建议减少数据块大小以增加分区个数会更好。减少数据块大小能使Spark处理任务时同时使用的处理器核数更多。
val rawUserArtistData = sc.textFile("/user/ds/user_artist_data.txt",8)  #可以设置为集群处理器总核数
文件的每行包含一个用户ID,一个艺术家ID和播放次数,用空格分隔。
#下面计算用户和艺术家的统计信息
scala> rawUserArtistData.map(_.split(' ')(0).toDouble).stats()
res6: org.apache.spark.util.StatCounter = (count: 24296858, mean: 1947573.265353, stdev: 496000.544975, max: 2443548.000000, min: 90.000000)




scala> rawUserArtistData.map(_.split(' ')(1).toDouble).stats()
res7: org.apache.spark.util.StatCounter = (count: 24296858, mean: 1718704.093757, stdev: 2539389.040171, max: 10794401.000000, min: 1.000000)




可以看出最大的用户ID和艺术家ID分别为2443548和10794401,都远小于2147483647,因此没有必要做进一步处理。




本例中的艺术家名字对应模糊的数值ID,这些信息包含在artist_data.txt中。
现在artist_data.txt包含艺术家ID和名字,它们用制表符分隔。但是直接解析会出错,因为有的格式不对。
scala> val rawArtistData = sc.textFile("/user/ds/artist_data.txt")
rawArtistData: org.apache.spark.rdd.RDD[String] = /user/ds/artist_data.txt MapPartitionsRDD[16] at textFile at <console>:28


val artistByID = rawArtistData.map{line => 
    val (id,name) = line.span(_ != '\t')
    (id.toInt, name.trim)
}
artistByID: org.apache.spark.rdd.RDD[(Int, String)] = MapPartitionsRDD[17] at map at <console>:30




这里使用span()用第一个制表符将一行拆分成两部分,接着将第一部分解析为艺术家ID,剩下的部分作为艺术家的名字(去掉了空白的制表符)。
因为map函数要求对每个输入必须严格返回一个值,因此这里不合适。
使用flatMap可以解决,flatMap中的函数本可以简单返回一个空List,或一个只有一个元素的List,但使用Some和None更合理。
下面是好的解决方法:
val artistByID = rawArtistData.flatMap{line => 
    val (id,name) = line.span(_ != '\t')
    if (name.isEmpty) {
        None
    } else {
        try {
            Some((id.toInt, name.trim))
        } catch {
            case e: NumberFormatException => None
        }
    }
}


artist_alias.txt将拼写错的艺术家ID和非标准的艺术家ID映射为艺术家的正规名字。其中每行有两个ID,用制表符分隔。
有必要将"不良的"艺术家ID映射到"良好的"ID。
注意:数据的某些行没有艺术家的第一个ID,所以需要过滤掉。
val rawArtistAlias = sc.textFile("/user/ds/artist_alias.txt")
val artistAlias = rawArtistAlias.flatMap { line => 
    val tokens = line.split("\t")
    if (tokens(0).isEmpty) {
        None
    } else {
        Some(tokens(0).toInt, tokens(1).toInt)
    }
}.collectAsMap() #collectAsMap 对(K,V)型的RDD数据返回一个单机HashMap。对于重复K的RDD元素,后面的元素覆盖前面的元素。 
artistAlias: scala.collection.Map[Int,Int] = Map(6803336 -> 1000010, 6663187 -> 1992, 2124273 -> 2814, 10412283 -> 1...




我们看到第一条将ID为6803336映射为1000010。我们可以从RDD进行查找:
scala> artistByID.lookup(6803336).head
res15: String = Aerosmith (unplugged)                                           


scala> artistByID.lookup(1000010).head
res16: String = Aerosmith  


可以看出,这条记录将" Aerosmith (unplugged)"映射为"Aerosmith"。


#4 构建第一个模型
经过上面的处理,虽然符合Spark MLlib的ALS算法实现的要求,但我们还需要额外做两个转换。
第一,如果艺术家ID存在一个不同的正规ID,我们要用别名数据集将所有的艺术家ID转换为正规ID。
第二,需要把数据集转换成Rating对象,Rating对象是ALS算法实现对"用户-产品-值"的抽象。
import org.apache.spark.mllib.recommendation._
val bArtistAlias = sc.broadcast(artistAlias)
val  trainData = rawUserArtistData.map { line => 
        val Array(userID,artistID,count) = line.split(' ').map(_.toInt)        
        val finalArtistID = bArtistAlias.value.getOrElse(artistID,artistID)
        Rating(userID,finalArtistID,count)
}.cache


#如果艺术家存在别名,取得艺术家的别名,否则取得原始名字
虽然刚创建的artistAlias是驱动程序本地的一个Map,可以在RDD的map()函数中直接使用它。这是没有问题的,因为artistAlias会随着任务一起被
自动复制。但是它可不小,要消耗15MB内存,就是序列化也得占用几M字节。毕竟每个JVM中有很多任务,所以发送和存储太多副本非常浪费资源。


针对上面的这种情况,我们可以为artistAlias创建一个广播变量。使用广播变量时,Spark对集群中每个executor只发送一个副本,并且在内存里也只保存一个副本。
如果有几千个任务都在executor上并行执行,使用广播变量能节省巨大的网络流量和内存。


#广播变量
Spark执行一个阶段时(Stage),会为待执行函数建立闭包,也就是该阶段所有任务所需信息的二进制形式。这个闭包包括驱动程序里函数引用的所有数据结构。
Spark把这个闭包发送到集群的每个executor上。
当许多任务需要访问同一个数据结构时,我们应该使用广播变量。
好处:
第一,在每个executor上将数据缓存为原始的Java对象,不用为每个任务执行反序列化
第二,在多个作业和阶段之间缓存数据


#最后,我们构建模型
scala> val model = ALS.trainImplicit(trainData,10,5,0.01,1.0)
这样就构建了一个MatrixFactorizationModel模型。这个操作可能要花费几分钟或者更长时间,具体时间取决于所用的集群。
对于每个用户和产品,模型都包含一个有10个值的特征向量。本例中总共超过170万个特征向量。模型用两个不同的RDD,他们分别
表示"用户-特征"和"产品-特征"这两个大型矩阵。
特征向量是一个包含10个数值的数组。


如果想查看某些特征向量,可以使用如下代码,mkString把向量翻译为可读的形式,在Scala中mkString方法常用于把集合元素表示以某种形式分隔的字符串:
scala> model.userFeatures.mapValues(_.mkString(", ")).first()
res11: (Int, String) = (90,-0.07274463027715683, 0.46505239605903625, 0.4246442914009094, 0.017407121136784554, -0.26859962940216064, -1.047317624092102, -0.656421959400177, -0.059907883405685425, -0.046106256544589996, -0.04561980441212654)




trainImplicit()中包含的其他参数都是超参数,他们的值将影响模型的推荐质量。


#逐个检查推荐结果
比如我们看一下 2093760 的例子。
找到用户2093760的行:
scala> val rawArtistsForUser = rawUserArtistData.map(_.split(' ')).filter {case Array(user,_,_) => user.toInt == 2093760}
rawArtistsForUser: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[146] at filter at <console>:26




收集不同的艺术家:
scala> val existingProducts = rawArtistsForUser.map{ case Array(_,artist,_) => artist.toInt }.collect().toSet
existingProducts: scala.collection.immutable.Set[Int] = Set(1255340, 942, 1180, 813, 378)


我们可以将rawArtistsForUser的RDD的关系给列出来:
scala> rawArtistsForUser.toDebugString
res19: String = 
(4) MapPartitionsRDD[143] at filter at <console>:26 []
 |  MapPartitionsRDD[142] at map at <console>:26 []
 |  /user/ds/user_artist_data.txt MapPartitionsRDD[1] at textFile at <console>:21 []
 |  /user/ds/user_artist_data.txt HadoopRDD[0] at textFile at <console>:21 []
 
过滤艺术家,取出艺术家并打印:
scala> artistByID.filter { case (id,name) => existingProducts.contains(id)}.values.collect().foreach(println)
David Gray
Blackalicious
Jurassic 5
The Saw Doctors
Xzibit


用户播放过的艺术家既有大众流行音乐的也有嘻哈风格的。


#下面我们对5个用户做出推荐:
scala> val recommendations = model.recommendProducts(2093760,5)
recommendations: Array[org.apache.spark.mllib.recommendation.Rating] = Array(Rating(2093760,2814,0.0324184709372749), Rating(2093760,1001819,0.03097342837980681), Rating(2093760,1300642,0.030952322282742546), Rating(2093760,1007614,0.03035531332876626), Rating(2093760,4605,0.030150220216114847))


scala> recommendations.foreach(println)
Rating(2093760,2814,0.0324184709372749)
Rating(2093760,1001819,0.03097342837980681)
Rating(2093760,1300642,0.030952322282742546)
Rating(2093760,1007614,0.03035531332876626)
Rating(2093760,4605,0.030150220216114847


结果由Rating对象组成,包括用户ID(重复的),艺术家ID和一个数值。虽然字段名称叫rating,但其实不是估计的得分。
对这类的ALS算法,它是一个在0到1之间的模糊值,值越大,推荐质量越好。它不是概率,但可以把它理解成对0/1值的一个估计,0表示用户不喜欢播放艺术家的歌曲,1表示
喜欢播放艺术家的歌曲。


得到所推荐的艺术家的ID之后,就可以用类似的方法查到艺术家的名字:
scala> val recommendedProductIDs = recommendations.map(_.product).toSet
recommendedProductIDs: scala.collection.immutable.Set[Int] = Set(2814, 1001819, 1300642, 4605, 1007614)


scala> artistByID.filter{ case(id,name) => recommendedProductIDs.contains(id) }.values.collect().foreach(println)
50 Cent
Snoop Dogg
Jay-Z
2Pac
The Game


结果可能不怎么样。


下一课我们将介绍如何提高评价推荐质量。

你可能感兴趣的:(Spark数据分析之第4课)