最近在工作中需要去优化离职同事留下的用户协同过滤算法,本来想协同过滤嘛,不就是一顿算相似度,然后取top-k相似的用户去做推荐就完了。结果看代码的过程中,对计算相似度的部分却是一头雾水,主要是对其中使用的LSH算法不甚了解。经过了一番调研之后,才算是理解了这个算法的精妙,也感到自己之前的粗糙想法实在是naive。
传统的协同过滤算法,不管是基于用户还是基于物品的,其中最关键的一个问题便是:计算两个用户(或物品)之间的相似度。相似度的计算有多种方式:欧氏距离、余弦相似度或者Jaccard相似度,不管以何种计算方式,在数据维度较小时,都可以用naive的方式直接遍历每一个pair去计算。但当数据维度增大到一定程度时,计算复杂度就开始飙升了,主要体现在两个方面(以计算用户相似度为例):
对于工业界的数据,用户和物品的维度都在千万甚至更高的情况下,直接计算两两之间的相似度,即便使用大规模计算集群有可能实现,所需要的计算成本也是极高的。这时便需要使用近似算法,牺牲一些精度来大大提高计算效率。Min Hashing和Locality Sensitive Hashing(LSH,局部敏感哈希)便是用来分别提高这两个方面的计算效率的。
首先我们定义一下变量的记号:假设有两个用户,用向量A和B来表示,其长度为n(也就是item的维度)。A和B向量中的非零值个数分别为 和 ,A、B向量中共同的非零值个数为 ,则Jaccard相似度可定义为:
当a,b的值较大的话,计算Jaccard相似度的复杂度也是线性增长的,如何减小这个计算复杂度就是MinHash想要去解决的问题。简单来说,MinHash所做的事情就是:将向量A、B映射到一个低维空间,并且近似保持A、B之间的相似度。
如何得到这样的映射呢?我们现将用户A、B用物品向量的形式表达如下:
其中 到 表示n个物品,所谓的MinHash是这样一个操作:
得到向量A,B的MinHash值之后,有这样一个重要的结论:
要理解这个等式,可以考虑向量A,B每一行的取值可以分为三类:
对于稀疏向量而言,大部分行都是属于第3类,而这种情况对等式两边都没有影响。假设第1类和第2类情况的数量分别为x和y,那么容易得到等式右边 。对于等式左边,如果permutation是随机的话,那么向量A,B从上往下找,遇到的第一个非零行的情况属于第一类的概率也应为 ,从而上面的等式成立。
假设我们对向量A,B做m次permutation(m一般为几百或更小,通常远小于原向量的长度n),每一次permutation得到MinHash值的映射记为 ,那么向量A,B就分别被转换为两个signature向量:
这样只要计算这两个signature向量MinHash值相等的比例,即可以估计原向量A,B的Jaccard相似度。
上面理解Min Hashing的方式虽然很直观,但是在计算上却是很难实现:当n很大时,做m次permutation的时间复杂度是很高的。通常我们可以使用一个针对row index的哈希函数来达到permutation的效果,虽然可能会有哈希碰撞的情况产生,但是只要碰撞的概率不大,对估计的结果没有大的影响。于是便有了下面的Min Hashing算法:
至于哈希函数的选择,可以参考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
}
}
上面的Min Hashing算法解决了前面所说的计算复杂度的第一个方面:它通过将向量A、B映射到低维空间中的两个签名向量,并且近似保持A、B之间的相似度,降低了用户相似度在物品维度很高的情况下的计算复杂度。但是当用户数目较大时(例如用户数 ),计算两两用户之间相似度就需要 次计算,显然这个计算量太大了。如果我们能先粗略地将用户分桶,将可能相似的用户以较大概率分到同一个桶内,这样每一个用户的“备选相似用户集”就会相对较小,降低寻找其相似用户的计算复杂度,LSH就是这样一个近似算法。
LSH的具体做法是在Min Hashing所得的signature向量的基础上,将每一个向量分为几段,称之为band,如下图所示:
每个signature向量被分成了4段,图上仅展示了各向量第一段的数值。其基本想法是:如果两个向量的其中一个或多个band相同,那么这两个向量就可能相似度较高;相同的band数越多,其相似度高的可能性越大。所以LSH的做法就是对各个用户的signature向量在每一个band上分别进行哈希分桶,在任意一个band上被分到同一个桶内的用户就互为candidate相似用户,这样只需要计算所有candidate用户的相似度就可以找到每个用户的相似用户群了。
这样一种基于概率的用户分桶方法当然会有漏网之鱼,我们希望下面两种情况的用户越少越好:
实际操作中我们可以对每一个band使用同一个哈希函数,但是哈希分桶id需要每个band不一样,具体说来,假设向量 均被分为3个band:[ , , ]和[ , , ]。则:
其中b1,b2,b3分别表示三个band标记,H(x)为哈希函数,这样即可完成candidate分桶。
下面我们对signature向量的分桶概率作一些数值上的分析,以便针对具体应用确定相应的向量分段参数。假设我们将signature向量分为b个band,每个band的大小(也就是band内包含的行数)为r。假设两个用户向量之间的Jaccard相似度为s,前面我们知道signature向量的任意一行相同的概率等于Jaccard相似度s,我们可以按照以下步骤计算两个用户成为candidate用户的概率:
这个概率在r和b取不同值时总是一个S形的曲线,例如当b=100,r=4时, 的曲线如下图所示
这个曲线的特点在于,当s超过一个阈值之后,两个用户成为candidate用户的概率会迅速增加并接近于1。这个阈值,也就是概率变化最陡的地方,近似为 。实际应用当中,我们需要首先决定 为多少才可以视为相似用户,以及signature向量的长度来确定这里的b和r,并考虑:
这样针对具体应用,经过前期的数据探索之后,我们便可以为LSH算法设置具体的参数,使得在保证精度的情况下,提升计算效率。当然这里只是说明了Jaccard相似度下的LSH算法,对于其他的相似度度量比如余弦相似度等,可参考《mining of massive datasets》中chapter 3:finding similar items.
参考文献