Spark ALS源码总结

来源:互联网 发布:恐怖漫画 软件 编辑:程序博客网 时间:2024/04/27 02:04

Spark ALS是ALS的分布式实现,非常高效,代码进行了大量的优化,有许多可以借鉴和思考的地方,它实现了Explicit ALS和Implicit ALS分布式算法。本文是阅读Spark ALS源码后的一些总结和思考。

Implicit ALS原理

先说一下隐式数据的特点:

  • 没有负反馈
  • 充满噪声
  • 显式数据的数值代表偏好,隐式数据的数值代表了置信等级(confidence level)。例如,我们观测到该用户看了某部电影,推测他可能喜欢该电影,如果发现他看了这部电影好几遍,我们就很有信心认为他喜欢这部电影了

基于隐式数据的特点,Implicit ALS做如下假设:

  • 引入二分值pui:

    pu,i={10if ru,i>0otherwise

  • 引入置信等级cui

    cui=1+αrui

  • 损失函数

    minx,y(u,i)Kcu,i(pu,ixuyi)2+λ(u||xu||2+i||yi||2)

  • 迭代公式

    xu=(YCuY+λI)1YCupuyi=(XCiX+λI)1XCipi

    其中,
    Cu=cu1cun

  • 变换
    xu为例,直接计算的话,计算量太大,注意到YCuY=YY+Y(CuI)Y,而CuI使得只需要计算用户u有过行为的物品集合,YY可以在一轮迭代里只需要计算一次。这里已经有点分布式的味道了。

Spark ALS并行化分析

xu为例(之后也该角度分析),计算它需要两个要素:

  • 用户u的评分详情(对哪个物品评了多少分),用于计算CuIpu
  • 用户u关联的所有物品集的隐式因子,用于计算Y(CuI)YYCupu

Spark ALS 主要有三个步骤

  • partitionRatings:将原始评分数据分片为块
  • makeBlocks:产生InBlock和OutBlock
  • computeFactors:Normal Equation求解

分布式计算关注的重点是控制计算复杂度和通信复杂度。Spark ALS首先是以Block为单位进行正态方程求解的。例如在迭代Block 1中的所有用户U1的factors的时候,需要将U1的所有评分详情和所需的物品因子集全部汇总到同一个Partition中。

这里Spark ALS设计了两个结构InBlock和OutBlock。InBlock存储评分详情,OutBlock存储“因子关联索引”,用于索引相关的Item Facors,这部分的源码是个难点。

Spark ALS 数据格式衍变

通过阅读源码,总结其从最初的评分数据格式衍变格式如下

编号 函数 形式 KV 1 partitionRatings
将原始ratings分片为blocks (srcBlockId, dstBlockId) RatingBlock(srcIds, dstIds, ratings) 2.1 makeBlocks
建立InBlock和OutBlock srcBlockId (dstBlockId, srcIds, dstLocalIndices, ratings)
dstLocalIndices表示Block本地索引 2.2 srcBlockId (srcIds, dstEncodedIndices, ratings)
dstEncodedIndices是dstBlockId和dstLocalIndices的组合 3 InBlock形态 srcBlockIdInBlock(uniqueSrcIds, dstPtrs, dstEncodedIndices, ratings)
这里进行了排序和矩阵压缩 4 OutBlock形态 srcBlockId[dstBlockId][uniqSrcIdLocalIndex] 5 Factor形态 srcBlockId[srcIdLocalIndex][Array[float]]

Block用户集获取所需Item集Factors的过程

ItemOutBlock.join(ItemFactors).flatMap {    case(ItemBlockId, (ItemOutBlock, ItemFactors)) =>     ItemOutBlock.view.zipWithIndex.map { case ([uniqItemIdLocalIndex], UserBlockId) =>         (UserBlockId, (ItemBlockId, AssocItemFactors)))       }  } =>(UserBlockId, Array[(ItemBlockId, ItemFactors)])

通过以上方式,所有需要的元素都传输到了一起。

重点源码分析

核心源码在org.apache.spark.ml.recommendation.ALS中,相比于Spark SVD++的200多行的代码,ALS的代码量真是巨无霸,洋洋洒洒1~2千行,不过核心模块的代码也是几百行左右,这里主要分析makeBlocks源码片段。

partitionRatings的作用

原始评分数据是(user:ID,item:ID,rating:Float)这样的大量tuple

把这些打分直接按照 Tuple 存的话会有几个问题。首先是空间的额外开销,每个 Tuple 实例都需要一个指针,而每个 Tuple 所存的数据不过是两个 ID 和一个打分,非常不划算。而且存储大量的 Tuple 实例会降低 Java 垃圾回收效率。所以我们使用三个原始数组来存 InBlock 信息:([v1, v2, v1, v2, v2], [u1, u1, u2, u2, u3], [a11, a12, a21, a22, a32])。这样不仅大幅减少了实例数量,还有效地利用了连续内存

编号3 矩阵压缩

注释上说CSC压缩,但是我觉得是CSR压缩,并且和普通的CSR压缩不同,因为是hash到了block单位,每个block上的SrcIds更加稀疏,此时用普通矩阵压缩会产生大量的0值。于是就有了这种形式,注意在压缩之前已经排好序了,这样做的便利很多,1是方便压缩 2是方便最后的正态方程的计算。

InBlock(uniqueSrcIds,dstPtrs,dstEncodedIndices,ratings)

其中,dstPtrs表示uniqueSrcId步长

编号4 OutBlock生成

OutBlock的意义就是建立连接,当前的SrcBlockId中的哪些srcIdLocalIndex是需要发送到哪些DstBlockId的。

val outBlocks = inBlocks.mapValues { case InBlock(srcIds, dstPtrs, dstEncodedIndices, _) =>      val encoder = new LocalIndexEncoder(dstPart.numPartitions)      //activeIds就是需要建立的“联系”,发送给哪些dst,发送哪些srcIdLocalIndex      val activeIds = Array.fill(dstPart.numPartitions)(mutable.ArrayBuilder.make[Int])      var i = 0      //标识该uniqSrcId已经发送      val seen = new Array[Boolean](dstPart.numPartitions)      while (i < srcIds.length) {        var j = dstPtrs(i)        ju.Arrays.fill(seen, false)        while (j < dstPtrs(i + 1)) {          val dstBlockId = encoder.blockId(dstEncodedIndices(j))          if (!seen(dstBlockId)) {            //添加local index到该out-block            activeIds(dstBlockId) += i                         seen(dstBlockId) = true          }          j += 1        }        //该uniqSrcId发送完毕        i += 1      }      activeIds.map { x =>        x.result()      }    }.setName(prefix + "OutBlocks")      .persist(storageLevel)

topK推荐

userFactorsitemFactors计算完毕后,如何对每个用户对推荐topK物品呢,很显然计算复杂度为O(MN)。Spark ALS没有对该计算复杂度进行优化,主要在计算方式上进行优化,依旧是分块计算,并使用level-3 BLAS来进行矩阵内积运算。

看了Spark ALS的JIRA下的讨论,mahout的作者Sean Owen提供了一种方法LSH来缩减搜索范围,不过该方法会损失虽小但可见的精度,最后讨论的结果还是维持原状。

在实际操作的时候,我们可以根据领域知识缩减这个内积操作的范围,比如推荐餐馆,我们通过标识该用户的活动区域,只把该用户的隐式因子与这个区域的餐馆的隐式因子乘积,这样可以极大地缩减计算量。

参考

  • Spark ALS源码
  • https://issues.apache.org/jira/browse/SPARK-3066
  • http://www.csdn.net/article/2015-05-07/2824641