文本類似性計算--MinHash和LSH算法

  給定N個集合,從中找到類似的集合對,如何實現呢?直觀的方法是比較任意兩個集合。那麼能夠十分精確的找到每一對類似的集合,可是時間複雜度是O(n2)。此外,假如,N個集合中只有少數幾對集合類似,絕大多數集合都不類似,該方法在兩兩比較過程當中「浪費了計算時間」。因此,若是能找到一種算法,將大致上類似的集合聚到一塊兒,縮小比對的範圍,這樣只用檢測較少的集合對,就能夠找到絕大多數類似的集合對,大幅度減小時間開銷。雖然犧牲了一部分精度,可是若是可以將時間大幅度減小,這種算法仍是能夠接受的。接下來的內容講解如何使用Minhash和LSH(Locality-sensitive Hashing)來實現上述目的,在類似的集合較少的狀況下,能夠在O(n)時間找到大部分類似的集合對。html

1、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類似度公式:python

Jac(X,Y) = |X∩Y| / |X∪Y|算法

  也就是兩個結合交集的個數比上兩個集合並集的個數。範圍在[0,1]之間。 markdown

2、降維技術Minhash

  原始問題的關鍵在於計算時間太長。若是可以找到一種很好的方法將原始集合壓縮成更小的集合,並且又不失去類似性,那麼能夠縮短計算時間。Minhash能夠幫助咱們解決這個問題。舉個例子,S1 = {a,d,e},S2 = {c, e},設全集U = {a,b,c,d,e}。集合能夠以下表示:app

行號dom

元素函數

S1post

S2atom

類別spa

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(S­1) = 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在壓縮原始集合的狀況下,保證了集合的類似度沒有被破壞。 

3、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。因此只要S­1和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)。

4、代碼實現

  方法一:引用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

相關文章
相關標籤/搜索