大规模数据的相似度计算:LSH算法

大规模数据的相似度计算:LSH算法

前言

最近在工作中需要去优化离职同事留下的用户协同过滤算法,本来想协同过滤嘛,不就是一顿算相似度,然后取top-k相似的用户去做推荐就完了。结果看代码的过程中,对计算相似度的部分却是一头雾水,主要是对其中使用的LSH算法不甚了解。经过了一番调研之后,才算是理解了这个算法的精妙,也感到自己之前的粗糙想法实在是naive。

传统的协同过滤算法,不管是基于用户还是基于物品的,其中最关键的一个问题便是:计算两个用户(或物品)之间的相似度。相似度的计算有多种方式:欧氏距离、余弦相似度或者Jaccard相似度,不管以何种计算方式,在数据维度较小时,都可以用naive的方式直接遍历每一个pair去计算。但当数据维度增大到一定程度时,计算复杂度就开始飙升了,主要体现在两个方面(以计算用户相似度为例):

  1. 两个用户之间相似度的计算随着物品维度的增加而增加
  2. 计算每一个用户和其他所有用户之间的相似度的复杂度随着用户规模的增长,呈平方增长

对于工业界的数据,用户和物品的维度都在千万甚至更高的情况下,直接计算两两之间的相似度,即便使用大规模计算集群有可能实现,所需要的计算成本也是极高的。这时便需要使用近似算法,牺牲一些精度来大大提高计算效率。Min Hashing和Locality Sensitive Hashing(LSH,局部敏感哈希)便是用来分别提高这两个方面的计算效率的。

Min Hashing

首先我们定义一下变量的记号:假设有两个用户,用向量A和B来表示,其长度为n(也就是item的维度)。A和B向量中的非零值个数分别为 ab ,A、B向量中共同的非零值个数为 c ,则Jaccard相似度可定义为:

Jaccard(A,B) = \frac{c}{a+b}\\

当a,b的值较大的话,计算Jaccard相似度的复杂度也是线性增长的,如何减小这个计算复杂度就是MinHash想要去解决的问题。简单来说,MinHash所做的事情就是:将向量A、B映射到一个低维空间,并且近似保持A、B之间的相似度

如何得到这样的映射呢?我们现将用户A、B用物品向量的形式表达如下:

其中 i_1i_n 表示n个物品,所谓的MinHash是这样一个操作:

  • 首先对 i_1i_2 ... i_n 作一个permutation,向量A,B每一维的取值作同样的操作
  • 向量的MinHash值对应permutation之后,取值为非零的第一行的row index

得到向量A,B的MinHash值之后,有这样一个重要的结论:

P[\text{minHash}(\textbf{A})=\text{minHash}(\textbf{B})] = \text{Jaccard}(\textbf{A},\textbf{B})

要理解这个等式,可以考虑向量A,B每一行的取值可以分为三类:

  1. A和B在这一行上的取值均为1
  2. A和B在这一行上一个为1,一个为0
  3. A和B在这一行上的取值均为0

对于稀疏向量而言,大部分行都是属于第3类,而这种情况对等式两边都没有影响。假设第1类和第2类情况的数量分别为x和y,那么容易得到等式右边 \text{Jaccard}(A,B)=\frac{x}{x+y} 。对于等式左边,如果permutation是随机的话,那么向量A,B从上往下找,遇到的第一个非零行的情况属于第一类的概率也应为 \frac{x}{x+y} ,从而上面的等式成立。

假设我们对向量A,B做m次permutation(m一般为几百或更小,通常远小于原向量的长度n),每一次permutation得到MinHash值的映射记为 h_1, h_2, ... ,h_m ,那么向量A,B就分别被转换为两个signature向量:

sig(A) = [h_1(A), h_2(A), ..., h_m(A)]\\

sig(B) = [h_1(B), h_2(B), ..., h_m(B)]\\

这样只要计算这两个signature向量MinHash值相等的比例,即可以估计原向量A,B的Jaccard相似度。

Min Hashing的实现

上面理解Min Hashing的方式虽然很直观,但是在计算上却是很难实现:当n很大时,做m次permutation的时间复杂度是很高的。通常我们可以使用一个针对row index的哈希函数来达到permutation的效果,虽然可能会有哈希碰撞的情况产生,但是只要碰撞的概率不大,对估计的结果没有大的影响。于是便有了下面的Min Hashing算法:

  1. 取m个针对row index的哈希函数: h_1, h_2, ... ,h_m ,将 0,1,...,n-1 映射到 0,1,...,n-1
  2. Sig(i, \textbf{v})\textbf{v} 列原向量在第 i 个哈希函数下的minHash值,初始值可置为 \infty
  3. 对每一行 r
  • 计算 h_1(r), h_2(r), ..., h_m(r)
  • 对每一个列向量 \textbf{v}
    • 如果 \textbf{v}r 行的取值为0,则忽略
    • 如果 \textbf{v}r 行的取值为1,则对于 i=1,2,...,m ,设置 Sig(i, \textbf{v}) \leftarrow \text{min}\{ Sig(i, \textbf{v}), \ h_i(r) \}

至于哈希函数的选择,可以参考Spark中Min Hashing算法的实现,这里将核心代码提取如下:

import org.apache.spark.mllib.linalg.SparseVector
import scala.util.Random

/**
  * @param hashNum 签名向量的维度, hash函数的个数
  */
