接着上一篇的博客继续下去,这篇博客主要讲下最小哈希签名的东西。
对于上篇博客中提到的shingle,可以说是在压缩数据量的基础上又尽可能保留了源文档的特征,以便于后面对不同的文档进行相似度比较。但是我们会发现,shingle集合非常大,即使将每个shingle都哈希为4个字节,一篇文档的shingle集合所需要的空间仍然大概是该文档所需空间的4倍(这是因为shingle分词的特性,导致分词后shingle的 个数略等于文档中字母的个数,而一个字母一般在计算机中占一个字节,shingle哈希占了4个字节,故而是4倍左右)。那么可想而知,如果有数百万篇文档,很可能不能将这些文档的shingle集合都放入内存中,或者及时所有的集合都可以放入到内存中,那所需要对的数目也可能会多到无法估计没对的相似度。
这里就需要将上述大集合替换成规模很多的“签名”(signature)表示。所谓签名,在日常生活中我们很多地方都需要签名,进而代表我们个人,我们这里的“签名”其实代表的 就是一篇文档,它是由文档中经过特殊方法选择出的具有代表性的数据组成。对于签名而言,我们所需要的重要特征是能够仅仅通过比较两篇文档的签名集合就可以估计实际shingle集合之间的Jaccard相似度。当然,通过签名无法得到原始shingle集合之间Jaccard相似度的精确值,但是估计结果与真实结果相差不大,并且签名集合越大,估计的经度越高。例如,50000字节文档的shingle可能会映射为200000字节的哈希结果,然后替换成1000字节大小的签名集合。基于最终签名集合得到的原始文档Jaccard相似度的估计值与真实值的差异也就在几个百分点之内。
集合的矩阵表示
在介绍如何构建哈希签名之前,首先来说一下如何将一系列集合表示成其特征矩阵。矩阵的列对应集合,行对应全集(所有集合中可能的元素组成全集)中的元素。如果行r对应的元素属于列c对应的集合,那么矩阵第r行第r列的元素为1,否则为0。
例如:
下图给出了全集{a,b,c,d,e}中元素组成的多个集合的矩阵表示。这里S1={a,d},S2={c},S3={b,d,e},S4={a,c,d}。图中最上面一行和最左边一列并非矩阵的一部分,而是表示各行和各列的含义。
图1
需要记住的是,特征矩阵并非数据真正的存储方式,但是作为数据可视化的一种方式则是非常有用的。在实际当中,数据不会存储为矩阵的一个原因是该矩阵往往非常稀疏(0的个数远多于1)。只存储1所在的位置能够大大节省存储的开销,同时又能完整地表示整个矩阵。另外一个原因是,数据往往基于其它目的而存储成其它格式。
最小哈希
想要构建的集合的签名由大量计算(比如数百次 )的结果组成,而每次计算是特征矩阵的最小哈希过程。
为了对特征矩阵每列所表示的集合进行最小哈希计算,首先选择行的一个排列转换(即是将行号重新排列)。任意一列的最小哈希值是在排列转换后的行排列次序下第一个列值为1的行的行号。
例如:
对于图1中的矩阵,假定采用beadc的行序重新排列,如下图。改排列转换定义了一个最小哈希函数h,它将某个集合映射成一行。接下来我们基于函数h计算集合S1的最小哈希值。按照beacd的顺序来扫描集合S1所对应的第一列,由于b行对应的值为0,所以需要往下继续扫描到e行,即排列转换次序中的第二行,其对应的S1列的值仍然是0.于是再往下处理到行a,此时其对应的值为1,因此,就有了h(S1)=a。
尽管物理上不可能对非常大的特征矩阵进行排列转换,最小哈希 函数h却隐式地将图1矩阵的行重新排列,使之变成图2中的举证。在新矩阵中,h函数的值可以通过从上往下扫描至遇到1为止。因此,我们有h(S2)=c、h(S3)=b及h(S4)=a。
图2
最小哈希及Jaccard相似度
在集合的Jaccard相似度及集合的最小哈希函数值之间存在着非同寻常的关联:
□ 两个集合经随机排列转换之后得到的两个最小哈希值相等的概率等于这两个集合的Jaccard相似度。
为了理解上述结论的原因,必须要对两个集合同一列对应的所有可能结果进行枚举。假设只考虑结合S1和S2所对应的列,那么他们所在的行可以按照所有可能的结果分成三类:
(1)数据X类的行,两列的值均为1;
(2)数据Y类的行,其中一列的值为0,另一列的值为1;
(3)属于Z类的行,两列的值均为0。
由于特征矩阵十分稀疏,因此大部分行都属于Z类。但是X和Y类行数目的比例决定了SIM(S1,S2)及概率h(S1)=h(S2)的大小。假定X类行的数目为x,Y类的行的数目为ym,则SIM(S1,S2)=x/(x+y)。原因是S1∩S2的大小为x而S1∪S2的大小为x+y。
接下来考虑h(S1)=h(S2)的概率。设想所有行进行随机排列转换,然后我们从上到下进行扫描处理,在碰到Y类行之前碰到X类行的概率为x/(x+y)。但是如果从上往下扫描遇到的除Z类行之外的第一行属于X类,那么肯定有h(S1)=h(S2)。另一方面,如果首先碰到的是Y类行,而不是Z类行,那么值为1的那个集合的最小哈希值为当前行。但值为0的那个集合必将会进一步扫描下去。因此 ,如果首先碰到Y类行,那么此时h(S1)≠h(S2).于是,我们可以得到最终结论,即h(S1)=h(S2)的概率为x/(x+y),而这也是两个集合Jaccard相似度的计算公式。
最小哈希签名
此处将会继续讲解前面介绍的一系列集合的特征矩阵表示M。为表示这些集合,我们随机选择n个排列转换用于矩阵M的行处理。其中n一般为一百或几百。对于集合S对应的列,分别调用这些排列转换所决定的最小哈希函数h1,h2,h3,.......,hn,则可以构建S的最小哈希签名向量[h1(S),h2(S),.......,hn(S)],该向量通常写成列向量方式。因此,基于矩阵M可以构建一个签名矩阵,其中M的每一列替换成该列所对应的最小哈希签名向量即可。
需要注意的是,签名矩阵与 M的列数相同但行数只有n。即使不显示表示M中的全部元素而采用适合于稀疏矩阵的某种压缩形式(比如只存储1所在的位置)来表示,通常情况下签名矩阵所需要的空间仍比矩阵M本身的表示空间要小许多。
最小哈希签名的计算
对于大规模特征矩阵进行显式排列转换是不可行的。即使对上百万甚至数十亿的行选择一个随机排列转换也是极其消耗时间,而对行进行必要的排序则需要花费更多的时间。因此,类似图2给出的排列转换的矩阵在概念上十分吸引人,但却缺乏可操作性。
幸运的是,我们可以通过一个随机哈希函数来模拟随机排列转化的效果,该函数将行号映射到与行数目大致相等的数量的桶中。通常而言,一个将整数0,1,2.....,k-1映射到桶号0,1,2,......,k-1的哈希函数会将某些整数对映射到同一个桶中,而有些桶却没有被任何整数映射到。然而,只要k很大且哈希结果冲突不太频繁的话,差异就不是很重要。于是,我们就可以继续假设哈希函数“h”将原来的第r行放在排列转换后次序中的第h(r)个位置上。
因此,我们就可以不对行选择n个随机排列转换,取而代之的是随机选择n个哈希函数h1,h2,.......,hn作用于行。在上述处理基础上,就可以根据每行在哈希之后的位置来构建签名矩阵。令SIG(i,c)为签名矩阵中第i个哈希函数在第c列上的元素。一开始,对于所有的i和c,将SIG(i,c)都初始化为∞。然后,对行进行如下操作:
(1)计算 h1(r),h2(r),......,hn(r)。
(2)对每列c进行如下操作:
(a)如果c在第r行为0,则什么也不做;
(b)否则,如果c在第r行为1,那么对于每个i=1,2,....,n,将SIG(i,c)置为原来的SIG(i,c)hi(r)之中的较小值。
例如:在此考虑图1对应的特征矩阵,我们在后面加上一些数据形成图3。另外将每一行替换成其对应的行号0,1,.....,4。选择的两个哈希函数分别为h1(x)=x+1 mod 5及h2(x)=3x+1 mod 5.两个哈希函数产生的结果显示在图3-4中的最后两列。注意到这里的两个简单哈希函数对应真正的行排列转换,当然这里这有当行数目为质数(这里为5, 这是为了避免不同的数之间具有相同的约数而导致余数会相等进而会被分配到一个桶号中,这就会产生冲突了)时才会有真正的排列转换。通常来说,哈希结果都会存在冲突,即至少有两行得到的哈希值相等。
图3
接下来 模拟计算签名矩阵的算法。一开始,签名矩阵全部都由∞构成:
首先 ,考虑图3中的第0行。此时,不论是h1(0)还是h2(0)的结果都是1。而只有集合S1和S4在第0行为1,因此签名矩阵中只有这两列的值需要修改。因为1<∞,因此实际上是对S1和S4的对应值进行修改,所以当前签名矩阵的估计结果为:
接下来,我们下移到图3中的第一行。对于该行,只有S3的值为1,此时其哈希值为h1(1)=2,h2(1)=4。因此,SIG(1,3)置为2,SIG(2,3)置为4。因为第一行中其它列的值均为0,所以签名矩阵的相应列的元素保持不变。于是,新的签名矩阵为:
图3第2行中只有S2和S4对应的列的值为1,且其哈希值h1(2)=3,h2(2)=2,。S4对应的标签名本应修改,但是签名矩阵中对应列值为[1,1],因此其签名最后不会修改。而S2对应的列中仍然是初始值∞,我们将其修改为[3,2],得到如下图:
再接下来处理图3中的第3行。此时只有S2对应的列的值不为1。而哈希值h1(3)=4,h2(3)=0。h1的结果已经超过了矩阵中所有列上的已有值,因此不需要修改签名矩阵的第一列的任一值。然而,h2的值为0小于矩阵元素,因此将SIG(2,1)、SIG(2,3)及SIG(2,4)减小为0。需要注意的是,由于图3中S2列在当前行的取值已经为0,因此SIG(2,2)不可能再减小。于是,此时得到的签名矩阵为:
最后考虑图3中的第4行,此时h1(4)=0,h2(4)=3。由于第4行只在S3列取值为1,我们仅仅比较S3的当前值[2,0]与哈希值[0,3]即可。由于0<2,因此将SIG(1,3)改为0,而同时由于 3>0,因此SIG(2,3)保持不变。最终得到的签名矩阵为:
基于上述签名矩阵,可以估计原始集合之间的Jaccard相似度。注意到在签名矩阵中S1和S4对应的列向量完全相同,因此我们可以猜测SIM(S1,S4)=1.0。如果回到图3,会发现S1和S4的真是Jaccard相似度为2/3.需要记住的是,签名矩阵中行之间的一致程度只是真实Jaccard相似度的一个估计值,因为本例规模太小,所以并不足以说明在大规模数据情况下估计值和真实值相近的规律。另外,在本例中,S1和S3在签名矩阵中有一半元素一致(真实相似度为1/4),而S1和S2在签名矩阵中没有相同元素,所以相似度估计值为0(真实相似度也为0)。
(注:以上理论性的知识全部是来源于上篇博客中所提到的书里的,所以是可靠的)。
下面附上自己的python代码:
这个是依据上述理论一个版本
""" 此函数用于获得所有文档的最小哈希签名,signatureNum表示签名行数 """ def getMinHashSignature(shingleList,signatureNum): #tatalSet用于存放所有集合的并集 totalSet=shingleList[0] for i in range(1,len(shingleList)): totalSet=totalSet|shingleList[i] temp=int(math.sqrt(signatureNum)) #randomArray用于模拟随机哈希函数 randomArray=[] #signatureList用于存放总的哈希签名 signatureList=[] maxNum=sys.maxint for i in range(signatureNum): randomArray.append(random.randint(1,temp)) randomArray.append(random.randint(1,temp)) #buketNum用于记录所有元素的个数,作为随机哈希函数的桶号 buketNum=len(totalSet) for i in range(signatureNum): """ A用于代表随机哈希函数的系数,B代表常数,signature用于存放哈希函数产生的签名 """ A=randomArray[i*2] B=randomArray[i*2+1] signature=[] for shingleSet in shingleList: minHash=maxNum index=-1 for item in totalSet: index+=1 if item in shingleSet: num=(A*index+B)%buketNum minHash=min(minHash,num) signature.append(minHash) signatureList.append(signature) return signatureList
此处是上述函数的一个跟进版本,做了些微修补:
""" 此处是新版的函数,将哈希签名的矩阵换的行列换了一下,便于接下来使用 """ def getMinHashSignature(shingleList,signatureNum): #tatalSet用于存放所有集合的并集 totalSet=shingleList[0] for i in range(1,len(shingleList)): totalSet=totalSet|shingleList[i] temp=int(math.sqrt(signatureNum)) #randomArray用于模拟随机哈希函数 randomArray=[] #signatureList用于存放总的哈希签名 signatureList=[] maxNum=sys.maxint for i in range(signatureNum): randomArray.append(random.randint(1,temp*2)) randomArray.append(random.randint(1,temp*2)) #buketNum用于记录所有元素的个数,作为随机哈希函数的桶号 buketNum=len(totalSet) """ 此处将不同文档的自己的哈希签名存成一个list,然后再进行汇总到一个总的list """ for shingleSet in shingleList: """ signature用于存放哈希函数产生的签名 """ signature=[] for i in range(signatureNum): minHash=maxNum for index,item in enumerate(totalSet): if item in shingleSet: num=(randomArray[i*2]*index+randomArray[i*2+1])%buketNum minHash=min(minHash,num) signature.append(minHash) signatureList.append(signature) return signatureList
下面的函数是对相似度进行计算:
""" 此函数通过比较两个文档的最小哈希签名进行计算相似度,传入的参入是两个文档的最小哈希签名的集合, 存放在list中,最后结果返回相似度 """ def calSimilarity(signatureSet1,signatureSet2): count=0 for index in range(len(signatureSet1)): if(signatureSet1[index]==signatureSet2[index]): count+=1 return count/(len(signatureSet1)*1.0) """ 此函数用于将计算所有文档的相似度,并将结果存放在一个list中,结果用元组存放 """ def calAllSimilarity(signatureList,filesName): signatureNum=len(signatureList) fileNum=len(filesName) result=[] for index1,signatureSet1 in enumerate(signatureList): for index2,signatureSet2 in enumerate(signatureList): if(index1<index2): result.append((calSimilarity(signatureSet1,signatureSet2),filesName[index1],filesName[index2])) return result dir="D://E07" # 存放文档的文件夹路径 filesName=getFilesName(dir) shingleList=getShingleList(dir,4) #此处数字控制取出的对比的字段的长度 signatureList=getMinHashSignature(shingleList,100) #此处数字表示从代码中去的个数 result=calAllSimilarity(signatureList,filesName) result.sort() result.reverse() for each in result: print each至此,一个简单版本的查重小程序就出来了,后面将会继续跟进,做进一步的更新。