共计 6646 个字符,预计需要花费 17 分钟才能阅读完成。
本文由 QQ 大数据发表
最朴素的做法
在大多数情况下,大量的重复文本一般不会是什么好事情,比如互相抄袭的新闻,群发的垃圾短信,铺天盖地的广告文案等,这些都会造成网络内容的同质化并加重数据库的存储负担,更糟糕的是降低了文本内容的质量。因此需要一种准确而高效率的文本去重算法。而最朴素的做法就是将所有文本进行两两比较,简单易理解,最符合人类的直觉,对于少量文本来说,实现起来也很方便,但是对于海量文本来说,这明显是行不通的,因为它的时间复杂度是,针对亿级别的文本去重时,时间消耗可能就要以年为单位,此路不通。
另外,我们讲到去重,实际上暗含了两个方面的内容,第一是用什么方式去比较更为高效,第二是比较的时候去重标准是什么。这里的去重标准在文本领域来说,就是如何度量两个文本的相似性,通常包含编辑距离,Jaccard 距离,cosine 距离,欧氏距离,语义距离等等,在不同领域和场景下选用不同的相似性度量方法,这里不是本文的重点,所以按下不表,下面着重解决如何进行高效率比较的问题。
核心思想
降低时间复杂度的关键:> 尽力将潜在的相似文本聚合到一块,从而大大缩小需要比较的范围
simHash 算法
海量文本去重算法里面,最为知名的就是 simHash 算法,是谷歌提出来的一套算法,并被应用到实际的网页去重中。simHash 算法的最大特点是:将文本映射为一个 01 串,并且相似文本之间得到的 01 串也是相似的,只在少数几个位置上的 0 和 1 不一样。为了表征原始文本的相似度,可以计算两个 01 串之间在多少个位置上不同,这便是汉明距离,用来表征 simHash 算法下两个文本之间的相似度,通常来说,越相似的文本,对应 simHash 映射得到的 01 串之间的汉明距离越小。
为了让这个过程更为清晰,这里举个简单的例子。
t1 = “ 妈妈喊你来吃饭 ” t2 = “ 妈妈叫你来吃饭 ”
可以看到,上面这两个字符串虽然只有一个字不同,但是通过简单的 Hash 算法得到的 hash 值可能就完全不一样了,因而无法利用得到的 hash 值来表征原始文本的相似性。然而通过 simHash 算法的映射后,得到的 simHash 值便是如下这样:
SH1 = “1000010010101101[1]1111110000010101101000[0]00111110000100101[1]001011” SH2 = “1000010010101101[0]1111110000010101101000[1]00111110000100101[0]001011”
仔细观察,上面的两个 simHash 值只有三个地方不一样(不一样的地方用 ”[]” 标出),因此原始文本之间的汉明距离便是 3。通常来说,用于相似文本检测中的汉明距离判断标准就是 3,也就是说,当两个文本对应的 simHash 之间的汉明距离小于或等于 3,则认为这两个文本为相似,如果是要去重的话,就只能留下其中一个。
simHash 算法的去重过程思路很简单,首先有一个关键点:> 假如相似文本判断标准为汉明距离 3,在一个待去重语料集中存在两个相似文本,那也就是说这两个相似文本之间的汉明距离最大值为 3(对应 hash 值最多有 3 个地方不同),如果 simHash 为 64 位,可以将这个 64 位的 hash 值从高位到低位,划分成四个连续的 16 位,那么这 3 个不同的位置最多只能填满 4 个中的任意 3 个区间(可以反过来想,如果这 4 个区间都填满了,那就变成汉明距离为 4 了)。也就是说两个相似文本必定在其中的一个连续 16 位上完全一致。
想明白了这个关键点之后,就可以对整个待去重文本都进行一次 simHash 映射(本文中使用 64 位举例),接着将这些 01 串从高位到低位均分成四段,按照上面的讨论,两个相似的文本一定会有其中一段一样,仍用上面的例子,分成的四段如下所示:
t1 = “ 妈妈喊你来吃饭 ” SH1 = “1000010010101101[1]1111110000010101101000[0]00111110000100101[1]001011” SH1_1 = “1000010010101101” #第一段 SH1_2 = “[1]111111000001010” #第二段 SH1_3 = “1101000[0]00111110” #第三段 SH1_4 = “000100101[1]001011” #第四段 t2 = “ 妈妈叫你来吃饭 ” SH2 = “1000010010101101[0]1111110000010101101000[1]00111110000100101[0]001011” SH2_1 = “1000010010101101” #第一段 SH2_2 = “[0]111111000001010” #第二段 SH2_3 = “1101000[1]00111110” #第三段 SH2_4 = “000100101[0]001011” #第四段
这一步做完之后,接下来就是索引的建立。按照上面的讨论,每一个 simHash 都从高位到低位均分成 4 段,每一段都是 16 位。在建立倒排索引的过程中,这些截取出来的 16 位 01 串的片段,分别作为索引的 key 值,并将对应位置上具有这个片段的所有文本添加到这个索引的 value 域中。直观上理解,首先有四个大桶,分别是 1,2,3,4 号(对应的是 64 位 hash 值中的第一、二、三、四段),在每一个大桶中,又分别有个小桶,这些小桶的编号从 0000000000000000 到 1111111111111111. 在建立索引时,每一个文本得到对应的 simHash 值后,分别去考察每一段(确定是 1,2,3 和 4 中的哪个大桶),再根据该段中的 16 位 hash 值,将文本放置到对应大桶中对应编号的小桶中。索引建立好后,由于相似文本一定会存在于某一个 16 位 hash 值的桶中,因此针对这些分段的所有桶进行去重(可以并行做),便可以将文本集合中的所有相似文本去掉。
整个利用 simHash 进行去重的过程如下图所示:
总结一下,整个 simHash 去重的步骤主要是三个:1. 针对每一个待去重文本进行 simHash 映射;2. 将 simHash 值分段建立倒排索引;3. 在每一个分段的 hash 值中并行化去重操作。
利用 simHash 进行去重有两个点非常关键:– simHash 映射后仍然保持了原始文本的相似性;– 分而治之的思想大大降低了不必要的比较次数。
因此,有了这两点做保证,对于长文本下的 simHash 算法以及使用汉明距离来度量文本之间的相似性,可以极大降低算法的时间复杂度,并且也能取得很好的去重效果。但是在短文本场景下,这种度量方法的效果将会变得很差,通常情况下,用来度量长文本相似的汉明距离阈值为 3,但是短文本中,相似文本之间的汉明距离通常是大于 3 的,并且该算法中,基于汉明距离的相似性阈值选取的越高,该算法的时间复杂度也会越高,此时汉明距离无法继续作为短文本相似性的度量标准应用到短文本去重中。
基于文本局部信息的去重算法
基于文本局部信息的去重过程,其基本思想和 simHash 类似,只不过不是利用 hash 值,而是直接利用文本的一个子串作为 key, 然后凡是拥有这个子串的文本都会被放入到这个子串对应的桶中。这里隐含了一个前提:> 任意两个可判定的相似文本,必定在一个或多个子串上是完全一致的。
此外,子串的产生,可以通过类似于 n -grams(如果是词和字层面的,对应 shingles)的方法,直接从原始文本上滑动窗口截取,也可以去掉停用词后在剩下的有序词组合中截取,还可以对原始文本进行摘要生成后再截取,总之只要是基于原始文本或可接受范围内的有损文本,都可以利用类似的思想来产生这些作为索引的子串。
整个去重算法分为五个大的框架,分别包括:文本预处理,倒排索引的建立,并行化分治,去重算法的实现,文本归并等。
文本预处理
文本预处理根据所选用的具体子串截取方法的不同,而有所不同。如果子串是由词组合形成的,则需要对文本进行分词,如果需要去掉停用词,那么这也是文本预处理的工作。为了简化过程的分析,这里主要以原始文本直接截取子串为例,因此预处理的工作相对偏少一些。
倒排索引的建立
假定潜在的两个相似文本(要求去重后其中一个被去掉)分别是 t1 和 t2,二者之间完全一致的最大连续子文本串有 k 个,它们组成一个集合,将其定义为 S = {s1,s2,…,sk},这些子文本串的长度也对应一个集合 L = {l1,l2,…,lk},针对该特定的两个文本进行去重时,所选择的截取子文本串长度不能超过某一个阈值,因为如果截取长度超过了该阈值,这两个文本便不再会拥有同一个子文本串的索引,因而算法自始至终都不会去比较这两个文本,从而无法达到去重的目的。这个阈值便被定义为这两个文本上的最大可去重长度,有:
在所有的全局文本上去重的话,相应的也有一个全局去重长度 m,它表征了如果要将这部分全局文本中的相似文本进行去重的话,针对每一个文本需要选取一个合适的截取长度。一般来说,全局去重长度的选择跟去重率和算法的时间复杂度相关,实际选择的时候,都是去重率和时间复杂度的折中考虑。全局去重长度选择的越小,文本的去重效果越好(去重率会增大),但相应的时间复杂度也越高。全局去重长度选择越大,相似文本去重的效果变差(部分相似文本不会得到比较),但时间复杂度会降低。这里的原因是:如果全局去重长度选择的过高,就会大于很多相似文本的最大可去重长度,因而这些相似文本便不再会判定为相似文本,去重率因而会下降,但也正是因为比较次数减少,时间复杂度会降低。相反,随着全局去重长度的减小,更多的相似文本会划分到同一个索引下,经过相似度计算之后,相应的相似文本也会被去除掉,因而全局的去重率会上升,但是由于比较次数增多,时间复杂度会增大。
假定有一个从真实文本中抽样出来的相似文本集 C,可以根据这个样例集来决定全局去重长度 m,实际情况表明,通常来说当 m >=4(一般对应两个中文词的长度),算法并行计算的时候,时间复杂度已经降低到可以接受的范围,因此可以得到:
假定某个待去重的文本 t,其长度为 n。定义 S 为截取的 m -gram 子串的集合,根据 m 和 n 的大小关系,有下列两种情况:(1)当 n >= m 时,可以按照 m 的大小截取出一些 m -gram 子串的集合,该集合的大小为 n -m+1,用符号表示为 S = {s1,s2,…,sn-m+1};(2)当 n <m 时,无法截取长度为 m 的子串,因此将整个文本作为一个整体加入到子串集合当中,因此有 S ={t}. 每一个待去重文本的 m -gram 子串集合生成之后,针对每个文本 t,遍历对应集合中的元素,将该集合中的每一个子串作为 key,原始文本 t 作为对应 value 组合成一个 key-value 对。所有文本的 m -gram 子串集合遍历结束后,便可以得到每一个文本与其 n -m+ 1 个 m -gram 子串的倒排索引。接下来,按照索引 key 值的不同,可以将同一个索引 key 值下的所有文本进行聚合,以便进行去重逻辑的实现。
算法的并行框架
这里的并行框架主要依托于 Spark 来实现,原始的文本集合以 HDFS 的形式存储在集群的各个节点上,将这些文本按照上面所讲的方法将每一个文本划分到对应的索引下之后,以每一个索引作为 key 进行 hash,并根据 hash 值将所有待去重文本分配到相应的机器节点(下图中的 Server),分布式集群中的每一个工作节点只需负责本机器下的去重工作。基于 Spark 的分布式框架如下,每一个 Server 便是一个工作节点,Driver 负责分发和调配,将以 HDFS 存储形式的文本集合分发到这些节点上,相当于将潜在的可能重复文本进行一次粗粒度的各自聚合,不重复的文本已经被完全分割开,因而每个 Server 只需要负责该节点上的去重工作即可,最终每个 Server 中留下的便是初次去重之后的文本。
去重的实现
并行化框架建立后,可以针对划分到每一个索引下的文本进行两两比较(如上一个图所示,每一个 Server 有可能处理多个索引对应的文本),从而做到文本去重。根据 1 中的分析,任意两个可判定的相似文本 t1 和 t2,必定在一个或多个子文本串上是完全一致的。根据 3.1.1 中的设定,这些完全一致的最大连续子串组成了一个集合 S = {s1,s2,…,sk},针对 t1 和 t2 划分 m -gram 子串的过程中,假定可以分别得到 m -gram 子串的集合为 S1 和 S2,不妨假设 S 中有一个子串为 si,它的长度 |si| 大于全局去重长度 m,那么一定可以将该子串 si 划分为 |si|-m+ 1 个 m -gram 子串,并且这些子串一定会既存在于 S1 中,也会存在于 S2 中。更进一步,t1 和 t2 都会同时出现在以这 |si|-m+ 1 个 m -gram 子串为 key 的倒排索引中。
去重的时候,针对每一个索引下的所有文本,可以计算两两之间的相似性。具体的做法是,动态维护一个结果集,初始状态下随机从该索引下的文本中选取一条作为种子文本,随后遍历该索引下的待去重文本,尝试将遍历到的每一条文本加入结果集中,在添加的过程中,计算该遍历到的文本与结果集中的每一条文本是否可以判定为相似(利用相似性度量的阈值),如果与结果集中某条文本达到了相似的条件,则退出结果集的遍历,如果结果集中完全遍历仍未触发相似条件,则表明此次待去重的文本和已知结果集中没有任何重复,因此将该文本添加到结果集中,并开始待去重文本的下一次遍历。去重的时候,两个文本之间的相似性度量非常关键,直接影响到去重的效果。可以使用的方法包括编辑距离、Jaccard 相似度等等。在实际使用时,Jaccard 相似度的计算一般要求将待比较的文本进行分词,假定两个待比较的文本分词后的集合分别为 A 和 B,那么按照 Jaccard 相似度的定义可以得到这两个文本的相似度 显然,两个完全不一致的文本其 Jaccard 相似度为 0,相反两个完全一样的文本其 Jaccard 相似度为 1,因此 Jaccard 相似度是一个介于 0 和 1 之间的数,去重的时候,可以根据实际需要决定一个合适的阈值,大于该阈值的都将被判定为相似文本从而被去掉。
整个的去重实现伪代码如下:
初始状态:文本集合 T = {t_1,t_2,…,t_n} 去重结果 R = {} 相似度阈值 sim_th 输出结果:去重结果 R 算法过程:for i in T: flag = true for j in R: if( similarity(i,j) < sim_th ) flag = false break -> next i else continue -> next j if(flag) R.append(i) #表示 i 文本和当前结果集中的任意文本都不重复,则将 i 添加到结果集中
文本归并去重
这一个步骤的主要目的是将分处在各个不同机器节点上的文本按照预先编排好的 id,重新进行一次普通的 hash 去重,因为根据上一步的过程中,可能在不同子串对应的桶中会留下同一个文本,这一步经过 hash 去重后,便将这些重复的 id 去除掉。最终得到的结果便是,在整个文本集上,所有的重复文本都只保留了一条,完成了去重的目的。整个的去重流程如下图所示:
和 simHash 进行比较
这里提出来的去重算法与 simHash 比较,分别从时间复杂度和去重准确度上来说,
首先,时间复杂度大大降低 – 分桶的个数根据文本量的大小动态变化,大约为文本数的 2 倍,平均单个桶内不到一条文本,桶内的计算复杂度大大降低;而 simHash 算法中,桶的个数是固定的 4 *216=26 万个 – 一般来说,只有相似文本才有相似的词组合,所以某个特定的词组合下相似文本占大多数,单个桶内的去重时间复杂度倾向于 O(N);相应的,simHash 单个桶内依然有很多不相似文本,去重时间复杂度倾向于 O(N^2)
其次,相似性的度量更为精准:– 可以使用更为精准的相似性度量工具,但是 simHash 的汉明距离在短文本里面行不通,召回太低,很多相似文本并不满足汉明距离小于 3 的条件
总结
这里提出的基于文本局部信息的去重算法,是在短文本场景下 simHash 等去重算法无法满足去重目的而提出的,实际上,同样也可以应用于长文本下的去重要求,理论上,时间复杂度可以比 simHash 低很多,效果能够和 simHash 差不多,唯一的缺点是存储空间会大一些,因为算法要求存储很多个文本的副本,但在存储这些文本的副本时候,可以使用全局唯一的 id 来代替,所以存储压力并不会提升很多,相比时间复杂度的大大降低,这点空间存储压力是完全可以承担的。
此文已由作者授权腾讯云 + 社区发布,更多原文请点击
搜索关注公众号「云加社区」,第一时间获取技术干货,关注后回复 1024 送你一份技术课程大礼包!