Minhash算法直观理解
作者: @凌漪_ @板烧鱼仔 @Yuxn.
背景
- Jaccard相似度
两个集合 A 和 B,我们关心它们的Jaccard相似度
J ( A , B ) = ∣ A ∪ B ∣ ∣ A ∩ B ∣ J(A,B)=\frac{∣A∪B∣}{∣A∩B∣} J(A,B)=∣A∩B∣∣A∪B∣
Jaccard相似度描述了两个集合之间的相似程度。
使用场景1:两个文档之间的相似度。注意: jaccard相似度并没有提取文档的任何语义,只是在查看它们是否包含相同的单词。因此,“大王八”和“八大王”的jaccard相似度为1。在计算文档之间的相似度时,通常不会基于词级别,而是n-gram(n个连续单词),再计算交集和并集的元素的个数,得到jaccard相似度。
使用场景2:你的歌单和我的歌单之间的相似度 - 计算Jaccard效率低下
当拥有一个大型的文档集合时(比如里面有N篇文档),我们需要找出其中相似度高的文档对。此时就需要对文档进行来两两比较,计算Jaccard,得到一个 N ∗ N N * N N∗N的矩阵。(当然,为了避免重复进行2次计算:文档A与文档B,文档B与文档A,其中有一半上三角的值为0),所需要的比较次数为 ( N 2 ) / 2 N^2) /2 N2)/2,计算复杂度为 O ( N 2 ) O(N^2) O(N2)。而在每次进行jaccard比较时,需要交集和并集,计算复杂度取决于文档的大小,假设所有文档的长度相等,都包含M个单词,则计算复杂度为 O ( N 2 ⋅ M ) O(N^2 · M) O(N2⋅M)。就会耗费很长时间,计算和内存消耗变得不可承受 - Jaccard的计算替代方式:Minhash算法
Minhash的好处:Minhash可以实现把一篇文章用一个较短的signature表示,这个signature有个很好的性质: 两个minhash signature的Jaccard相似度和原始文本的Jaccard相似度在概率上是一致的。 比较一次的所耗费的时间为numHash,总的计算复杂度为 O ( N 2 ⋅ n u m H a s h ) O(N^2 · numHash) O(N2⋅numHash)。而numHash是可以自己设置的大小,这就远小于M。
minhash如何工作?
先介绍它的工作方式,可能能帮助你更好理解这个算法。
前提假设:如果两个集合非常相似 → 那么对这两个集合应用同一种变化,得到的变化结果也很相似 → 对这两个变化结果选取某种特征(比如选最小值),它们有很高的概率是相等的。
- 假设我们拥有2个集合
# 设置2个集合A和B
A = {1, 2, 3, 4, 5}
B = {3, 4, 5, 6, 7}
- 其jaccard相似度为:
print('Jaccard相似度:', len(A & B) / len(A | B))
Jaccard相似度: 0.4285
基于hash函数的算法
我们拥有哈希函数(有点像加密算法),它能将整数x映射到一个不同的整数,并且不会产生冲突(c 是大素数)。不同的哈希函数相当于不同的投影规则
h
a
s
h
(
x
)
=
(
a
x
+
b
)
%
c
hash(x) = (ax + b )\% c
hash(x)=(ax+b)%c
#设置某个哈希函数
def hash(x, a=1, b=1, p=11):
return (a * x + b) % p
#对集合里的元素进行哈希,并计算最小哈希值
def minhash(s, hash):
return min(hash(x) for x in s)
我们将某个文档A经过hash函数之后(Hash(A)),记录所得到的最小哈希值(minHash(A))。minHash(A)相当于此集合在某种观测视角下得到的结果。我们对文档B也进行计算,得到
Hash(A) = [5, 8, 0, 3, 6] minHash(A) = 0
Hash(B) = [0, 3, 6, 9, 1] minHash(B) = 0
最后,minHash(A) = minHash(B)的概率就是Jaccard相似度。这一步我们在之后会进行解释。
实际上,单次比较 minHash(A) 和 minHash(B),查看取值是否相等,这个事件的取值只有True or False。(就和抛硬币一样)只有我们经过多次hash函数操作(相当于经过了多次抛硬币),该事件发生的概率值理论上趋近于(A ∩ B)/ ( A ∪ B)。
因此,使用minhash方法对Jaccard的低维估计计算方式就是:
m
i
n
H
a
s
h
(
A
)
=
m
i
n
H
a
s
h
(
B
)的次数
h
a
s
h
函数的个数
\frac{minHash(A) = minHash(B)的次数}{hash函数的个数}
hash函数的个数minHash(A)=minHash(B)的次数
- 接下来我们进行多个不同的hash函数的实验,并观察其变化趋势:
如果哈希函数产生的结果高度相关(甚至相同),则这些哈希函数提供的不同视角就减少了。这意味着我们实际上并没有从多个独立的角度去观察集合的相似性,从而可能导致估计的Jaccard相似度不够准确
生成k个不相同的随机数:
import random
def pickRandomCoeffs(k):
randList = []
while k > 0:
randIndex = random.randint(0, 99999)
while randIndex in randList:
randIndex = random.randint(0, 99999)
randList.append(randIndex)
k = k - 1
return randList
基于不相同的随机数,作为a和b,生成不同hash函数
#生成n个不同的hash函数,确保每个hash函数的a系数和b系数不同:
num_hash_funcs = 10000
hash_funcs = []
a = pickRandomCoeffs(num_hash_funcs)
b = pickRandomCoeffs(num_hash_funcs)
for i in range(num_hash_funcs):
#增加不同的hash函数,确保每次生成的hash函数不同
hash_funcs.append(lambda x, a=a[i], b=b[i]: hash(x, a, b))
count = 0
for a, b in zip(minhash_values_A, minhash_values_B):
#如果hash函数得到的minhash相同
if a == b:
count += 1
print('进行比较的哈希函数个数:', len(hash_funcs))
print('Minhash相似度:', count/ len(hash_funcs))
进行比较的哈希函数个数: 5 Minhash相似度: 0.6
进行比较的哈希函数个数: 100 Minhash相似度: 0.38
进行比较的哈希函数个数: 1000 Minhash相似度: 0.411
进行比较的哈希函数个数: 5000 Minhash相似度: 0.4242
Jaccard相似度的理论值为: 0.4285。随着hash函数的个数增加,实验值逐渐逼近 0.4285
基于打乱排序的做法
hash函数相当于一种对集合的变换方式,但实际应用时,如果我们有多个hash函数,需要对每个集合计算minhash,而哈希操作的速度也比较慢。
既然只是找到一种对集合的变换方式,再用另一种视角来观察这个集合的特征值,有没有另一种更方便的变换方法呢?
我们可以使用基于打乱排序的算法。
假设两个文档的内容如下:
文档A:{上山打老虎}
文档B:{老虎不在家}
将其转为one-hot编码如下:
原始下标 | 词 | 文档A | 文档B |
---|---|---|---|
0 | 上 | 1 | 0 |
1 | 山 | 1 | 0 |
2 | 打 | 1 | 0 |
3 | 老 | 1 | 1 |
4 | 虎 | 1 | 1 |
5 | 不 | 0 | 1 |
6 | 在 | 0 | 1 |
7 | 家 | 0 | 1 |
观测视角是:进行行变换,打乱排序,查找两个文档第一个为1的下标,判断它们是否相等
打乱行的排序:
原始下标 | idx | 词 | 文档A | 文档B |
---|---|---|---|---|
1 | 0 | 山 | 1 | 0 |
7 | 1 | 家 | 0 | 1 |
2 | 2 | 打 | 1 | 0 |
3 | 3 | 老 | 1 | 1 |
5 | 4 | 不 | 0 | 1 |
6 | 5 | 在 | 0 | 1 |
4 | 6 | 虎 | 1 | 1 |
0 | 7 | 上 | 1 | 0 |
文档A变成{1,0,1,1,0,0,1,1},第一个为1的jdx为0,(对应’山‘)
文档B变成{0,1,0,1,1,1,1,0},第一个为1的idx为1,(对应’家’)
0 != 1,表示在这次打乱中,两个文档第一个为1的下标不相等。
我们进行多次这样的打乱排序,并统计他们相等的次数,除以总次数,得到的就是minhash对jaccard相似度的估计。
具体代码如下:
import random
# 设置2个集合A和B
A = [0,0,1,1,1]
B = [1,0,1,1,0]
idx = [0,1,2,3,4]
cnt = 0
# 计算Jaccard相似度
print("Jaccard相似度理论值",sum([A[i] & B[i] for i in range(5)]) / sum([A[i] | B[i] for i in range(5)]))
for i in range(1000):
random.shuffle(idx)
newA = [A[i] for i in idx]
newB = [B[i] for i in idx]
# 第一个为1的位置
idxA = newA.index(1)
idxB = newB.index(1)
if idxA == idxB:
cnt += 1
print("Jaccard相似度实验值",cnt / 1000)
Jaccard相似度理论值 0.5
Jaccard相似度实验值 0.497
在使用的时候,也可以首先计算一次hash值,得到hash(X_i),然后对其进行线性变换,这样就省去了多次计算hash值的操作。
p
e
r
(
x
i
)
=
(
a
⋅
h
a
s
h
(
x
i
)
+
b
)
%
c
per(x_i) = (a\cdot{hash(x_i)} + b)\% c
per(xi)=(a⋅hash(xi)+b)%c
m
i
n
h
a
s
h
(
x
)
=
m
i
n
(
p
e
r
(
x
i
)
)
minhash(x) = min(per(x_i))
minhash(x)=min(per(xi))
为什么minhash有效?
case1:
”横看成岭侧成峰,远近高低各不同“ ——苏轼《题西林壁》
minhash相当于在不同视角(投影角度)下观察两个集合的特征点是否相同,如果他们在多次观察下都具有相同的特征点,那么他们很有可能是分布相似的集合。
我们想象现在存在两座山,如果A山和B山从东西南北四个角度观察,他们的最高点高度相同,次高点、次次高点的高度都相同,那么这两座山很可能就是形状一样的山。
minhash在实际操作上很容易,但为什么minHash(A) = minHash(B)的概率就是Jaccard相似度呢?
case2:
P
r
(
m
i
n
H
a
s
h
(
A
)
=
m
i
n
H
a
s
h
(
B
)
)
=
A
∩
B
A
∪
B
=
J
a
c
c
a
r
d
(
A
,
B
)
Pr(minHash(A) = minHash(B)) = \frac{A \cap B }{A \cup B } = Jaccard(A,B)
Pr(minHash(A)=minHash(B))=A∪BA∩B=Jaccard(A,B)
为了方便理解,我们这里还是举一个直观的例子:
有一个班级,里面有30个小朋友,里面的小朋友不是会唱歌,就是会跳舞,还有的既会唱歌也会跳舞。
此时集合 A = { 会唱歌的小朋友 } A = \{会唱歌的小朋友\} A={会唱歌的小朋友}, B = { 会跳舞的小朋友 } B = \{会跳舞的小朋友\} B={会跳舞的小朋友}, A ∪ B A \cup B A∪B 构成了全集 D D D
我们寻找一种相同的投影维度(一种哈希函数)将小朋友们排序,并选出顺序第一的人。例如各自选出集合A和B中身高最高的小朋友,他们是同一个小朋友的概率,等价于在全班所有小朋友中都选出最高的小朋友,他既会唱歌也会跳舞的概率。
在这个例子中,身高就是一种将小朋友们向一个方向投影的哈希函数,同理还可以使用体重、力气、吃零食速度、谁哭得最响等等多个维度,最终我们多次投影得到计算的概率值就可以用来近似 A ∩ B A ∪ B \frac{A \cap B }{A \cup B } A∪BA∩B。
转化成数学公式,就是: P r ( 某一种投影 ( A ) = 某一种投影 ( B ) ) = A ∩ B A ∪ B Pr(某一种投影(A) = 某一种投影(B)) = \frac{A \cap B }{A \cup B } Pr(某一种投影(A)=某一种投影(B))=A∪BA∩B
当多次投影都能得到相同的值时,我们就可以认为这两个集合很相似啦!
参考文献
使用MinHash算法计算两个集合的相似度 - unrealwalker - 博客园 (cnblogs.com)
MinHash 教程与 Python 代码 · 克里斯·麦科米克 — MinHash Tutorial with Python Code · Chris McCormick (mccormickml.com)
ansvver | 局部敏感哈希 - MinHash