[Spark ML] 第四章:构建基于Spark的推荐引擎(4.4节)

4.4 使用推荐模型

有了训练好的模型后便可以用它来做预测。预测通常有两种:为某个用户推荐物品,或找出与某个物品相关或相似的其他物品

4.4.1 用户推荐

用户推荐是指向给定用户推荐物品。它通常以“前k个”形式展现,即通过模型求出用户可能喜好程度最高的前K个商品。这个过程通过计算每个商品的预计得分并按照得分进行排序实现。

具体的实现方法取决于所采用的模型。比如若采用基于用户的模型,则会利用相似用户的评级来计算对某个用户的推荐。而若采用基于物品的模型,则会依靠用户接触过的物品与候选物品之间的相似度来获得推荐。

利用矩阵分解方法时,是直接对评级数据进行建模,所以预计得分可视作相应用户因子向量和物品因子向量的点积

1. 从MovieLens 100k数据集生成电影推荐

MLlib的推荐模型基于矩阵分解,因此可用模型所求得的因子矩阵来计算用户对物品的预计评级。下面只针对利用MovieLens中显式数据做推荐的情形,使用隐式模型时的方法与之相同。

MatrixFactorizationModel类提供了一个predict函数,以方便地计算给定用户对给定物品的预期得分:

scala> val predictdRating = model.predict(789, 123)
predictdRating: Double = 3.8346483653941728

可以看到,该模型预测用户789对电影123的评级为3.83(书中原是3.12)。

ALS模型的初始化是随机的,这可能让你看到的结果和这里的不同。实际上,每次运行该模型所产生的推荐也会不同。

predict函数同样可以以(user, item)ID对类型的RDD对象作为输入,这时它将为每一个对都生成相应的预测得分。我们可以借助这个函数来同时为多个用户和物品进行预测。

要为某个用户生成前K个推荐物品,可以借助MatrixFactorizationModel所提供的recommendProducts函数来实现。该函数需两个输入参数:user和num。其中user是用户ID,而num是要推荐的物品个数。

返回值为预测得分最高的前num个物品。这些物品的序列按得分排序。该得分为相应的用户因子向量和各个物品因子向量的点积

现在,计算一下为用户789推荐的前10个物品:

scala> val userId = 789
userId: Int = 789

scala> val k = 10
k: Int = 10
                                                
scala> val topKRecs = model.recommendProducts(userId, k)

这就求得了为用户789所能推荐的物品以及对应的预计得分。将这些信息打印出来如下:

scala> println(topKRecs.mkString("\n"))
Rating(789,127,5.017495884006371)
Rating(789,693,5.008663826504293)
Rating(789,150,4.998347488881693)
Rating(789,475,4.98021409862233)
Rating(789,129,4.967487199093502)
Rating(789,56,4.955091573492443)
Rating(789,276,4.937001551056337)
Rating(789,179,4.925411423072295)
Rating(789,100,4.92335948917222)
Rating(789,741,4.91585505688794)

2. 检验推荐内容

要直观的检验推荐的效果,可以简单比对下用户所评级过的电影的标题和被推荐的那些电影的电影名称。首先,我们需要读入电影数据。这些数据会导入为Map[Int, String]类型,即从电影ID到标题的映射

scala> val movies = sc.textFile("file:///usr/datasets/ml-100k/u.item")
movies: org.apache.spark.rdd.RDD[String] = file:///usr/datasets/ml-100k/u.item MapPartitionsRDD[425] at textFile at :29
 
