使用Spark实现相似度计算

使用Spark实现相似度计算

 

在这篇文章中,我们和大家分享一下使用Spark来实现一些比较复杂的逻辑的过程中所遇到的问题和一些体会。本文的例子基于all pair similarity  search

一、简介

All pair similarity search简单来说就是计算给定的一组向量之间的两两相似度,通常向量是高维的且是稀疏的,向量数量是巨大的。Apss是在大数据的数据挖掘中的一个基本的计算,在许多领域都有应用,如推荐,相似文档检测,web搜索的查询优化等等。由于实际问题中数据规模很大,所以必须使用分布式计算的方式才能完成。

Spark是目前比较流行的大数据分布式计算平台,与Map reduce系统把计算仅仅分为map与reduce两个步骤不同,Spark支持以DAG的方式来描述计算阶段与步骤,并且支持在内存中cache中间执行结果,比较适合迭代的计算任务。虽然APSS的计算并不是一个典型的迭代问题,但我们仍然期望基于Spark对计算更加强大的表达与控制能力,以及内存cache,可以获得相比Mapreduce更好的性能。

本文之后将主要介绍如何在Spark分布式计算平台上实现APSS,着重于使用Spark实现的过程以及一些经验与问题的探讨。内容安排如下:首先我们会具体描述问题,接着是介绍各种的实现方式与尝试,最后是一些经验与问题探讨。

二、问题描述

如前所述,对于一组向量1, 以及给定的相似度度量sim(x,y),All pair similarity search需要对于所有的pair(x, y),计算sim(x,y)。由于所有的pair的数目是n^2级别的,数量巨大,所以一般需要加上过滤条件。在我们的实际需求中,过滤条件是sim(x,y)大于阈值t的topK个结果,这里的topK是指对于一个向量Vi,找出与Vi最相似的前K个向量

具体来说,实际的输入数据可以看作为一个大表,由两部分组成,第一部分是每个向量的id,第二部分是稀疏向量本身,其中稀疏向量由坐标格式(列坐标:值)表示:

IDSparse Vector
1(C1:V1, C2:V2, …Cn1:Vn1)
2(C1:V1, C2:V2, …Cn1:Vn1)
3(C1:V1, C2:V2, …Cn1:Vn1)
….
n(C1:V1, C2:V2, …Cn1:Vn1)

 

相似度的度量依据实际的需求我们至少需要支持cosine相似度,相似度定义如下:

2

如果把A和B都正则化为单位向量,那么相似度就化简为A和B的点积。

三、几种尝试

  1. Spark SQL.

从上面的问题描述可以看出,如果暂时先不考虑topK,则计算两两的相似度可以看作是输入表自己与自己做笛卡尔积,并以阈值t来过滤结果集.用SQL表示即为:

Select sim(x,y) s from V va, V vb where s > t

对于大约1m行的数据,在Spark Sql中运行类似的sql语句,耗时十多个小时后运行失败.

  1. Broadcast的方式.

对于输入表如果不是很大,可以放在内存中的话,那么最方便的就是把输入表构造为一个Map,然后作为Broadcast变量发送到各个运算节点,进行类似于hash join的运算.这个方法在数据量不大的情况下是可行的,而且速度很快,但问题是它可扩展性不好,无法满足我们的实际需求.

3.分块暴力搜索

对于输入数据较大的情况, 把数据进行切分分块是常见的做法。对于APSS,我们把输入的一组向量按行切分,由于需要自己与自己做全组合,所以概念上构成了一个分块的矩阵。例如下图所示,我们把输入的6行数据三等分,构成了3*3分块的矩阵:

3

对于每一个小块Bi,它只需要输入数据中的一小部分。例如,对于B1只用到与 ,而对于B4则只用到了 ;同时,一个向量会被若干个快所用到。因此,我们可以事先把每个块所用的数据准备好,这样避免了直接进行笛卡尔积的运算,减少了数据拷贝与传输的量。

VIDBID List
11,2,3,4,7
21,2,3,4,7
32,4,5,6,8
42,4,5,6,8
53,6,7,8,9
63,6,7,8,9

通过分块,把一条数据的拷贝量从n下降到2*m-1,其中m是把输入数据划分的个数,而n是输入数据的行数,通常n >> m。在Spark中,VID到BID list的转化可以在flatmap中进行,之后基于BID进行repartition. 由于实际数据是很稀疏的, 这个方法完全没有用到数据稀疏性,所以也不怎么适合实际的数据.

四、所用的实现
当数据非常稀疏的时候,遍历的方式会把大量时间浪费在零元素上,R. J. Bayardo在论文Scaling Up All-Pairs Similarity Search中提出了一种解决稀疏数据求相似度的方法,即Inverted Index。所以接下来我们基于此在Spark上试着实现类似方法。

Inverted Index