class MinHash(hashNum: Int) extends Serializable {
    
    val HASH_PRIME=2038074743
    val rand = new Random()
    
    /**
    * n个随机哈希函数的参数配置
    */
    val randCoefs: Array[(Int, Int)] = Array.fill(hashNum) {
        (1 + rand.nextInt(HASH_PRIME - 1), rand.nextInt(HASH_PRIME - 1))
    }

    def generateSignature(vector: SparseVector): Array[Int] = {
        val indexes = vector.indices
        val signatureVector = randCoefs.map { 
            case (a, b) => 
            indexes.map(index => ((1 + index) * a + b) % HASH_PRIME).min
        }
        signatureVector
    }
}

Locality Sensitive Hashing

上面的Min Hashing算法解决了前面所说的计算复杂度的第一个方面:它通过将向量A、B映射到低维空间中的两个签名向量,并且近似保持A、B之间的相似度,降低了用户相似度在物品维度很高的情况下的计算复杂度。但是当用户数目较大时(例如用户数 N>10^6 ),计算两两用户之间相似度就需要 C_N^2 次计算,显然这个计算量太大了。如果我们能先粗略地将用户分桶,将可能相似的用户以较大概率分到同一个桶内,这样每一个用户的“备选相似用户集”就会相对较小,降低寻找其相似用户的计算复杂度,LSH就是这样一个近似算法。

LSH的具体做法是在Min Hashing所得的signature向量的基础上,将每一个向量分为几段,称之为band,如下图所示:

每个signature向量被分成了4段,图上仅展示了各向量第一段的数值。其基本想法是:如果两个向量的其中一个或多个band相同,那么这两个向量就可能相似度较高;相同的band数越多,其相似度高的可能性越大。所以LSH的做法就是对各个用户的signature向量在每一个band上分别进行哈希分桶,在任意一个band上被分到同一个桶内的用户就互为candidate相似用户,这样只需要计算所有candidate用户的相似度就可以找到每个用户的相似用户群了。

这样一种基于概率的用户分桶方法当然会有漏网之鱼,我们希望下面两种情况的用户越少越好:

  • False Positives: 相似度很低的两个用户被哈希到同一个桶内
  • False Negatives: 真正相似的用户在每一个band上都没有被哈希到同一个桶内

实际操作中我们可以对每一个band使用同一个哈希函数,但是哈希分桶id需要每个band不一样,具体说来,假设向量 \textbf{A}、\textbf{B} 均被分为3个band:[ A_1 , A_2 , A_3 ]和[ B_1 , B_2 , B_3 ]。则:

  • 向量A分别被哈希到三个桶内:b1- H(A_1) ,b2- H(A_2) 和b3- H(A_3)
  • 向量B也被哈希到三个桶内:b1- H(B_1) ,b2- H(B_2) 和b3- H(B_3)

其中b1,b2,b3分别表示三个band标记,H(x)为哈希函数,这样即可完成candidate分桶。

LSH分桶优化

下面我们对signature向量的分桶概率作一些数值上的分析,以便针对具体应用确定相应的向量分段参数。假设我们将signature向量分为b个band,每个band的大小(也就是band内包含的行数)为r。假设两个用户向量之间的Jaccard相似度为s,前面我们知道signature向量的任意一行相同的概率等于Jaccard相似度s,我们可以按照以下步骤计算两个用户成为candidate用户的概率:

  • 两个signature向量的任意一个band内所有行的值都相同的概率为 s^r
  • 两个signature向量的任意一个band内至少有一行值不同的概率为 1-s^r
  • 两个signature向量的所有band都不同的概率为 (1-s^r)^b
  • 两个signature向量至少有一个band相同的概率为 1-(1-s^r)^b ,即为两个用户成为candidate用户的概率

这个概率在r和b取不同值时总是一个S形的曲线,例如当b=100,r=4时, 1-(1-s^4)^{100} 的曲线如下图所示

这个曲线的特点在于,当s超过一个阈值之后,两个用户成为candidate用户的概率会迅速增加并接近于1。这个阈值,也就是概率变化最陡的地方,近似为 t=(1/b)^{\frac{1}{r}} 。实际应用当中,我们需要首先决定 s>s_{min} 为多少才可以视为相似用户,以及signature向量的长度来确定这里的b和r,并考虑:

  1. 如果想要尽可能少的出现false negative,就需要选择b和r使得概率变化最陡的地方小于 s_{min} 。例如假设我们认为s在0.5以上才属于相似用户,那么我们就要选择b和r使得S曲线的最陡处小于0.5(上图所示的b=100,r=4就是一个较好的选择),这样的话,s在0.5以上的“真正”的相似用户就会以很大的概率成为candidate用户。
  2. 如果想要保证计算速度较快,并且尽可能少出现false positive,那么最好选择b和r使得概率变化最陡的地方较大,例如下图所示的b=20,r=6。这样的话,s较小的两个用户就很难成为candidate用户,但同时也会有一些“潜在”的相似用户不会被划分到同一个桶内。(candidate用户是一部分质量较高的相似用户)

这样针对具体应用,经过前期的数据探索之后,我们便可以为LSH算法设置具体的参数,使得在保证精度的情况下,提升计算效率。当然这里只是说明了Jaccard相似度下的LSH算法,对于其他的相似度度量比如余弦相似度等,可参考《mining of massive datasets》中chapter 3:finding similar items.


参考文献

编辑于 2018-10-10