給定N個集合,從中找到相似的集合對,如何實現呢?直觀的方法是比較任意兩個集合。那麼可以十分精確的找到每一對相似的集合,但是時間複雜度是O(n2)。此外,假如,N個集合中只有少數幾對集合相似,絕大多數集合都不相似,該方法在兩兩比較過程中“浪費了計算時間”。所以,如果能找到一種演算法,將大體上相似的集合聚到一起,縮小比對的範圍,這樣只用檢測較少的集合對,就可以找到絕大多數相似的集合對,大幅度減少時間開銷。雖然犧牲了一部分精度,但是如果能夠將時間大幅度減少,這種演算法還是可以接受的。接下來的內容講解如何使用Minhash和LSH(Locality-sensitive Hashing)來實現上述目的,在相似的集合較少的情況下,可以在O(n)時間找到大部分相似的集合對。
一、Jaccard相似度
判斷兩個集合是否相等,一般使用稱之為Jaccard相似度的演算法(後面用Jac(S1,S2)來表示集合S1和S2的Jaccard相似度)。舉個列子,集合X = {a,b,c},Y = {b,c,d}。那麼Jac(X,Y) = 2 / 4 = 0.50。也就是說,結合X和Y有50%的元素相同。下面是形式的表述Jaccard相似度公式:
Jac(X,Y) = |X∩Y| / |X∪Y|
也就是兩個結合交集的個數比上兩個集合並集的個數。範圍在[0,1]之間。
二、降維技術Minhash
原始問題的關鍵在於計算時間太長。如果能夠找到一種很好的方法將原始集合壓縮成更小的集合,而且又不失去相似性,那麼可以縮短計算時間。Minhash可以幫助我們解決這個問題。舉個例子,S1 = {a,d,e},S2 = {c, e},設全集U = {a,b,c,d,e}。集合可以如下表示:
行號 |
元素 |
S1 |
S2 |
類別 |
1 |
a |
1 |
0 |
Y |
2 |
b |
0 |
0 |
Z |
3 |
c |
0 |
1 |
Y |
4 |
d |
1 |
0 |
Y |
5 |
e |
1 |
1 |
X |
表1
表1中,列表示集合,行表示元素,值1表示某個集合具有某個值,0則相反(X,Y,Z的意義後面討論)。Minhash演算法大體思路是:採用一種hash函式,將元素的位置均勻打亂,然後將新順序下每個集合第一個元素作為該集合的特徵值。比如雜湊函式h1(i) = (i + 1) % 5,其中i為行號。作用於集合S1和S2,得到如下結果:
行號 |
元素 |
S1 |
S2 |
類別 |
1 |
e |
1 |
1 |
X |
2 |
a |
1 |
0 |
Y |
3 |
b |
0 |
0 |
Z |
4 |
c |
0 |
1 |
Y |
5 |
d |
1 |
0 |
Y |
Minhash |
e |
e |
|
表2
這時,Minhash(S1) = e,Minhash(S2) = e。也就是說用元素e表示S1,用元素e表示集合S2。那麼這樣做是否科學呢?進一步,如果Minhash(S1) 等於Minhash(S2),那麼S1是否和S2類似呢?
MinHash的合理性分析
首先給出結論,在雜湊函式h1均勻分佈的情況下,集合S1的Minhash值和集合S2的Minhash值相等的概率等於集合S1與集合S2的Jaccard相似度,即:
P(Minhash(S1) = Minhash(S2)) = Jac(S1,S2)
下面簡單分析一下這個結論。
S1和S2的每一行元素可以分為三類:
X類 均為1。比如表2中的第1行,兩個集合都有元素e。
Y類 一個為1,另一個為0。比如表2中的第2行,表明S1有元素a,而S2沒有。
Z類 均為0。比如表2中的第3行,兩個集合都沒有元素b。
這裡忽略所有Z類的行,因為此類行對兩個集合是否相似沒有任何貢獻。由於雜湊函式將原始行號均勻分佈到新的行號,這樣可以認為在新的行號排列下,任意一行出現X類的情況的概率為|X|/(|X|+|Y|)。這裡為了方便,將任意位置設為第一個出現X類行的行號。所以P(第一個出現X類) = |X|/(|X|+|Y|) = Jac(S1,S2)。這裡很重要的一點就是要保證雜湊函式可以將數值均勻分佈,儘量減少衝撞。
一般而言,會找出一系列的雜湊函式,比如h個(h << |U|),為每一個集合計算h次Minhash值,然後用h個Minhash值組成一個摘要來表示當前集合(注意Minhash的值的位置需要保持一致)。舉個列子,還是基於上面的例子,現在又有一個雜湊函式h2(i) = (i -1)% 5。那麼得到如下集合:
行號 |
元素 |
S1 |
S2 |
類別 |
1 |
b |
0 |
0 |
Z |
2 |
c |
0 |
1 |
Y |
3 |
d |
1 |
0 |
Y |
4 |
e |
1 |
1 |
X |
5 |
a |
1 |
0 |
Y |
Minhash |
d |
c |
|
表3
所以,現在用摘要表示的原始集合如下:
雜湊函式 |
S1 |
S2 |
h1(i) = (i + 1) % 5 |
e |
e |
h2(i) = (i - 1) % 5 |
d |
c |
表4
從表四還可以得到一個結論,令X表示Minhash摘要後的集合對應行相等的次數(比如表4,X=1,因為雜湊函式h1情況下,兩個集合的minhash相等,h2不等):
X ~ B(h,Jac(S1,S2))
X符合次數為h,概率為Jac(S1,S2)的二項分佈。那麼期望E(X) = h * Jac(S1,S2) = 2 * 2 / 3 = 1.33。也就是每2個hash計算Minhash摘要,可以期望有1.33元素對應相等。所以,Minhash在壓縮原始集合的情況下,保證了集合的相似度沒有被破壞。
三、LSH – 區域性敏感雜湊
現在有了原始集合的摘要,但是還是沒有解決最初的問題,仍然需要遍歷所有的集合對,才能所有相似的集合對,複雜度仍然是O(n2)。所以,接下來描述解決這個問題的核心思想LSH。其基本思路是將相似的集合聚集到一起,減小查詢範圍,避免比較不相似的集合。仍然是從例子開始,現在有5個集合,計算出對應的Minhash摘要,如下:
|
S1 |
S2 |
S3 |
S4 |
S5 |
區間1 |
b |
b |
a |
b |
a |
c |
c |
a |
c |
b |
|
d |
b |
a |
d |
c |
|
區間2 |
a |
e |
b |
e |
d |
b |
d |
c |
f |
e |
|
e |
a |
d |
g |
a |
|
區間3 |
d |
c |
a |
h |
b |
a |
a |
b |
b |
a |
|
d |
e |
a |
b |
e |
|
區間4 |
d |
a |
a |
c |
b |
b |
a |
c |
b |
a |
|
d |
e |
a |
b |
e |
表5
上面的集合摘要採用了12個不同的hash函式計算出來,然後分成了B = 4個區間。前面已經分析過,任意兩個集合(S1,S2)對應的Minhash值相等的概率r = Jac(S1,S2)。先分析區間1,在這個區間內,P(集合S1等於集合S2) = r3。所以只要S1和S2的Jaccard相似度越高,在區間1內越有可能完成全一致,反過來也一樣。那麼P(集合S1不等於集合S2) = 1 - r3。現在有4個區間,其他區間與第一個相同,所以P(4個區間上,集合S1都不等於集合S2) = (1 – r3)4。P(4個區間上,至少有一個區間,集合S1等於集合S2) = 1 - (1 – r3)4。這裡的概率是一個r的函式,形狀猶如一個S型,如下:
圖1
如果令區間個數為B,每個區間內的行數為C,那麼上面的公式可以形式的表示為:
P(B個區間中至少有一個區間中兩個結合相等) = 1 - (1 – rC)B
令r = 0.4,C=3,B = 100。上述公式計算的概率為0.9986585。這表明兩個Jaccard相似度為0.4的集合在至少一個區間內衝撞的概率達到了99.9%。根據這一事實,我們只需要選取合適的B和C,和一個衝撞率很低的hash函式,就可以將相似的集合至少在一個區間內衝撞,這樣也就達成了本節最開始的目的:將相似的集合放到一起。具體的方法是為B個區間,準備B個hash表,和區間編號一一對應,然後用hash函式將每個區間的部分集合對映到對應hash表裡。最後遍歷所有的hash表,將衝撞的集合作為候選物件進行比較,找出相識的集合對。整個過程是採用O(n)的時間複雜度,因為B和C均是常量。由於聚到一起的集合相比於整體比較少,所以在這小範圍內互相比較的時間開銷也可以計算為常量,那麼總體的計算時間也是O(n)。
四、程式碼實現
方法一:引用python包datasketch
安裝:
pip install datasketch
使用示例如下:
MinHash
from datasketch import MinHash data1 = ['minhash', 'is', 'a', 'probabilistic', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'datasets'] data2 = ['minhash', 'is', 'a', 'probability', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'documents'] m1, m2 = MinHash(), MinHash() for d in data1: m1.update(d.encode('utf8')) for d in data2: m2.update(d.encode('utf8')) print("Estimated Jaccard for data1 and data2 is", m1.jaccard(m2)) s1 = set(data1) s2 = set(data2) actual_jaccard = float(len(s1.intersection(s2)))/float(len(s1.union(s2))) print("Actual Jaccard for data1 and data2 is", actual_jaccard)
MinHash LSH
from datasketch import MinHash, MinHashLSH set1 = set(['minhash', 'is', 'a', 'probabilistic', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'datasets']) set2 = set(['minhash', 'is', 'a', 'probability', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'documents']) set3 = set(['minhash', 'is', 'probability', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'documents']) m1 = MinHash(num_perm=128) m2 = MinHash(num_perm=128) m3 = MinHash(num_perm=128) for d in set1: m1.update(d.encode('utf8')) for d in set2: m2.update(d.encode('utf8')) for d in set3: m3.update(d.encode('utf8')) # Create LSH index lsh = MinHashLSH(threshold=0.5, num_perm=128) lsh.insert("m2", m2) lsh.insert("m3", m3) result = lsh.query(m1) print("Approximate neighbours with Jaccard similarity > 0.5", result)
MinHash LSH Forest——區域性敏感隨機投影森林
from datasketch import MinHashLSHForest, MinHash data1 = ['minhash', 'is', 'a', 'probabilistic', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'datasets'] data2 = ['minhash', 'is', 'a', 'probability', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'documents'] data3 = ['minhash', 'is', 'probability', 'data', 'structure', 'for', 'estimating', 'the', 'similarity', 'between', 'documents'] # Create MinHash objects m1 = MinHash(num_perm=128) m2 = MinHash(num_perm=128) m3 = MinHash(num_perm=128) for d in data1: m1.update(d.encode('utf8')) for d in data2: m2.update(d.encode('utf8')) for d in data3: m3.update(d.encode('utf8')) # Create a MinHash LSH Forest with the same num_perm parameter forest = MinHashLSHForest(num_perm=128) # Add m2 and m3 into the index forest.add("m2", m2) forest.add("m3", m3) # IMPORTANT: must call index() otherwise the keys won't be searchable forest.index() # Check for membership using the key print("m2" in forest) print("m3" in forest) # Using m1 as the query, retrieve top 2 keys that have the higest Jaccard result = forest.query(m1, 2) print("Top 2 candidates", result)
方法二
minHash原始碼實現如下:
from random import randint, seed, choice, random import string import sys import itertools def generate_random_docs(n_docs, max_doc_length, n_similar_docs): for i in range(n_docs): if n_similar_docs > 0 and i % 10 == 0 and i > 0: permuted_doc = list(lastDoc) permuted_doc[randint(0,len(permuted_doc))] = choice('1234567890') n_similar_docs -= 1 yield ''.join(permuted_doc) else: lastDoc = ''.join(choice('aaeioutgrb ') for _ in range(randint(int(max_doc_length*.75), max_doc_length))) yield lastDoc def generate_shingles(doc, shingle_size): shingles = set([]) for i in range(len(doc)-shingle_size+1): shingles.add(doc[i:i+shingle_size]) return shingles def get_minhash(shingles, n_hashes, random_strings): minhash_row = [] for i in range(n_hashes): minhash = sys.maxsize for shingle in shingles: hash_candidate = abs(hash(shingle + random_strings[i])) if hash_candidate < minhash: minhash = hash_candidate minhash_row.append(minhash) return minhash_row def get_band_hashes(minhash_row, band_size): band_hashes = [] for i in range(len(minhash_row)): if i % band_size == 0: if i > 0: band_hashes.append(band_hash) band_hash = 0 band_hash += hash(minhash_row[i]) return band_hashes def get_similar_docs(docs, n_hashes=400, band_size=7, shingle_size=3, collectIndexes=True): hash_bands = {} random_strings = [str(random()) for _ in range(n_hashes)] docNum = 0 for doc in docs: shingles = generate_shingles(doc, shingle_size) minhash_row = get_minhash(shingles, n_hashes, random_strings) band_hashes = get_band_hashes(minhash_row, band_size) docMember = docNum if collectIndexes else doc for i in range(len(band_hashes)): if i not in hash_bands: hash_bands[i] = {} if band_hashes[i] not in hash_bands[i]: hash_bands[i][band_hashes[i]] = [docMember] else: hash_bands[i][band_hashes[i]].append(docMember) docNum += 1 similar_docs = set() for i in hash_bands: for hash_num in hash_bands[i]: if len(hash_bands[i][hash_num]) > 1: for pair in itertools.combinations(hash_bands[i][hash_num], r=2): similar_docs.add(pair) return similar_docs if __name__ == '__main__': n_hashes = 200 band_size = 7 shingle_size = 3 n_docs = 1000 max_doc_length = 40 n_similar_docs = 10 seed(42) docs = generate_random_docs(n_docs, max_doc_length, n_similar_docs) similar_docs = get_similar_docs(docs, n_hashes, band_size, shingle_size, collectIndexes=False) print(similar_docs) r = float(n_hashes/band_size) similarity = (1/r)**(1/float(band_size)) print("similarity: %f" % similarity) print("# Similar Pairs: %d" % len(similar_docs)) if len(similar_docs) == n_similar_docs: print("Test Passed: All similar pairs found.") else: print("Test Failed.")
參考:
https://www.cnblogs.com/bourneli/archive/2013/04/04/2999767.html
https://blog.csdn.net/weixin_43098787/article/details/82838929