Inverted Index算法是个迭代过程,当迭代到第i行的时候,会同时计算第i行和前i-1行(共i-1对向量)的点积。两个稀疏向量的点积,相当于对应位置的非零元素相乘,再相加。整个迭代过程中会构造一个单链表数组的数据结构,用来保存前i-1行的向量,当i行完成迭代的时候,把i行的数据添加到这个单链表数组中,每次都插入到单链表的头部(复杂度O(1)),这就是Inverted Index名字的由来。算法会用一个累加数组来保存第i行和前i-1行的点积结果,实际实现时一般会采用HashMap。

算法思路如下:

  1. 初始化单链表数组
  2. 遍历所有向量,当遍历到第i行时
    1. 初始化累加数组
    2. 计算第i行和前i-1行的点积
      1. 遍历第i行所有非零元素
      2. 当访问第i行第j列非零元素时,遍历单链表数组的第j个链表
      3. 将第i行第j列元素同第j个链表中的所有元素相乘,并放入累加数组的对应位置
    3. 累加数组中第k行的值就是第i行和第k行的点积
    4. 把第i行向量添加到单链表数组

4

 

程序伪代码如下:

5

6

下面举一个简单的例子来说明Inerted Index的计算过程,原始数据如下(6个向量,每个向量有6列):

7

当迭代到第6行时,单链表数组的结构如下图,1-5行的非零元素都被串在6个不同的单链表上。然后开始遍历第6行,发现第6行有两个非零元素:1.0和0.4。

  1. 首先遍历1.0所在列的单链表,只有一个元素,即第4行的0.9,把1.0 x 0.9放到累加数组的第4个位置。
  2. 然后遍历0.4所在列的单链表,有两个元素,即第1行的0.8和第4行的0.6
    1. 把0.4 x 0.8放到累加数组的第1个位置
    2. 把0.4 x 0.6放到累加数组的第4个位置(第4个位置已经有值0.9,把新的值累加到原来的值上)
  3. 遍历累加数组
    1. 累加数组第1个位置等于0.32,说明第6行和第1行的点积等于0.32
    2. 累加数组第4个位置等于1.14,说明第6行和第4行的点积等于1.14
    3. 累加数组的其他位置等于0,说明第6行和其他行的点积等于0

8

Inverted Index in Spark

下面介绍一下如何把单机版的Inverted Index算法变成在Spark上运行的并行程序。我们还是采用分块的方法,假设数据量为n,那么原问题需要求解n X n个点积计算。分块算法的大致思路如下:

  1. 我们把n分成b块,每块大小为n/b
  2. 因为点积具有交换律,所以我们可以把原问题化简为b(b+1)/2个规模为n/b的子问题
  3. 计算其中一个块的时候(假设块的坐标为x, y)
    1. 如果x==y,那么计算这个块只需要第x块的数据
    2. 如果x!=y,那么计算这个块同时需要第x和第y块的数据

9

Inverted Index在Spark上的并行实现算法步骤如下(假设有n个向量,分成b块):

  1. 将所有向量变成单位向量
  2. 把每个向量发送到对应的partition,第b块的向量需要发送到坐标为(*, b)和(b, *)的分区,并去除下三角区域。
  3. 每个partition分别利用单机版的Inverted Index算法计算点积
  4. 汇总每个partition的结果

值得一提的是,单机版Inverted Index算法计算的是n个向量两两之间的点积,但是在Spark并行版本中,只有在对角线上的partition才符合这个条件,不在对角线上的partition,需要计算两组不同的n个向量互相之间的点积。我们采用的办法是:把其中一组n个向量建立单链表数组,然后遍历另外一组n个向量的方式,来计算两组不同的n个向量之间的点积。

五、一些经验

Column Based VS Row Based

如果RDD中需要保存大量小对象,在进行action操作时,这些小对象会被创建销毁,导致大量的GC时间。因此在这种情况下,建议使用列式存储来保存这些大量的小对象。以下分别是在Spark和Scala环境中对行存储和列存储的性能对比:(数据量为5千万)

GC(s) / Runtime(s)Row BasedColumn Based
Spark226 / 229 = 98.7%0.42 / 4 = 10.5%
Scala214 / 217 = 98.6%0.12 / 3 = 4%

while VS for

Scala的for语法是Scala典型的语法糖,Scala的for其实最终调用的是foreach方法,而for里面的代码会变成一个继承自Function1的匿名类。因此当for循环次数非常多的情况下,大量的函数调用会严重影响程序的性能。以下试验测试了Scala的For循环和While+var循环的性能对比:(三重循环,每重循环2000次)

time (s)
For6.328
While0.231

测试代码

 

10

Data locality and cache misses

这里的局部性特指对本机的内存访问,虽然基于jvm程序不推荐关心具体数据的内存分布情况,但我们在实际尝试中,仍然观察到了比较明显的由于数据局部性造成的问题。下面以矩阵相乘举两个例子。

(1) 数据尽量连续访问

11

考虑到矩阵不同的行列存储方式,至少对一个矩阵的元素是连续访问的

(2) 数据重用

12

计算A的一行,1024个值:1024次访问A, 384次访问B, 1024×384=393216次访问C,总计394524。

计算A中32×32的小块,1024个值:1024次访问A, 384×32次访问B, 32×384次访问C,总计25600。

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值