3-Spark高级数据分析-第三章 音乐推荐和Audioscrobbler数据集

偏好是无法度量的。

相比其他的机器学习算法,推荐引擎的输出更直观,更容易理解。

接下来三章主要讲述Spark中主要的机器学习算法。其中一章围绕推荐引擎展开,主要介绍音乐推荐。在随后的章节中我们先介绍Spark和MLib的实际应用,接着介绍一些机器学习的基本思想。

3.1 数据集

用户和艺术家的关系是通过其他行动隐含提现出来的,例如播放歌曲或专辑,而不是通过显式的评分或者点赞得到的。这被称为隐式反馈数据。现在的家用电视点播也是这样,用户一般不会主动评分。

数据集在http://www-etud.iro.umontreal.ca/~bergstrj/audioscrobbler_data.html,需要自带梯子,下载地址是http://www.iro.umontreal.ca/~lisa/datasets/profiledata_06-May-2005.tar.gz,这个好像不用梯子。在百度网盘共享地址是http://pan.baidu.com/s/1bQ4Ilg。

3.2 交替最小二乘推荐算法

我们要找的学习算法不需要用户和艺术家的属性信息。这类算法通常称为协同过滤算法。根据两个用户的年龄相同来判断谈么可能有相似的偏好,这不叫协同过滤。相反,根据两个用户包房过许多相同歌曲来判断他们可能都喜欢某首歌,这才叫协同过滤。

潜在因素模型通过数量相对少的未被观察到的底层原因,来解释大量用户和产品之间可观察到的交互。

本实例使用的是一种矩阵分解模型。问题简化为“用户-特征矩阵”和“特征-艺术家矩阵”的乘积,该乘积的结果是对整个稠密的“用户-艺术家互相关心矩阵”的完整估计。

在求解矩阵分解时,使用交替最小二乘(Alternating Least Squares,ALS)算法。需要借助QR分解的方法。

3.3 准备数据

如果数据不是运行在集群上,而是运行在本地,为了保证内存充足,在启动spark-shell时需要指定参数--driver-memory 6g。

构建模型的第一步是了解数据,对数据进行解析或转换,以便在Spark中做分析。

Spark MLib的ALS算法实现有一个小缺点:它要求用户和产品的ID必须是数值型,并且是32位非负整数,这意味着大于Integer.MAX_VALUE(2147483647)的ID是非法的。我们首先看看数据集是否满足要求:

Scala:

scala> val rawUserArtistData = sc.textFile("D:/Workspace/AnalysisWithSpark/src/main/java/advanced/chapter3/profiledata_06-May-2005/user_artist_data.txt")
rawUserArtistData: org.apache.spark.rdd.RDD[String] = D:/Workspace/AnalysisWithSpark/src/main/java/advanced/chapter3/profiledata_06-May-2005/user_artist_data.txt MapPartitionsRDD[1] at textFile at <console>:27

scala> rawUserArtistData.map(_.split(' ')(0).toDouble).stats()
res0: 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()
res1: org.apache.spark.util.StatCounter = (count: 24296858, mean: 1718704.093757, stdev: 2539389.040171, max: 10794401.000000, min: 1.000000)

Java:

 1 //初始化SparkConf
 2 SparkConf sc = new SparkConf().setMaster("local").setAppName("RecommendingMusic");
 3 System.setProperty("hadoop.home.dir", "D:/Tools/hadoop-2.6.4");
 4 JavaSparkContext jsc = new JavaSparkContext(sc);
 5 
 6 //读入用户-艺术家播放数据
 7 JavaRDD<String> rawUserArtistData =jsc.textFile("src/main/java/advanced/chapter3/profiledata_06-May-2005/user_artist_data.txt");
 8 
 9 //显示数据统计信息
10 System.out.println(rawUserArtistData.mapToDouble(line -> Double.parseDouble(line.split(" ")[0])).stats());
11 System.out.println(rawUserArtistData.mapToDouble(line -> Double.parseDouble(line.split(" ")[1])).stats());

最大用户和艺术家ID为2443548和10794401,没必要处理这些ID。
接着解析艺术家ID与与艺术家名对应关系。由于文件中少量的行不规范,有些行没有制表符、有些不小心加入了换行符,所以不能直接使用map处理。这是需要使用flatMap,它将输入对应的两个或多个结果组成的集合简单展开,然后放到一个更大的RDD中。后面的Scala程序没有运行过,只是粘贴在这里。读入艺术家ID-艺术家名数据并剔除错误数据:

Scala:

val rawArtistData = sc.textFile("hdfs:///user/ds/artist_data.txt")
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
		}
	}
}

