一)数据文件说明:
user_artist_data.txt
3 columns: userid artistid playcount
artist_data.txt
2 columns: artistid artist_name
artist_alias.txt
2 columns: badid, goodid
known incorrectly spelt artists and the correct artist id.
you can correct errors in user_artist_data as you read it in using this file
(we're not yet finished merging this data)
二)数据准备:
将文件:artist_alias.txt、artist_data.txt、user_artist_data.txt导入到hdfs:///tmp/data/profiledata目录下面
利用 SparkContext 的 textFile 方法,将数据文
件转换成 String 类型的 RDD:
spark-shell --driver-memory 10g
val rawUserArtistData = sc.textFile("hdfs:///tmp/data/profiledata/user_artist_data.txt")
//计算数据量:24296858
rawUserArtistData.count()
方法 stats()
返回统计信息对象,包括最大值和最小值,数据量
rawUserArtistData.map(_.split(' ')(0).toDouble).stats()
rawUserArtistData.map(_.split(' ')(1).toDouble).stats()
//返回结果如下:
org.apache.spark.util.StatCounter = (count: 24296858, mean: 1947573.265353, stdev: 496000.544975, max: 2443548.000000, min: 90.000000)
现在 artist_data.txt 包含艺术家 ID 和名字,它们用制表符分隔。
这里 span() 用第一个制表符将一行拆分成两部分,接着将第一部分解析为艺术家 ID,剩
余部分作为艺术家的名字(去掉了空白的制表符)。文件里有少量行看起来是非法的:有
些行没有制表符,有些行不小心加入了换行符。这些行会导致 NumberFormatException ,它
们不应该有输出结果
val rawArtistData = sc.textFile("hdfs:///tmp/data/profiledata/artist_data.txt")
val artistByID = rawArtistData.map { line =>
val (id, name) = line.span(_ != ' ')
(id.toInt, name.trim)
}
然而, map() 函数要求对每个输入必须严格返回一个值,因此这里不能用这个函数。另一
种可行的方法是用 filter() 方法删除那些无法解析的行,但这会重复解析逻辑。当需要将
每个元素映射为零个、一个或更多结果时,我们应该使用 flatMap() 函数,因为它将每个
输入对应的零个或多个结果组成的集合简单展开,然后放入到一个更大的 RDD 中。它可
以和 Scala 集合一起使用,也可以和 Scala 的 Option 类一起使用。 Option 代表一个值可以
不存在,有点儿像只有 1 或 0 的一个简单集合,1 对应子类 Some ,0 对应子类 None 。因此
在以下代码中,虽然 flatMap 中的函数本可以简单返回一个空 List ,或一个只有一个元素
的 List ,但使用 Some 和 None 更合理,这种方法简单明了。
val artistByID = rawArtistData.flatMap { line =>
val (id, name) = line.span(_ != ' ')
if (name.isEmpty) {
None
} else {
try {
Some((id.toInt, name.trim))
} catch {
case e: NumberFormatException => None
}
}
}
artist_alias.txt 将拼写错误的艺术家 ID 或非标准的艺术家 ID 映射为艺术家的正规名字。其
中每行有两个 ID,用制表符分隔。这个文件相对较小,有 200 000 个记录。有必要把它转
成 Map 集合的形式,将“不良的”艺术家 ID 映射到“良好的”ID,而不是简单地把它作
为包含艺术家 ID 二元组的 RDD。这里又有一点小问题:由于某种原因有些行没有艺术家
的第一个 ID。这些行将被过滤掉
val rawArtistAlias = sc.textFile("hdfs:///tmp/data/profiledata/artist_alias.txt")
val artistAlias = rawArtistAlias.flatMap { line =>
val tokens = line.split(' ')
if (tokens(0).isEmpty) {
None
} else {
Some((tokens(0).toInt, tokens(1).toInt))
}
}.collectAsMap()
比如,第一条将 ID 6803336 映射为 1000010。接下来我们可以从包含艺术家名字的 RDD
中进行查找:
artistByID.lookup(6803336).head
artistByID.lookup(1000010).head
显然,这条记录将“Aerosmith (unplugged)” 映射为“Aerosmith”。
三)构建第一个模型
如果艺术家存在别名,取得艺术家别名,否则取得原始名字。
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()
最后,我们构建模型:
val model = ALS.trainImplicit(trainData, 10, 5, 0.01, 1.0)
特征向量是一个包含 10 个数值的数组,数
组的打印形式原本是不可读的。代码用 mkString() 把向量翻译成可读的形式,在 Scala 中,
mkString() 方法常用于把集合元素表示成以某种形式分隔的字符串。
model.userFeatures.mapValues(_.mkString(", ")).first()
四)逐个检查推荐结果
应该看看模型给出的艺术家推荐直观上是否合理,我们检查一下用户播放过的艺术家,然
后看看模型向用户推荐的艺术家。具体来看看用户 2093760 的例子。现在我们要提取该用
户收听过的艺术家 ID 并打印他们的名字,这意味着先在输入数据中搜索该用户收听过的
艺术家的 ID,然后用这些 ID 对艺术家集合进行过滤,这样我们就可以获取并按序打印这
些艺术家的名字:
val rawArtistsForUser = rawUserArtistData.map(_.split(' ')).
filter { case Array(user,_,_) => user.toInt == 2093760 }
val existingProducts =
rawArtistsForUser.map { case Array(_,artist,_) => artist.toInt }.
collect().toSet
artistByID.filter { case (id, name) =>
existingProducts.contains(id)
}.values.collect().foreach(println)
...
David Gray
Blackalicious
Jurassic 5
The Saw Doctors
Xzibit
我们可以对该用户作出 5 个推荐:
val recommendations = model.recommendProducts(2093760, 5)
recommendations.foreach(println)
...
Rating(2093760,1300642,0.02833118412903932)
Rating(2093760,2814,0.027832682960168387)
Rating(2093760,1037970,0.02726611004625264)
Rating(2093760,1001819,0.02716011293509426)
Rating(2093760,4605,0.027118271894797333)
结果由 Rating 对象组成,包括用户 ID(重复的)、艺术家 ID 和一个数值。虽然字段名称叫 rating ,但其实不是估计的得分。对这类 ALS 算法,它是一个在 0 到 1 之间的模糊值,
值越大,推荐质量越好。它不是概率,但可以把它理解成对 0/1 值的一个估计,0 表示用
户不喜欢播放艺术家的歌曲,1 表示喜欢播放艺术家的歌曲。
得到所推荐艺术家的 ID 之后,就可以用类似的方法查到艺术家的名字:
val recommendedProductIDs = recommendations.map(_.product).toSet
artistByID.filter { case (id, name) =>
recommendedProductIDs.contains(id)
}.values.collect().foreach(println)
...
Green Day
Linkin Park
Metallica
My Chemical Romance
System of a Down
五)选择超参数
ALS.trainImplicit() 的参数包括以下几个。
• rank = 10
模型的潜在因素的个数,即“用户 - 特征”和“产品 - 特征”矩阵的列数;一般来说,
它也是矩阵的阶。
• iterations = 5
矩阵分解迭代的次数;迭代的次数越多,花费的时间越长,但分解的结果可能会更好。
• lambda = 0.01
标准的过拟合参数;值越大越不容易产生过拟合,但值太大会降低分解的准确度。
• alpha = 1.0
控制矩阵分解时,被观察到的“用户 - 产品”交互相对没被观察到的交互的权重。
可以把 rank 、 lambda 和 alpha 看作为模型的超参数。( iterations 更像是对分解过程使用
的资源的一种约束。)这些值不会体现在 MatrixFactorizationModel 的内部矩阵中,这些矩
阵只是参数,其值由算法选定。而 rank 、 lambda 和 alpha 这几个超参数是构建过程本身的
参数。
刚才列表中给出的超参数值不一定是最优的。如何选择好的超参数值在机器学习中是个普
遍性问题。最基本的方法是尝试不同值的组合并对每个组合评估某个指标,然后挑选指标
值最好的组合。
在下面的示例中,我们尝试了 8 中可能的组合: rank = 10 或 50、 lambda = 1.0 或 0.0001,
以及 alpha = 1.0 或 40.0。这些值当然也是猜的,但它们能够覆盖很大范围的参数值。各
种组合的结果按 AUC 得分从高到底排序:
val evaluations =
for (rank <- Array(10, 50);
lambda <- Array(1.0, 0.0001);
alpha <- Array(1.0, 40.0))
yield {
val model = ALS.trainImplicit(trainData, rank, 10, lambda, alpha)
val auc = areaUnderCurve(cvData, bAllItemIDs, model.predict)
((rank, lambda, alpha), auc)
}
evaluations.sortBy(_._2).reverse.foreach(println)
...
((50,1.0,40.0),0.9776687571356233)
((50,1.0E-4,40.0),0.9767551668703566)
((10,1.0E-4,40.0),0.9761931539712336)
((10,1.0,40.0),0.976154587705189)
((10,1.0,1.0),0.9683921981896727)
((50,1.0,1.0),0.9670901331816745)
((10,1.0E-4,1.0),0.9637196892517722)
((50,1.0E-4,1.0),0.9543377999707536)
有意思的是,参数 alpha 取 40 的时候看起来总是比取 1 表现好(为了满足读者的好奇,顺
便提一下,40 是前面提到的最初 ALS 论文的默认值之一)。这说明了模型在强调用户听过
什么时的表现要比强调用户没听过什么时要好。
lambda 取较大的值看起来结果要稍微好一些。这表明模型有些受过拟合的影响,因此需要
一个较大的 lambda 值以防止过度精确拟合每个用户的稀疏输入数据。
lambda 取较大的值看起来结果要稍微好一些。这表明模型有些受过拟合的影响,因此需要
一个较大的 lambda 值以防止过度精确拟合每个用户的稀疏输入数据
特征值的个数影响不是很明显;在分数最高的组合和分数最低的组合中均出现特征值个
数取 50 的情况,虽然分数绝对值变化也不大。这可能表示正确的特征值个数实际上比 50
大,而特征值个数太小时无论特征值个数是多少区别都不大。
当然我们可以重复上述过程,试试不同的取值范围或试试更多值。这是超参数选择的一种
暴力方式。但是在当今这个世界,这种简单粗暴的方式变得相对可行:集群常常有几 TB
内存,成百上千个核,Spark 之类的框架可以利用并行计算和内存来提高速度。
严格来说,理解超参数的含义其实不是必需的,但知道这些值的典型范围有助于找到一个
合适的参数空间开始搜索,这个空间不宜太大,也不能太小。
六)小结:
ALS 不是唯一的推荐引擎算法。目前它是 Spark MLlib 唯一支持的算法。但是,对于非
隐含数据,MLlib 也支持一种 ALS 的变体,它的用法和 ALS 是一样的,不同之处在于模
型用方法 ALS.train() 构建。它适用于给出评分数据而不是次数数据。比如,如果数据集
是用户对艺术家的打分,值从 1 到 5,那么用这种变体就很合适。不同推荐方法返回的
Rating 对象结果,其中 rating 字段是估计的打分。
源于《Spark高级数据分析》