最樸素的做法
在大多數情況下,大量的重複文字一般不會是什麼好事情,比如互相抄襲的新聞,群發的垃圾簡訊,鋪天蓋地的廣告文案等,這些都會造成網路內容的同質化並加重資料庫的儲存負擔,更糟糕的是降低了文字內容的質量。因此需要一種準確而高效率的文字去重演算法。而最樸素的做法就是將所有文字進行兩兩比較,簡單易理解,最符合人類的直覺,對於少量文字來說,實現起來也很方便,但是對於海量文字來說,這明顯是行不通的,因為它的時間複雜度是,針對億級別的文字去重時,時間消耗可能就要以年為單位,此路不通。
另外,我們講到去重,實際上暗含了兩個方面的內容,第一是用什麼方式去比較更為高效,第二是比較的時候去重標準是什麼。這裡的去重標準在文字領域來說,就是如何度量兩個文字的相似性,通常包含編輯距離,Jaccard距離,cosine距離,歐氏距離,語義距離等等,在不同領域和場景下選用不同的相似性度量方法,這裡不是本文的重點,所以按下不表,下面著重解決如何進行高效率比較的問題。
降低時間複雜度的關鍵: > 盡力將潛在的相似文字聚合到一塊,從而大大縮小需要比較的範圍
海量文字去重演算法裡面,最為知名的就是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比較,分別從時間複雜度和去重準確度上來說,
首先,時間複雜度大大降低 – 分桶的個數根據文字量的大小動態變化,大約為文字數的2倍,平均單個桶內不到一條文字,桶內的計算複雜度大大降低;而simHash演算法中,桶的個數是固定的4*216=26萬個 – 一般來說,只有相似文字才有相似的片語合,所以某個特定的片語合下相似文字佔大多數,單個桶內的去重時間複雜度傾向於O(N);相應的,simHash單個桶內依然有很多不相似文字,去重時間複雜度傾向於O(N^2)
其次,相似性的度量更為精準: – 可以使用更為精準的相似性度量工具,但是simHash的漢明距離在短文字里面行不通,召回太低,很多相似文字並不滿足漢明距離小於3的條件
這裡提出的基於文字區域性資訊的去重演算法,是在短文字場景下simHash等去重演算法無法滿足去重目的而提出的,實際上,同樣也可以應用於長文字下的去重要求,理論上,時間複雜度可以比simHash低很多,效果能夠和simHash差不多,唯一的缺點是儲存空間會大一些,因為演算法要求儲存很多個文字的副本,但在儲存這些文字的副本時候,可以使用全域性唯一的id來代替,所以儲存壓力並不會提升很多,相比時間複雜度的大大降低,這點空間儲存壓力是完全可以承擔的。
來原:騰訊QQ大資料