Java:

 1 //读入艺术家ID-艺术家名数据
 2 JavaRDD<String> rawArtistData =jsc.textFile("src/main/java/advanced/chapter3/profiledata_06-May-2005/artist_data.txt");
 3 JavaPairRDD<Integer, String> artistByID = rawArtistData.flatMapToPair(line -> {
 4     List<Tuple2<Integer, String>> results = new ArrayList<>();
 5     String[] lineSplit = line.split("\\t", 2);
 6     if (lineSplit.length == 2) {
 7         Integer id;
 8         try {
 9             id = Integer.parseInt(lineSplit[0]);
10         } catch (NumberFormatException e) {
11             id = null;
12         }
13         if(!lineSplit[1].isEmpty() && id != null){
14             results.add(new Tuple2<Integer, String>(id, lineSplit[1]));
15         }
16     }
17     return results;
18 });

将拼写错误的艺术家ID或非标准的艺术家ID映射为艺术家的正规名:
Scala:

val rawArtistAlias = sc.textFile("hdfs:///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()

Java:

 1 //将拼写错误的艺术家ID或非标准的艺术家ID映射为艺术家的正规名
 2 JavaRDD<String> rawArtistAlias =jsc.textFile("src/main/java/advanced/chapter3/profiledata_06-May-2005/artist_alias.txt");
 3 Map<Integer, Integer> artistAlias  = rawArtistAlias.flatMapToPair(line -> {
 4     List<Tuple2<Integer, Integer>> results = new ArrayList<>();
 5     String[] lineSplit = line.split("\\t", 2);
 6     if((lineSplit.length == 2 && !lineSplit[0].isEmpty())){
 7         results.add(new Tuple2<Integer, Integer>(Integer.parseInt(lineSplit[0]), Integer.parseInt(lineSplit[1])));
 8     }
 9     return results;
10 }).collectAsMap();

artist_alias.txt中第一条为:"1092764 1000311",获取ID为1092764和1000311的艺术家名:
Java:

1 artistByID.lookup(1092764).forEach(System.out::println);
2 artistByID.lookup(1000311).forEach(System.out::println);

输出为:

Winwood, Steve
Steve Winwood

书中的例子为:
Scala:

artistByID.lookup(6803336).head
artistByID.lookup(1000010).head

Java:

1 artistByID.lookup(1000010).forEach(System.out::println);
2 artistByID.lookup(6803336).forEach(System.out::println);

输出为:

Aerosmith (unplugged)
Aerosmith

3.4 构建第一个模型

我们需要做两个转换:第一,将艺术家ID转为正规ID;第二,把数据转换成rating对象,它是ALS算法对“用户-产品-值”的抽象。其中产品指“向人们推荐的物品”。现在完成这两个工作:

Scala:

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()

Java:

1 //数据集转换
2 Broadcast<Map<Integer, Integer>> bArtistAlias = jsc.broadcast(artistAlias);
3 
4 JavaRDD<Rating> trainData = rawUserArtistData.map( line -> {
5     List<Integer> list = Arrays.asList(line.split(" ")).stream().map(x -> Integer.parseInt(x)).collect(Collectors.toList());
6     bArtistAlias.getValue().getOrDefault(list.get(1), list.get(1));
7     return new Rating(list.get(0), list.get(1), list.get(2));
8 }).cache();

这里使用了广播变量,它能够在每个executor上将数据缓存为原始的Java对象,这样就不用为每个任务执行反序列化,可以在多个作业和阶段之间缓存数据。

构建模型:
Scala:

val model = ALS.trainImplicit(trainData, 10, 5, 0.01, 1.0)

Java:

1 MatrixFactorizationModel model = org.apache.spark.mllib.recommendation.ALS.train(JavaRDD.toRDD(trainData), 10, 5 ,0.01, 1);

构建要花费很长的时间。我使用的是i5的笔记本,估计得三四天才能算完。所以实际计算时,我只使用了前98个用户的数据,一共是14903行。所以在3.5中打印用户播放过艺术家作品时,ID使用数据集中有的ID。

查看特征变量:
Scala:

model.userFeatures.mapValues(_.mkString(", ")).first()

Java:

1 model.userFeatures().toJavaRDD().foreach(f -> System.out.println(f._1.toString() + f._2[0] + f._2.toString()));

3.5 逐个检查推荐结果

获取用户ID对应的艺术家:

Scala:

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)

Java:

1 JavaRDD<String[]> rawArtistsForUser = rawUserArtistData.map(x -> x.split(" ")).filter(f -> Integer.parseInt(f[0]) == 1000029 );
2 List<Integer> existingProducts = rawArtistsForUser.map(f -> Integer.parseInt(f[1])).collect();
3 artistByID.filter(f -> existingProducts.contains(f._1)).values().collect().forEach(System.out::println);

我们可以对此用户做出5个推荐:
Scala:

val recommendations = model.recommendProducts(2093760, 5)
recommendations.foreach(println)

Java:
Rating[] recommendations = model.recommendProducts(1000029, 5);
Arrays.asList(recommendations).stream().forEach(System.out::println);

