大規模數據的相似度計算:LSH算法

前言

最近在工作中需要去優化離職同事留下的用戶協同過濾算法,本來想協同過濾嘛,不就是一頓算相似度,然後取top-k相似的用戶去做推薦就完了。結果看代碼的過程中,對計算相似度的部分卻是一頭霧水,主要是對其中使用的LSH算法不甚瞭解。經過了一番調研之後,纔算是理解了這個算法的精妙,也感到自己之前的粗糙想法實在是naive。

傳統的協同過濾算法,不管是基於用戶還是基於物品的,其中最關鍵的一個問題便是:計算兩個用戶(或物品)之間的相似度。相似度的計算有多種方式:歐氏距離、餘弦相似度或者Jaccard相似度,不管以何種計算方式,在數據維度較小時,都可以用naive的方式直接遍歷每一個pair去計算。但當數據維度增大到一定程度時,計算複雜度就開始飆升了,主要體現在兩個方面(以計算用戶相似度爲例):

  1. 兩個用戶之間相似度的計算隨着物品維度的增加而增加
  2. 計算每一個用戶和其他所有用戶之間的相似度的複雜度隨着用戶規模的增長,呈平方增長

對於工業界的數據,用戶和物品的維度都在千萬甚至更高的情況下,直接計算兩兩之間的相似度,即便使用大規模計算集羣有可能實現,所需要的計算成本也是極高的。這時便需要使用近似算法,犧牲一些精度來大大提高計算效率。Min Hashing和Locality Sensitive Hashing(LSH,局部敏感哈希)便是用來分別提高這兩個方面的計算效率的。

Min Hashing

首先我們定義一下變量的記號:假設有兩個用戶,用向量A和B來表示,其長度爲n(也就是item的維度)。A和B向量中的非零值個數分別爲 a 和 b ,A、B向量中共同的非零值個數爲 c ,則Jaccard相似度可定義爲:

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

當a,b的值較大的話,計算Jaccard相似度的複雜度也是線性增長的,如何減小這個計算複雜度就是MinHash想要去解決的問題。簡單來說,MinHash所做的事情就是:將向量A、B映射到一個低維空間,並且近似保持A、B之間的相似度

如何得到這樣的映射呢?我們現將用戶A、B用物品向量的形式表達如下:

其中 i_1 到 i_n 表示n個物品,所謂的MinHash是這樣一個操作:

  • 首先對 i_1 、 i_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.

 

參考文獻

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章