scala> val titles = movies.map(line => line.split("\\|").take(2)).map(array => (array(0).toInt, array(1))).collectAsMap()
titles: scala.collection.Map[Int,String] = Map(137 -> Big Night (1996), 891 -> Bent (1997), 550 -> Die Hard: With a Vengeance (1995), 1205 -> Secret Agent, The (1996), 146 -> Unhook the Stars (1996), 864 -> My Fellow Americans (1996), 559 -> Interview with the Vampire (1994), 218 -> Cape Fear (1991), 568 -> Speed (1994), 227 -> Star Trek VI: The Undiscovered Country (1991), 765 -> Boomerang (1992), 1115 -> Twelfth Night (1996), 774 -> Prophecy, The (1995), 433 -> Heathers (1989), 92 -> True Romance (1993), 1528 -> Nowhere (1997), 846 -> To Gillian on Her 37th Birthday (1996), 1187 -> Switchblade Sisters (1975), 1501 -> Prisoner of the Mountains (Kavkazsky Plennik) (1996), 442 -> Amityville Curse, The (1990), 1160 -> Love! Valour! Compassion! (1997), 101 -> Heavy Metal (1981), 1196 -> Sa...
 
scala> titles(123)
res13: String = Frighteners, The (1996)

用户789,我们可以找出他接触过的电影、给出最高评级的前10部电影及名称。具体实现时,可先用Spark的keyBy函数来从ratings RDD来创建一个键值对RDD。其主键为用户ID。然后利用lookup函数来只返回给定键值(即特定用户ID)对应的那些评级数据到驱动程序:

scala> val moviesForUser = ratings.keyBy(_.user).lookup(789)

来看下这个用户评价了多少电影。这会用到moviesForUser的size函数:

scala> println(moviesForUser.size)
33

可以看到,这个用户对33部电影做过评级。

接下来,我们要获取评级最高的前10部电影。具体做法是利用Rating对象rating属性来对moviesForUser集合进行排序并选出排名前10的评级(含电影ID)。之后以其作为输入,借助titles映射为(电影名称,具体评级)形式。再将名称与具体评级打印出来:

scala> moviesForUser.sortBy(_.rating).take(10).map(rating => (titles(rating.product), rating.rating)).foreach(println)
(English Patient, The (1996),1.0)
(Big Night (1996),2.0)
(Willy Wonka and the Chocolate Factory (1971),2.0)
(Palookaville (1996),3.0)
(Liar Liar (1997),3.0)
(Toy Story (1995),3.0)
(Tin Cup (1996),3.0)
(Trees Lounge (1996),3.0)
(Truth About Cats & Dogs, The (1996),3.0)
(Ransom (1996),3.0)

现在看下对该用户的前10个推荐,并利用上述相同的方式来查看他们的电影名(这些推荐以排序):

scala> topKRecs.map(rating => (titles(rating.product), rating.rating)).foreach(println)
(Godfather, The (1972),5.017495884006371)
(Casino (1995),5.008663826504293)
(Swingers (1996),4.998347488881693)
(Trainspotting (1996),4.98021409862233)
(Bound (1996),4.967487199093502)
(Pulp Fiction (1994),4.955091573492443)
(Leaving Las Vegas (1995),4.937001551056337)
(Clockwork Orange, A (1971),4.925411423072295)
(Fargo (1996),4.92335948917222)
(Last Supper, The (1995),4.91585505688794)

对比一下这两份电影名单,看这些推荐效果如何。

4.4.2 物品推荐

物品推荐的是为了回答如下的问题:给定一个商品,有哪些物品和它最为近似?这里相似的确切定义取决于所使用的模型。大多数情况下,相似度是通过某种方式比较表示两个物品的向量而得到的。常见的相似度衡量方法包括皮尔森相关系数(Pearson correlation)、针对实数向量的余弦定相似度(cosine similarity)和针对二元向量的杰卡德相似系数(Jaccard similarity)

1. 从MovieLens 100k数据集生成相似电影

MatrixFactorizationModel当前的API不能直接支持物品之间相似度的计算。所以我们要自己实现。

这里会使用余弦相似度来衡量相似度。另外采用jblas线性代数库(MLlib的依赖库之一)来求向量点积。这些和现有的predict和recommendProducts函数的实现方式类似,但我们会用到余弦相似度而不仅仅是求点积。

我们想利用余弦相似度来对指定物品的因子向量与其他物品的作比较。进行线性计算时,除了因子向量外,还需要创建一个Array[Double]类型的向量对象。以该类型对象为构造函数的输入来创建一个jblas.DoubleMatrix类型对象的方法如下:

scala> import org.jblas.DoubleMatrix
import org.jblas.DoubleMatrix

scala> val aMatrix = new DoubleMatrix(Array(1.0, 2.0, 3.0))
aMatrix: org.jblas.DoubleMatrix = [1.000000; 2.000000; 3.000000]

提示:在导入jblas库时,会出现如下的错误提示:

scala> import org.jblas.DoubleMatrix
<console>:27: error: object jblas is not a member of package org
         import org.jblas.DoubleMatrix
                    ^

原因是没有在Spark shell启动时,导入jblas.jar包。两种解决方法:。在使用spark-shell命令中加入–jars参数之后,重新启动spark-shell,继续下面的操作。(由于使用的是spark shell环境,而非IDE,所以在重新启动后之前的操作都不会进行保存,需要重新训练模型

注意:使用jblas时,向量和矩阵都表示为一个DoubleMatrix类对象,但前者是一维的,后者为二维。

我们需要定义一个函数来计算两个向量之间的余弦相似度。余弦相似度是两个向量在n维空间里两者的夹角的度数。它是两个向量的点积与各向量范数(长度)的乘积的商。(余弦相似度用的范数为L2-范数, L2-norm)。这样,余弦相似度是一个正则化了的点积。

该相似度的取值在-1到1之间。1表示完全相似,0表示两者互不相关(即无相似性)。这种衡量方法很有帮助,因为它能捕捉负相关性。也就是说,当为-1时则不仅表示两者不相关,还表示他们完全不同。

下面来创建这个cosineSimilarity函数:

scala> def cosineSimilarity(vec1:DoubleMatrix, vec2:DoubleMatrix):Double = {vec1.dot(vec2) / (vec1.norm2() * vec2.norm2()) }
cosineSimilarity: (vec1: org.jblas.DoubleMatrix, vec2: org.jblas.DoubleMatrix)Double

注意:这里定义了该函数的返回值类型为Double,但这并非必要。Scala会自动推断出这个返回值类型。但写明函数的返回值类型也是有帮助的。

下面以物品567为例从模型中取回其对应的因子。这可以通过调用lookup函数来实现。之前曾用过该函数来取回特定用户的评级下面的代码中还使用了head函数。lookup函数返回了一个数组而我们只需第一个值(实际上,数组里也只会有一个值,也就是该物品的因子向量)。

这个因子的类型为Array[Double],所以后面会用它来创建一个Double[Matrix]对象,然后再用该对象来计算它与自己的相似度:

scala> val itemId = 567
itemId: Int = 567

scala> val itemFactor = model.productFeatures.lookup(itemId).head
itemFactor: Array[Double] = Array(0.21237821877002716, 0.2962482273578644, -0.6378617286682129, 0.48622244596481323, -0.4152349531650543, 0.29090172052383423, 0.051501400768756866, 0.6493636965751648, -0.3310590386390686, -0.06814326345920563, -4.146805149503052E-4, 0.10999772697687149, 0.7779315710067749, -0.17048458755016327, -0.023082485422492027, 0.2821968197822571, 0.05601058527827263, 0.17597469687461853, 0.5571439266204834, -0.4689156413078308, 0.26584675908088684, 0.15271760523319244, 0.37241992354393005, 0.3920302093029022, 0.20865066349506378, -0.11361687630414963, -0.08782057464122772, 0.7015412449836731, 0.41746532917022705, 0.22573010623455048, 0.2686771750450134, 0.35239604115486145, 0.6839914321899414, 0.9956130385398865, -0.3525468409061432, -1.3945478200912476, -0.90538...
scala> val itemVector = new DoubleMatrix(itemFactor)
itemVector: org.jblas.DoubleMatrix = [0.212378; 0.296248; -0.637862; 0.486222; -0.415235; 0.290902; 0.051501; 0.649364; -0.331059; -0.068143; -0.000415; 0.109998; 0.777932; -0.170485; -0.023082; 0.282197; 0.056011; 0.175975; 0.557144; -0.468916; 0.265847; 0.152718; 0.372420; 0.392030; 0.208651; -0.113617; -0.087821; 0.701541; 0.417465; 0.225730; 0.268677; 0.352396; 0.683991; 0.995613; -0.352547; -1.394548; -0.905385; -0.262825; -1.312015; -0.523149; 0.858834; 0.009033; 0.286061; 1.499203; 0.140560; 0.207537; -0.131585; 0.449066; -0.365338; -1.564260]

scala> cosineSimilarity(itemVector, itemVector)
res7: Double = 1.0000000000000002

现在求各个物品的余弦相似度

scala> val sims = model.productFeatures.map {
     | case (id, factor) => 
     | val factorVector = new DoubleMatrix(factor)
     | val sim = cosineSimilarity(factorVector, itemVector)
     | (id, sim)
     | }
sims: org.apache.spark.rdd.RDD[(Int, Double)] = MapPartitionsRDD[419] at map at :46

接下来,对物品按照相似度排序,然后取出与物品567最相似的前10个物品:

scala> val K = 10
K: Int = 10

scala> val sortedSims = sims.top(K)(Ordering.by[(Int, Double), Double] { 
     | case (id, similarity) => similarity})
sortedSims: Array[(Int, Double)] = Array((567,1.0000000000000002), (150,0.7097504942403269), (250,0.7082597469741062), (719,0.7045102142955386), (288,0.692515388615497), (940,0.6919939485176535), (563,0.691766685663807), (508,0.68421334943082), (433,0.6813303652572235), (853,0.6794092599480155))

上述代码里使用到了Spark的top函数。相比使用collect函数将结果返回驱动程序然后在本地排序,它能分布式计算出“前k个结果”,因而更高效。(注意:推荐系统要处理的用户和物品数目可能以百万计)

Spark需要知道如何对sims RDD里的(item id, similarity score)对排序。为此,我们另外传入了一个参数给top函数。这个参数是一个scala Ordering对象,它会告诉Spark根据键值对里的值排序(也就是similarity排序)。

最后,打印出这10个与给定物品最相似的物品:

scala> println(sortedSims.take(10).mkString("\n"))
(567,1.0000000000000002)
(150,0.7097504942403269)
(250,0.7082597469741062)
(719,0.7045102142955386)
(288,0.692515388615497)
(940,0.6919939485176535)
(563,0.691766685663807)
(508,0.68421334943082)
(433,0.6813303652572235)
(853,0.6794092599480155)

很正常,排名第一的最相似物品就是我们给定的物品。之后便是以相似度排序的其他类似的物品。

2. 检查推荐的相似物品

来看一下我们所给定的电影的名称是什么:

scala> println(titles(itemId))
Wes Craven's New Nightmare (1994)

如在用户推荐中所做过的,我们可以看看推荐的那些电影名称是什么,从而直观上检查一下基于物品推荐的结果。这一次我们取前11个最为相似的电影,以排除给定的那一部。所以,可以选取列表中的第1到11项:

scala> val sortedSims2 = sims.top(K+1)(Ordering.by[(Int, Double), Double] {
     | case(id, similarity) => similarity })
sortedSims2: Array[(Int, Double)] = Array((567,1.0000000000000002), (150,0.7097504942403269), (250,0.7082597469741062), (719,0.7045102142955386), (288,0.692515388615497), (940,0.6919939485176535), (563,0.691766685663807), (508,0.68421334943082), (433,0.6813303652572235), (853,0.6794092599480155), (741,0.6757569320608299))

scala> sortedSims2.slice(1, 11).map{ case (id, sim) => (titles(id), sim)}.mkString("\n")
res10: String = 
(Swingers (1996),0.7097504942403269)
(Fifth Element, The (1997),0.7082597469741062)
(Canadian Bacon (1994),0.7045102142955386)
(Scream (1996),0.692515388615497)
(Airheads (1994),0.6919939485176535)
(Stephen King's The Langoliers (1995),0.691766685663807)
(People vs. Larry Flynt, The (1996),0.68421334943082)
(Heathers (1989),0.6813303652572235)
(Braindead (1992),0.6794092599480155)
(Last Supper, The (1995),0.6757569320608299)

上面,我们使用余弦相似度得出了相似物品。可以试着同样用该相似度,用用户因子向量来计算与给定用户类似的用户有哪些。

Question:在比较物品相似度或者用户相似度时,使用的是哪些feature(特征)进行的比较?这里没有提到,是不是使用的记录中所有的特征进行比较?能不能自己指定进行比较的特征值?