结果如下:

Rating(1000029,1001365,506.30319635520425)
Rating(1000029,4531,453.6082026572616)
Rating(1000029,4468,137.14313260781685)
Rating(1000029,599,130.16330043654924)
Rating(1000029,1003352,128.75804355555215)

书上说每行最后的数值是0到1之间的模糊值,值越大,推荐质量越好。但是我运行返回的结果不是这样的。
Spark 1.6.2的Java API是这么说的:
Rating objects, each of which contains the given user ID, a product ID, and a "score" in the rating field. Each represents one recommended product, and they are sorted by score, decreasing. The first returned is the one predicted to be most strongly recommended to the user. The score is an opaque value that indicates how strongly recommended the product is.
应该最后一个数组是评分吧。

得到所推荐艺术家的ID后,就可以用类似的方法查到艺术家的名字:
Scala:

val recommendedProductIDs = recommendations.map(_.product).toSet
artistByID.filter { case (id, name) =>
	recommendedProductIDs.contains(id)
}.values.collect().foreach(println)

Java:

1 List<Integer> recommendedProductIDs = Arrays.asList(recommendations).stream().map(y -> y.product()).collect(Collectors.toList());
2 artistByID.filter(f -> recommendedProductIDs.contains(f._1)).values().collect().forEach(System.out::println);

输出结果:

Barenaked Ladies
Da Vinci's Notebook
Rage
They Might Be Giants
"Weird Al" Yankovic

书上说好像推荐的结果不怎么样。

3.8 选择超参数

计算AUC这部分代码没有试。AUC(Area Under ROC Curve)是ROC(Receiver Operating Characteristic,受试者工作特征)线,它源于二战中用于敌机检测的雷达信号分析技术。在非均等代价下,ROC曲线不能直接反映出学习器的期望总体代价,而“代价曲线”则可达到该目的。

机器学习常涉及两类参数:一类是算法的参数,亦称“超参数”,数目常在10以内;另一类是模型的参数,数目可能很多。前者通常是由人工设定多个参数候选值后产生模型,后者则是通过学习来产生多个候选模型。
ALS.trainImplicit()的参数包括以下几个:

rank
  模型的潜在因素的个数,即“用户-特征”和“产品-特征”矩阵的列数;一般来说,它也是矩阵的阶。

iterations
  矩阵分解迭代的次数;迭代的次数越多,花费的时间越长,但分解的结果可能会更好。

lambda
  标准的过拟合参数;值越大越不容易产生过拟合,但值太大会降低分解的准确度。lambda取较大的值看起来结果要稍微好一些。

alpha
  控制矩阵分解时,被观察到的“用户-产品”交互相对没被观察到的交互的权重。40是最初ALS论文的默认值,这说明了模型在强调用户听过什么时的表现要比强调用户没听过什么时要好。

3.9 产生推荐

这个模型可以对所有用户产生推荐。它可以用于批处理,批处理每隔一个小时或更短的时间为所有用户重算模型和推荐结果,具体时间间隔取决于数据规模和集群速度。

但是目前Spark Mlib的ALS实现并不支持向所有用户给出推荐。该实现可以每次对一个用户进行推荐,这样每次都会启动一个短的几秒钟的分布式作业。这适合对小用户群体快速重算推荐。下面对数据中的多个用户进行推荐并打印结果:

Scala:

val someUsers = allData.map(_.user).distinct().take(100)
val someRecommendations =
	someUsers.map(userID => model.recommendProducts(userID, 5))
someRecommendations.map(
	recs => recs.head.user + " -> " + recs.map(_.product).mkString(", ")
).foreach(println)

Java:

1 //对id为1000029的用户做5个推荐
2 Rating[] recommendations = model.recommendProducts(1000029, 5);
3 Arrays.asList(recommendations).stream().forEach(System.out::println);

整个流程也可用于向艺术家推荐用户:

Scala:

rawUserArtistData.map { line =>
	...
	val userID = tokens(1).toInt
	val artistID = tokens(0).toInt
	...
}

Java:
在数据集转换时,"return new Rating(list.get(0), list.get(1), list.get(2));"变为"return new Rating(list.get(1), list.get(0), list.get(2));"

3.10 小结

对于非隐含数据,MLib也支持一种ALS的变体,它的用法和ALS是一样的,不同之处在于模型使用方法ALS.train()构建。它适用于给出评分数据而不是次数数据。比如,如果数据集是用户对艺术家的打分,值从1到5,那么用这种变体就很合适。不同推荐方法返回的Rating对象结果,其中rating字段是估计的打分。

如果能需要按需计算出推荐结果,可以使用Oryx 2(https://github.com/OryxProject/oryx),其底层使用MLib之类的库,但用高效的方式访问内存中的模型数据。

posted on 2016-08-17 09:00 龙猫先生 阅读(...) 评论(...) 编辑 收藏

导航

统计