從NLP任務中文字向量的降維問題,引出LSH(Locality Sensitive Hash 區域性敏感雜湊)演算法及其思想的討論

鄭瀚Andrew.Hann發表於2019-06-19

1. 引言 - 近似近鄰搜尋被提出所在的時代背景和挑戰

0x1:從NN(Neighbor Search)說起

ANN的前身技術是NN(Neighbor Search),簡單地說,最近鄰檢索就是根據資料的相似性,從資料集中尋找與目標資料最相似的專案,而這種相似性通常會被量化到空間上資料之間的距離,例如歐幾里得距離(Euclidean distance),NN認為資料在空間中的距離越近,則資料之間的相似性越高。

當需要查詢離目標資料最近的前k個資料項時,就是k最近鄰檢索(K-NN)。

0x2:NN的技術挑戰與發展

近些年的研究中湧現出大量以最近鄰檢索為基本思想的方法,主要可分為兩類:

  • 資料結構改進:基於提升檢索結構效能的新資料結構,大多基於樹形結構,例如KD-Tree。關於KD-tree的相關討論可以參閱另一篇部落格
  • 高效索引方法改進:基於對資料本身的索引和處理的方法,包括雜湊演算法、向量量化方法等。雜湊演算法就是使用HASH演算法構建資料索引,HASH方法的確效率很高,但是因為其全域性敏感性,輸入文字只要有任何微小的變化,得到的Hash Index就會發生改變,因此無法提高近鄰搜尋的效能。

儘管出現了很多針對NN演算法的改進措施,但是在實際工業場景中,NN演算法遇到最大阻礙是:

資料經過向量化(即特徵工程)之後,因為特徵空間特別高維(上百/上千/甚至上萬),導致在空間距離上特別稀疏,維度越高這個現象越明顯,這直接導致了NN的近鄰搜尋效果不好。筆者自己也同樣在專案開發中嘗試使用過NN演算法,當發現NN搜尋效果不佳時,反過來調整特徵工程,然後再繼續NN搜尋,如此反覆迭代,最終效果難以保證,因為你無法保證每一次的特徵工程都能精確地表徵出業務場景的相似性。

舉個例子來說,我們有一批惡意檔案現在要對其進行聚類分析,首先我們對其進行文字方面的特徵工程,得到一個向量集合。因為基於專家經驗得到的特徵維度之間是彼此“正交”的,因此每個特徵向量之間的餘弦相似性都不強,基於“空間距離度量”的聚類演算法效果自然也不會很好。

而且另一方面,因為特徵空間的維度太高了(幾百維、幾千維),一些本來很有用的”強貢獻特徵“可能會被淹沒在大量的“弱貢獻特徵”中,這很好理解,看一下歐幾里得空間的距離度量公式:

從公式中可以看到,所有維度都被“公平看待”,平方和開根起到了一個均值的作用,弱特徵越多,強特徵被“稀釋”的影響就越大。特徵不是越多越好,有時候太多無用的特徵可能還會引起反效果。

換句話說,即使本來可能很“相似的檔案”(例如同一個病毒家族的變種),但是在我們設計的特徵上卻不能很好地體現。

面對這些問題,如何解決呢?

一個很自然的想法是,如果能有一種演算法,能將相似的字串,從高維空間降維到一個相對低維的空間中。同時,在這個低維空間中,語法/語義相近的字串的夾角餘弦相對較小,也即語法/語義相近的字串在降維後彼此較為接近。

如果能實現上述兩個目標,我們不僅可以有效實現對高維向量的降維,同時因為低維空間的向量間具備相似聚集性,我們可以在接近線性的時間內,進行向量間距離評估,以及找到相似的文字。

0x3:用幾個例如引出LSH(區域性敏感雜湊)演算法被提出的歷史背景

這個章節我們按照歷史時間線來討論學術界在面對語言模型中文字相似性這個課題分支時,一路走來遇到了哪些問題,整個時間線學術成果非常豐富,我們這裡只能摘取其主要節點進行推導式的討論。

1. 基於原始文字計算最小編輯距離

假如我們有兩段輸入文字:

1. how are u?
2. how are you?

現在計算這兩段文字的相似度,也即需要計算這兩段文字的區別度,一個最簡單直觀的想法是直接基於原始的ascii序列逐位計算最小編輯距離:

1. u -> u
2. ? -> o
3. N/A -> u
3. N/A -> ?

即第一段文字通過4次修改即可得到第二段文字,所以這兩個文字的相似度為:

(1 - 4 / (len(第一段文字) + len(第二段文字))) * 100% = (1 - 4 / (10 + 12)) * 100% = 81.81%

相似度為81%,這個結論怎麼樣?準嗎?勉強好像可用,但是效果顯然不太好,怎麼辦呢?

2. 對變化如此敏感的問題出在哪?

我們開始思考,原始ascii字元空間對變換的感知非常敏感,有兩個主要原因:

  • 對輸入文字的單個ascii byte修改很敏感,每一個byte的變換都會引起最後結果的變化
  • 對輸入文字中ascii byte的position位置很敏感,即使是同一個ascii序列,換了一個位置(不管語法/語義是否發生變化),最終的結果都會發生變化

從向量空間的角度來看,原始的ascii字元空間可以抽象為一個 N * 2的列向量組(ascii bytes vec,position vec),這裡N代表著輸入文字的length長度。

3. 尋找一個線性空間對映

沿著線性空間的這個思考路線,我們應該去找一個新的向量空間,該新向量空間與原始ascii字元空間相比,對變化的敏感度更低(包括對ascii修改、ascii位置變化)。

那對ascii字元變化的敏感度更低,怎麼用數學思維來理解這個概念呢?

這裡需要引入線性對映的概念:

設 S 和 S' 是兩個集合,如果存在一個法則f,使得集合S中每一個元素a,都有集合 S' 中唯一確定的元素b與它對應,則稱 f 是S到 S' 的一個對映,記作:

我們需要找到一種線性對映,將原始ascii序列中的 N * 2(ascii byte,position)向量組,降維對映成一個 M * 1(ascii sequence windows)向量組,這裡 M 是新向量空間中的維度。

從投影降維理論視角我們知道,降維後,原始空間中的position維度被完全忽略了,而ascii byte這個維度被轉換為ascii sequence windows這個新維度,這顯然不是一個正交投影,即不是單射,因為原始輸入文字中的一個ascii修改,可能會引起新空間裡多個ascii sequence window的變化。

好,接下來的問題是,如何找到這個線性空間對映呢?這就是接下來要討論的ngram分詞演算法。

4. 基於ngram固定長度滑動視窗對輸入文字進行切詞

假設我們現在有3段輸入文字:

[
    'This is the first document.',
    'This is the second document.',
    'Is this the first document?',
]

以單個word為一個slice window進行切詞,即1-gram(unigram),得到:

[
    { u'This', u'is', u'the', u'first', u'document' },
    { u'This', u'is', u'the', u'second', u'document' },
    { u'This', u'is', u'the', u'first', u'document' }
]

從1-gram slice結果中,我們可以看到幾點資訊:

  • 第二段文字相比第一段文字,因為word 1-gram windows的關係,只變化了一個token。
  • 第三段文字相比第一段文字,雖然單詞的位置發生了變化,但是因為1-grame丟棄了position這個維度的資訊,所以在1-gram這個新的特徵空間中,position的變化是無感知的。

顯然,1-gram的分詞方案造成了資訊的過度失真,導致了原始輸入文字的語法結構被丟失了,這個問題怎麼解決呢?顯然,我們需要引入相對位置(relative position)這個特徵維度。

使用2-gram演算法進行切詞,得到:

[
    { u'This', u'This is', u'is the', u'the first', u'first document', u'document' },
    { u'This', u'This is', u'is the', u'the second', u'second document', u'document' },
    { u'is', u'is This', , u'This the', u'the first', u'first document', u'document' }
]

從1-gram slice結果中,我們可以看到幾點資訊:

  • 第二段文字相比第一段文字,因為word 2-gram windows的關係,有兩2個token發生了變化,2-gram對原始輸入文字變化的敏感程度提高了。
  • 第三段文字相比第一段文字,因為單詞的位置發生了變化,有3個token發生了變化,2-gram對原始輸入文字中的語法結構變換的敏感程度提高了。

從線性對映的角度看,在2-gram演算法下,原始ascii序列中的 N * 2(ascii byte,position)向量組,降維對映成一個 M * N(ascii sequence windows,relative position)向量組,這裡 M 代表了2-gram後的 gram token數量,N 代表了每個gram分組內的word組合,2-gram token內的組合維度數為2,如果是3-gram,則組合維度數位6。

5. 還需要繼續降維

問題到這裡就結束了嗎?顯然不是的,ngram演算法雖然比純粹的ascii逐字元比對各方面效果要好,但是還存在幾個問題:

  • 對區域性修改還是太敏感,ngram只是縮小了對輸入文字的變化感知域,但是不具備完全遮蔽輸入修改的影響。也就是說,輸入文字的任何修改,或多或少都會影響到ngram token lists的變換。
  • 對於海量語料庫來說,ngram的特徵空間還是太高了,有時會到達上萬維,一般的輸入文字進行詞表編碼後,很容易遇到高維稀疏問題,這種彼此正交的高維稀疏向量,不管是進行有監督學習還是無監督聚類來說,效果都很受影響。

那解決問題的思路是什麼呢?答案還是降維,我們需要繼續尋找一個新的對映函式,將原始ascii字元空間對映到一個低維向量空間中。但是要注意,這個新的低維向量空間有幾個技術指標需要滿足:

  • 語法/語義一致性:向量空間的變換不能丟失原向量空間的語法和語義資訊,或者近似相似。
  • 相對低維:用盡量少的bit空間,儘可能多的儲存有用的資訊。
  • 區域性修改容忍性:對不影響語法/語義的區域性修改,在新向量空間引起的變換儘可能小,最好是完全無變化。

6. 兩個不同的改進方向  

基於上個章節討論的3個技術指標,學術界開始了學術的研究和創新,演化出了兩個不同的方向:

7. Word Vector(data-dependent)和LSH(data-independent)這兩個方向的演化背景

LSH的核心是雜湊雜湊、其次是降維、其次是語法/語義一致性、再其次是演算法過程簡單高效適合在大規模高併發場景中使用。

可以這麼說,LSH通過犧牲了一部分的資訊熵,即犧牲了一部分的語法/語義一致性,換取了超級高效的時間/計算複雜度,是一種非常優秀的演算法思想,值得我們不斷深入思考和學習。 

但是換一個角度,如果對時間/空間複雜度沒有那麼高的需求,而是對語法/語義一致性有很高的要求,LSH演算法家族可能就不一定非常適合了。

這個時候,另一條思考脈輪就呈現在我們的面前,即詞向量/句子向量/文件向量,具體來說,就是從2007年開始逐漸被提出的各種詞向量降維表徵方法,包括:

  • 基於SVD奇異值矩陣分解詞向量降維方法
  • 基於神經網路的NNLM方法
  • 基於神經網路與詞頻統計綜合的Glove演算法
  • ......

詞向量廣泛地被運用於NLP相關的任務中,關於這部分的詳細討論,筆者在另一篇文章有所涉及。

從筆者自己經驗來看,在大資料時代,LSH的使用場景相比詞向量要相對少一些,筆者個人覺得問題核心在於現代NLP任務中,對語義的精確表徵能力要求越來越高,工程師和資料科學家通過不斷地引入更龐大的資料集,引入更復雜的詞向量演算法,也是希望儘可能提高資訊的利用率,儘量少的丟失資訊。

而相對的,對時間/空間複雜度有極高要求的場景可能只存在於一些極端的場景中,例如搜尋引擎等。

Relevant Link:

https://www2007.cpsc.ucalgary.ca/papers/paper215.pdf google Detecting Near-Duplicates for Web Crawling. WWW2007
http://www2007.org/
https://www.iw3c2.org/blog/category/www2007/

 

2. ANN(Approximate Nearest  Neighbor)演算法簡介 - 解決問題的思考方向

0x1:什麼場景下需要ANN演算法?

在很多應用領域中,我們面對和需要處理的資料往往是海量並且具有很高的維度(high dimensional spaces),同時資料中又普遍存在著近似相同的情況(例如相似的對話、相似的網頁、相似的URl等),怎樣快速地從海量的高維資料集合中找到與某個資料近似相似(approximate or exact Near Neighbor)的一個資料或多個資料,成為了一個難點和問題。

如果是低維的小資料集,我們通過線性查詢(Linear Search)就可以容易解決,但如果是對一個海量的高維資料集採用線性查詢匹配的話,會非常耗時,這成為ANN被研究和發展的原動力。

在筆者所在的網路安全學科中,也常常會遇到很多區域性不同(locality change)的近似文字的識別與檢測問題,例如:

  • WAF攻擊識別與檢測中,由於MVC這種架構的存在,會出現大量的相似URL,它們可能只是某一個key-value改變一個字元,或者是同一個module下不同的請求引數不同導致整個URL不同,這個時候我們常常會需要研發模擬化URL聚類技術,將大量的業務URL通過聚類降維到介面URL層面,這個時候可以大幅度降低資料量,在介面URL這個層面進行history profile建模以及非法檢測。
  • 在WEBSHELL/BIN MALWARE這類問題上,常常會出現同一個惡意原始碼,被大量的非法攻擊者進行了一些微小的定製化修改(例如修改了pass、署名等等),造成檔案的HASH變化。這個時候我們會需要對檔案進行模擬化HASH降維,不管是進行相似度惡意檢測還是相似惡意檔案搜尋,都非常有用。
  • 攻擊者或者攻擊者社群常常會使用一組相似的PAYLOAD/SHELLCODE進行有針對性的批量入侵,也就是所謂的”man with some power模型“。我們需要對攻擊者power進行有效的相似性匹配。
  • 網頁爬蟲的場景中,網路中可能存在很多轉載/複製/剽竊導致的”near-duplicate“網頁,這個時候就需要有一套高效的相似度匹配演算法,對新爬取的網頁進行相似度匹配,避免重複的網頁進入我們的spider候選佇列中,提高掃描器的效率。

0x2:近似近鄰演算法(Approximate Nearest Neighbor Algorithm)設計準則

面對海量高維資料背景下,還要進行高效的資料相似性搜尋的需求,該從哪些方面進行思考解決方案呢?

要實現上述目標,我們需要能找到一整套綜合技術,能綜合實現以下幾個技術指標:

  • 不管上層採用何種向量化編碼方式,ANN需要能夠實現有效降維。也就是說在儘量不丟失語法/語義的前提下,找到一個新的vector space,將高維的資料投影到相對低維的vector space中。這個技術指標要求是為了處理效能方面的考慮,因為高維資料的處理容易遇到”維數災難(dimension curse)問題“;
  • 降維投影后的新vector space中,在原有語料中詞義/語義相近的詞,在空間上要相對聚集在一起,反之在空間上要相對遠離稀疏,即降維投影需要具備語義不變性
  • 需要引入一種”向量空間距離量化指標“,例如歐式距離、L2範數、Hamming distance、編輯距離等。這個技術指標要求是為了量化地定義”什麼叫最近鄰“,以及”如何定義近鄰之間的距離“這兩個問題;
  • 在低維空間獲得了資料表徵後,也有了度量不同向量之間的距離的指標,接下來最後一項是需要設計一種近鄰搜尋演算法(Nearest  Neighbor Searching Algorithm),例如KNN演算法、Hamming翻轉距離演算法等;以及近鄰搜尋資料結構(Nearest  Neighbor Searching Structure,例如K-d tree with BBF、Randomized Kd-trees、Hierarchical K-means Tree。

符合以上四點技術指標的演算法被統稱為ANN(Approximate Nearest  Neighbor)演算法。

Relevant Link:

https://www2007.cpsc.ucalgary.ca/papers/paper215.pdf 

 

3. LSH(Locality Sensitive Hash 區域性敏感雜湊)演算法

值得注意的是,對於第二章提到的ANN技術指標中的前兩個,詞向量和LSH都可以實現同樣的效果,但是我們本文的討論物件LSH區域性敏感雜湊。

0x1:LSH一般性定義

區域性敏感雜湊(LSH)核心思想是:在高維空間相鄰的資料經過區域性敏感雜湊函式的對映投影轉化到低維空間後,他們落入同一個吊桶(空間區間)的概率很大而不相鄰的資料對映到同一個吊桶的概率則很小

這種方法的主要難點在於如何尋找適合的區域性敏感雜湊函式,在原論文中,作者提出了區域性敏感Hash函式的一般性定義:

我們設定x和y的距離測定函式為d(x,y),這個d()函式可以是Jaccard函式/Hamming度量函式,也可以其他具備同樣效能的函式。

在這個距離測定標準下,設定兩個距離閾值d1,d2,且 d1 < d2。

如果一個函式族F的每一個函式 f 滿足:

  • 如果 sim(x,y) <= d1,則 f(x) = f(y) 的概率至少為p1,即 P(f(x) = f(y)) >= p1;
  • 如果 sim(x,y) >= d2,則 f(x) = f(y) 的概率至多為p2,即 P(f(x) = f(y)) <= p2;

那麼稱F為(d1,d2,p1,p2)-敏感的函式族,實際上,simhash就是一種(d1,d2,p1,p2)-敏感的函式。

左圖是傳統Hash演算法,右圖是LSH。紅色點和綠色點距離相近,橙色點和藍色點距離相近。

0x2:LSH的發展歷程

按照LSH的發展順序,LSH家族的演變史如下:

  • 基於Stable Distribution投影方法
  • 基於隨機超平面投影的方法
  • 球雜湊Spherical Hashing演算法
  • SimHash
  • Kernel LSH
  • SSDEEP模糊化雜湊

我們接下來逐個討論其演算法流程及其背後的思維方式。 

Relevant Link:

https://www.cnblogs.com/wt869054461/p/9234184.html
http://people.csail.mit.edu/gregory/annbook/introduction.pdf
https://www.cnblogs.com/wt869054461/p/9234184.html
http://infolab.stanford.edu/~ullman/mmds/ch3.pdf
https://www.cnblogs.com/fengfenggirl/p/lsh.html
http://sawyersun.top/2016/Locality-Sensitive-Hashing.html

 

4. 基於Stable Distribution投影方法

2008年IEEE Signal Process上有一篇文章Locality-Sensitive Hashing for Finding Nearest Neighbors是一篇較為容易理解的基於Stable Dsitrubution的投影方法的Tutorial。

其思想在於高維空間中相近的物體,投影(降維)後也相近。

三維空間中的四個點,紅色圓形在三圍空間中相近,綠色方塊在三圍空間中相距較遠,那麼投影后還是紅色圓形相距較近,綠色方塊相距較遠。

基於Stable Distribution的投影LSH,就是產生滿足Stable Distribution的分佈進行投影,最後將量化後的投影值作為value輸出。

具體數學表示形式如下:給定特徵向量v,Hash的每一bit的生成公式為:

其中:

  • x 是一個隨機數,從滿足Stable Distribution的分佈中抽樣而來(通常從高斯或柯西分佈中抽樣而來)
  • x ⋅ v就是投影(和單位向量的內積就是投影)
  • w 值可以控制量化誤差
  • b 是隨機擾動,避免極端情況產生

需要注意的是,如果 x 抽樣於高斯分佈,那麼ϕ(u,v)衡量的是L2 norm;如果 x 抽樣於柯西分佈,那麼ϕ(u,v)衡量的是L1 norm。

更詳細的介紹在Alexandr Andoni維護的LSH主頁中,這就是LSH方法的鼻祖。

關於隨機抽樣涉及到隨機過程方面的知識,可以參閱這篇帖子。關於高斯隨機投影和柯西分佈隨機投影的討論,可以參閱另一篇blog

Relevant Link:

http://www.slaney.org/malcolm/yahoo/Slaney2008-LSHTutorial.pdf
https://www.cnblogs.com/LittleHann/p/6558575.html#_label2
https://www.zhihu.com/question/26694486/answer/242650962

  

5. 基於隨機超平面投影(hyperplane projection)的方法

0x1:Stable Distribution Projection的缺點

Stable Distribution Projection從原理上沒有什麼大問題,其實後來改進的隨機超平面和球面雜湊演算法,其底層思想上和Stable Distribution Projection沒有太大的區別。

但是在實際操作中,Stable Distribution Projection存在幾個比較明顯的問題:

  • 需要同時人工指定兩個引數,w和b,在具體的專案中會遇到調參難的問題
  • 量化後的雜湊值是一個整數而不是bit形式的0和1,還需要再變換一次

面對上述問題,Charikar改進了這種情況,提出了一種隨機超平面投影LSH。可以參考論文《Multi-probe LSH: efficient indexing for high-dimensional similarity search》

0x2:演算法公式 

假設有一個M維高維資料向量x,我們在M維空間中隨機選擇一個超平面,通過這個超平面來對資料進行切分。

這個動作總共進行N次,即通過N個隨機超平面單位向量來對原始資料集進行切分,這裡N就是降維後的向量維度。

超平面的選擇是隨機過程,不需要提前引數設定

如下圖所示,隨機在空間裡劃幾個超平面,就可以把資料分到不同空間裡,比如中間這個小三角的區域就可以賦值為110.

Hash的每一bit的數學定義式為:

x 是隨機超平面單位向量,sgn是符號函式:

接下來我們來討論在隨機超平面投影演算法下,LSH雜湊的產生原理是什麼。

1. 基於cosine距離來度量兩個向量相似度 

這時ϕ(u,v),也就是上述公式中的內積點乘計算,衡量的就是uv的cosine距離,θ(u,v)表示向量uv的夾角。

hyperplane projection的核心假設就是,兩個向量越相似,則他們的cosine距離越小:

下圖說明了該公式原理

可以看到,給定兩個向量(圖中的黑色箭頭),只有在其法線的交疊區域(深藍色區域)投影后的方向(sgn函式的值)才不相等,所以有:

,即藍色區域面積佔比整個圓,的比率等於u與v的夾角。

2. 基於sng符號函式將cosine距離歸一化為“方向是否相同”的0/1二值資訊

通過sgn符號函式的歸一化,只要兩個向量是同方向,不管距離遠近,都統一歸一化為1。

這樣計算後的hash value值是位元形式的1和0,雖然帶來了一定的資訊丟失,但是免去了使用時需要再次歸一化。

Relevant Link:

https://www.jiqizhixin.com/articles/2018-06-26-15
http://delivery.acm.org/10.1145/1330000/1325958/p950-lv.pdf?ip=42.120.75.135&id=1325958&acc=ACTIVE%20SERVICE&key=C8BAF422464E9FCC%2EC8BAF422464E9FCC%2E4D4702B0C3E38B35%2E4D4702B0C3E38B35&__acm__=1560846249_128df98f27ef856192df883b1ce48987
http://yangyi-bupt.github.io/ml/2015/08/28/lsh.html 

 

6. 球雜湊Spherical Hashing演算法

spherical hash是在前人hyperplane hash的基礎之上改進而來的,所以這裡我們首先來一起思考下hyperplane-base雜湊演算法都存在哪些問題。

0x1:hyperplane-based演算法存在的問題

  • 空間封閉性:hyperplane不容易形成一個closed region(封閉區間),如果我們要在一個d維空間中“切割”出一個closed region,我們至少需要d + 1個hyperplanes。而且這還是在這d+1個hyperplane都線性無關的情況下。最簡答的情況是是對於一個1維的線段,需要2條不同閾值的直線才能切割出一個封閉區間,高維空間可以類推。
    • ,左圖展示了通過3個hyperplane切割出了一個3維的封閉向量空間。
  • 演算法下界收斂性(Bounding Powrer):hyperplane切割得到的closed region不算非常緊湊(bounding),每個closed region內部,樣本間距離的bounding往往過大。這種情況下,降維雜湊函式的收斂性就會受影響,進而也影響後續近鄰搜尋的效果。
    • ,左圖展示了在hyperplane沒有完全封閉的區間內,樣本間距離可能會過大。
  • 超平面線性相關性(hyperplane Independence):hyperplane-based hash演算法中,hyperplane是基於隨機過程隨機取樣的,存在一定的可能性兩個hyperplane之間線性相關。通過線性代數的知識我們知道,線性相關性是空間中的冗餘結構,是可以被忽略的。
    • ,左圖展示了彼此正交的hyperplane和彼此線性相關的hyperplane的區別,顯然,彼此正交的hyperplane可以提供資訊增益。
  • 分介面資訊增益(Balanced partitioning):分介面資訊增益,指的是通過加入一個分介面,將分介面兩邊的資料分成0/1兩類,對最終的目標函式的提升度量。這個概念我們並不陌生,在決策樹的每個節點特徵選擇中,都是基於資訊增益最大的原則進行的。
    •   ,從左圖可以看到,我們希望每個hyperplane都能基本將樣本均分為兩半,在零先驗無監督的前提下,這可以提供最大的資訊增益,這也符合最大熵的原理。

sphericalplane hash超球體雜湊演算法就在這個背景下,在2012 CVPR上提出的。

面對上述幾個問題,sphericalplane hash進行了演算法理論和公式層面上的創新,我們接下來詳細討論具體細節。

0x2:sphericalplane hash的主要技術改進點

1. 用hypersphere超球面代替hyperplane超平面

我們知道,利用kernel space核空間技術,我們可以將線性超平面對映為一個非線性超平面,這是建立在核函式的理論基礎上的。但是研究發現,使用sphericalplane,因為球平面天生的封閉性,可以直接對高維空間進行partition分類,並獲得比non-linear hyperplane更好的效果。

理論上說,如果需要切割出一個d維封閉空間,至少需要d+1個超平面,但是如果使用超球體,則最少值需要1個超球體即可,例如下圖

值得注意的是,c個超球體劃分出的有界封閉區域數是可以計算的,即:

同時,球雜湊劃分的區域是封閉且更緊湊的,每個區域內樣本的最大距離的平均值(bounding power)會更小,說明各個區域的樣本是更緊湊的,如下圖所示:

Average of maximum distances within a partition: ‐ Hyper‐spheres gives tighter bound!

2. 通過Iterative Optimization過程實現hyperplane Independent和Balanced partitioning

通過漸進逼近的方法,迭代優化演算法超引數,得到符合演算法約束條件的近似最優解。

這裡的約束條件指的是:

1. 我們希望每個超球體把樣本都是均分的,就是球內球外各佔一半
2. 希望每個超球體的交叉部分不要太多,最多1/4,也就是每個雜湊函式相對獨立

0x3:演算法模型詳述

1. Optimization約束條件

優化過程最重要的一個前提就是設定約束條件(constraint condition)。

這裡首先先定義一些數學標記:

為資料向量在單個超球體(單個hash function)內部(+1)還是外部(-1)的概率。

為單個超球體的半徑。

Spherical Hashing是由c個不同位置,不同的大小的超球體組成的,對於c個超球體的總約束條件如下:

  • ,也即Balanced partitioning,資料向量在球內和求外的概率相等。
  • ,也即hyperplane Independent,任意兩個球的交叉概率等於1/4。

注意,約束2個1/4是一個理論極限值了,通過空間幾何的相關知識可以證明,當兩個球都近似將空間一分為二時,這兩個球的交集的最小值就是1/4。

2. 優化過程

優化過程的偽碼如下,我們接下來逐步討論:

1)輸入樣本準備

採用隨機取樣的方式從樣本集中取樣m個樣本,用於進行後續的優化過程。當然,如果你的算力足夠,也可以將所有樣本都作為訓練集進行優化訓練。

2)初始化 - 首次迭代

從樣本集S中隨機選擇c個資料點作為初始的超球體中心。

值得注意的是,作者在使用kmeans得到c個聚類中心作為初始的超球體中心後,並沒有很明顯提升實驗結果,這反映了求雜湊演算法對初始值不是非常敏感。

3)第二次及之後的迭代

接下來的迭代會不斷會動態調整半徑,以及動態移動球心的位置。為了方便計算,我們定義下面兩個輔助變數:

,1 ≤ i, j ≤ c。

對於來說,我們只要使其滿足即可。

對於來說,我們的目標是使其靠近m/4,通過計算當前值和目標值之間的殘差累積和,得到一個迴歸值,原論文中使用了力的概念形象地說明了這個過程。

對於交叉樣本太多的兩個球心,賦予一個repulsive的力,對離得太遠的兩個球賦予一個attractive 的力。然後計算這些力的累加作用,更新球心,再根據目標一更新半徑。對照上面演算法偽碼很容易理解該思想。

重複這個過程,直到滿足收斂條件。

4)收斂條件

理論上說,優化的最終結果應該是的均值為m/4,方差為0,即完全收斂,但是這很容易導致過擬合。

和很多漸進逼近的優化演算法一樣(例如gradient descent),球雜湊演算法設定了一個收斂近似精度,來提前停止優化,避免過擬合的發生。

演算法對均值和方差設定了一個容忍精度閾值 ,只要優化在一段步驟區間中,達到了這個容忍精度,即表明優化結果,停止優化。

在原論文中,作者對的值實驗最佳值分別是10%和15%。 

Relevant Link:

https://engineering.stanford.edu/people/moses-charikar
http://xueshu.baidu.com/s?wd=charikar+%E2%80%9CRandom+Hyperplane%E2%80%9D&tn=SE_baiduxueshu_c1gjeupa&cl=3&ie=utf-8&bs=charikar+Random+Hyperplane&f=8&rsv_bp=1&rsv_sug2=0&sc_f_para=sc_tasktype%3D%7BfirstSimpleSearch%7D
http://sglab.kaist.ac.kr/Spherical_Hashing/
https://blog.csdn.net/u014624632/article/details/79972100
http://sglab.kaist.ac.kr/Spherical_Hashing/Spherical_Hashing.pdf
https://blog.csdn.net/u014624632/article/details/79972100
https://blog.csdn.net/zwwkity/article/details/8565485
https://www.bbsmax.com/A/LPdojpBG53/

 

7. Simhash演算法

Simhash是一種降維投影方法,它將一段文字對映為一段固定位數的二進位制指紋(fixed length fingerprint),同時,這種fingerprint具有較好的語法/語義一致性。

它由google的Moses Charikar提出。整個演算法非常簡單精巧,我們這章來闡述一下其演算法過程。

0x1:Ngram分詞及權重統計

使用ngram對文件進行token化分詞,值得注意的是,n值的選取具有一定的技巧:

  • n越大越能找到真正相似的文件,但是n值越大越容易讓相似的文件在特徵維度上不相似,即n越大,容錯度越低;
  • 而n越小就能召回更多的文件,當時n值越小越可能將本來不相似的文件在 特徵維度表現相似,即n越小,容錯度越高。一個極端的情況,比如n=1,就變成了基本詞的比較了。

在分詞後,計算每個token的權重,可以通過ngram token詞頻統計得到w,也可以通過TF-IDF計算。不管用什麼方式,核心是將ngram token的權重表徵出來。

例如對”How are you?“這段話進行4-gram的切詞可以得到:

ngram tokens frequency list:  
{u'owar': 1, u'reyo': 1, u'howa': 1, u'eyou': 1, u'ware': 1, u'arey': 1}

注意,這裡權重w為1,只是我們舉例比較簡單,剛好的巧合。

0x2:Ngram Token雜湊化

將每個ngram token都被轉換為了一個雜湊hash,這個雜湊hash是隨機均勻分佈的,例如MD5、SHA-1演算法。

(u'owar', 1):  333172464361321106773216808497407930520
(u'reyo', 1):  310879434437019318776469684649603935114
(u'howa', 1):  98593099505511350710740956016689849066
(u'eyou', 1):  32675000308058660898513414756955031020
(u'ware', 1):  325869966946114134008620588371145019154
(u'arey', 1):  110781832133915061990833609831166700777

這個雜湊化過程主要是完成字串的數字化。因為對token進行雜湊處理的雜湊函式是像MD5、SHA1這種隨機雜湊函式,雜湊後的空間是隨機均勻的。因此不同的token得到的雜湊值本身不包含任何資訊熵。

那什麼東西包含資訊熵呢?筆者認為這裡的傳遞下一步的資訊熵有兩項:

  • token數量:token數量越多,包含的資訊自然也就越多
  • token權重:token權重越大,這個token在最終的fingerprint V向量中的”影響力“就越大

0x3:逐位翻譯為權重

對每個token hash進行逐位掃描,對某一個token hash來說,如果某一位為1,則賦值一個該token的正權重;如果某位是0,則賦值為該token的負權重。

得到一個N x M矩陣,N為token數量,M為fingerprint向量V的長度,原論文中預設為64bit,我們在實際開發中大多數也使用64bit,這是一個效率與效果比較折中的配置。

(u'owar', 1):  [-1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, -1, 1, 1, -1, -1, 1, -1, 1, 1, 1, 1, 1, -1, -1, -1, -1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, -1, -1, 1, 1, -1, -1, 1, 1, 1, 1, 1, 1, -1, 1, -1, 1, -1, -1, 1, -1, 1, -1, 1, 1, 1, 1, -1]
(u'reyo', 1):  [-1, 1, -1, 1, -1, -1, -1, 1, 1, 1, -1, 1, -1, 1, -1, 1, -1, -1, -1, 1, 1, 1, 1, 1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 1, -1, -1, -1, -1, -1, 1, 1, -1, 1, 1, 1, 1, 1, 1, 1, -1, 1, 1, -1, 1, -1, -1, 1, 1, 1, -1, 1, 1, -1, -1]
(u'howa', 1):  [-1, 1, -1, 1, -1, 1, 1, 1, -1, 1, -1, -1, -1, 1, -1, 1, -1, -1, 1, -1, 1, -1, -1, -1, -1, 1, 1, 1, -1, 1, -1, 1, 1, -1, 1, -1, 1, 1, -1, 1, -1, -1, -1, -1, -1, -1, -1, 1, 1, -1, 1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, -1, -1]
(u'eyou', 1):  [-1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, -1, -1, -1, -1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, 1, 1, 1, -1, -1, -1, -1, 1, 1, 1, 1, -1, -1, 1, 1, -1, -1, 1, 1, -1, -1, -1, 1, -1, -1, -1, -1, 1, -1, 1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
(u'ware', 1):  [-1, 1, -1, -1, 1, -1, -1, -1, 1, 1, 1, 1, -1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1, -1, 1, -1, 1, -1, 1, -1, -1, -1, -1, -1, 1, 1, -1, -1, -1, -1, 1, 1, 1, -1, -1, 1, -1, 1, -1, -1, 1, 1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1]
(u'arey', 1):  [1, -1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1, -1, 1, 1, 1, -1, 1, 1, 1, -1, -1, 1, 1, 1, 1, 1, -1, -1, 1, 1, 1, 1, -1, 1, 1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1]

這步有一個細節需要注意,即不管上一步token hash的位數多長,這一步都只進行fingerprint V長度的逐位掃描與翻譯,這實際上使用裁剪cutoff的方式實現了降維,這種壓縮對映會損失一部分準確性,引入一定的資訊損失和誤報,不過這和我們選擇的fingerprint V長度有關,我們選的V越長,例如128bit,這種資訊損失就越小。

從資訊熵的角度來說,這一步實際就是在將上一步傳入的token權重這一資訊進行翻譯。

0x4:逐位進行列維度sum壓縮合並

上一步得到的V是一個由token w組成的N x M矩陣,我們逐位進行縱向的列維度sum壓縮:

v:  [-4, 0, -4, 4, -2, 0, 0, 4, 0, 0, 0, 0, -4, 2, -2, 2, -2, 0, 4, 0, 2, 0, 0, 0, -4, 2, 2, 2, -4, 4, -4, 2, 0, 0, 0, 2, -2, -4, -2, 2, 0, -2, 0, 4, -2, 0, 2, 4, 4, -6, 0, -2, 0, -2, 0, -2, 0, 4, 4, 0, 2, 2, -4, -4]

這一步通過將每一bit上的所有資訊都壓縮綜合起來,得到最終的資訊表達。

0x5:逐位歸一化

上一步得到的V是一個1 x M,這裡M已經就是fingerprint長度的向量,預設為64bit,最後一步進行歸一化。

逐位bit掃描當前fingerprint向量 V,如果其值>0,則歸一化為1;如果其小於零,則歸一化為0

v_:  [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0]

用一張圖來總結梳理一下上述的幾個步驟:

 

這裡筆者丟擲一個問題來一起思考下,看起來在第四步已經得到了已經降維後的定長向量了,而且向量的每個元素也都是由所有token綜合起來得到的,應該能夠代表原始的輸入文字了,那為啥第五步還要多此一舉進行一次0/1歸一化呢?背後的原理是啥呢?

0x6:Simhash python實現程式碼 

# -*- coding: utf-8 -*-

from __future__ import division, unicode_literals

import re
import sys
import hashlib
import logging
import numbers
import collections
from itertools import groupby

if sys.version_info[0] >= 3:
    basestring = str
    unicode = str
    long = int
else:
    range = xrange


def _hashfunc(x):
    return int(hashlib.md5(x).hexdigest(), 16)


class Simhash(object):

    def __init__(
        self, value, f=64, reg=r'[\w\u4e00-\u9fcc]+', hashfunc=None, log=None
    ):
        """
        `f` is the dimensions of fingerprints

        `reg` is meaningful only when `value` is basestring and describes
        what is considered to be a letter inside parsed string. Regexp
        object can also be specified (some attempt to handle any letters
        is to specify reg=re.compile(r'\w', re.UNICODE))

        `hashfunc` accepts a utf-8 encoded string and returns a unsigned
        integer in at least `f` bits.
        """

        self.f = f
        self.reg = reg
        self.value = None

        if hashfunc is None:
            self.hashfunc = _hashfunc
        else:
            self.hashfunc = hashfunc

        if log is None:
            self.log = logging.getLogger("simhash")
        else:
            self.log = log
 
 
        if isinstance(value, Simhash):
            self.value = value.value
        elif isinstance(value, basestring):
            self.build_by_text(unicode(value))
        elif isinstance(value, collections.Iterable):
            self.build_by_features(value)
        elif isinstance(value, numbers.Integral):
            self.value = value
        else:
            raise Exception('Bad parameter with type {}'.format(type(value)))

    def __eq__(self, other):
        """
        Compare two simhashes by their value.

        :param Simhash other: The Simhash object to compare to
        """
        return self.value == other.value

    def _slide(self, content, width=4):
        return [content[i:i + width] for i in range(max(len(content) - width + 1, 1))]

    def _tokenize(self, content):
        content = content.lower()
        content = ''.join(re.findall(self.reg, content))
        ans = self._slide(content)  # ngram slide into tokens list
        return ans

    def build_by_text(self, content):
        # 1. ngram分詞
        features = self._tokenize(content)
        # 2. ngram token詞頻統計,統計得到的詞頻將作為權重
        features = {k:sum(1 for _ in g) for k, g in groupby(sorted(features))}
        print "ngram tokens frequency list: ", features
        return self.build_by_features(features)

    def build_by_features(self, features):
        """
        `features` might be a list of unweighted tokens (a weight of 1
                   will be assumed), a list of (token, weight) tuples or
                   a token -> weight dict.
        """
        v = [0] * self.f  # 初始化simhash fingerprint V,預設為64bit,每個元素初始化為0
        

        # 逐位為1的掩碼,即[1], [10], [100]....[100000(64個)],這個掩碼陣列的作用是後面進行逐位提取  
        masks = [1 << i for i in range(self.f)]
        print "masks: ", masks
        if isinstance(features, dict):
            features = features.items()
        for f in features:
            v_ = [0] * self.f
            # 如果傳入的是一個token string list,則預設每個token string的權重都為1
            if isinstance(f, basestring):
                # 通過雜湊雜湊演算法將每個ngram token轉換為一個hash序列
                h = self.hashfunc(f.encode('utf-8'))
                w = 1
            # 如果傳入的是一個(token, wight)的list,則按照預定的weight進行計算,我們本文預設採用ngram詞頻統計方式得到weight
            else:
                assert isinstance(f, collections.Iterable)
                h = self.hashfunc(f[0].encode('utf-8'))
                w = f[1]

            # 每個ngram token都被轉換為了一個雜湊hash,這個雜湊hash是隨機均勻分佈的
            #print "{0}: ".format(f), h
            # 迴圈f次(本文是64bit),逐位進行掃描,如果某一位是1,則賦值為該token的正權重;如果某位是0,則賦值為該token的負權重
            for i in range(self.f):
                #print "h & masks[i]: ", h & masks[i]
                v[i] += w if h & masks[i] else -w
                v_[i] += w if h & masks[i] else -w
            print "{0}: ".format(f), v_
        # 在完成對所有ngram token的掃描後,fingerprint向量 V 的每一位bit都是所有token hash在該bit上的權重加和結果。
        print "v: ", v
        ans = 0
        # 逐位bit掃描當前fingerprint向量 V,如果其值>0,則歸一化為1;如果其小於零,則歸一化為0
        v_ = [0] * self.f
        for i in range(self.f):
            if v[i] > 0:
                ans |= masks[i]
                v_[i] = 1
            else:
                v_[i] = 0
        print "v_: ", v_
        self.value = ans

    def distance(self, another):
        assert self.f == another.f
        x = (self.value ^ another.value) & ((1 << self.f) - 1)
        ans = 0
        while x:
            ans += 1
            x &= x - 1
        return ans


class SimhashIndex(object):

    def __init__(self, objs, f=64, k=2, log=None):
        """
        `objs` is a list of (obj_id, simhash)
        obj_id is a string, simhash is an instance of Simhash
        `f` is the same with the one for Simhash
        `k` is the tolerance
        """
        self.k = k
        self.f = f
        count = len(objs)

        if log is None:
            self.log = logging.getLogger("simhash")
        else:
            self.log = log

        self.log.info('Initializing %s data.', count)

        self.bucket = collections.defaultdict(set)

        for i, q in enumerate(objs):
            if i % 10000 == 0 or i == count - 1:
                self.log.info('%s/%s', i + 1, count)

            self.add(*q)

    def get_near_dups(self, simhash):
        """
        `simhash` is an instance of Simhash
        return a list of obj_id, which is in type of str
        """
        assert simhash.f == self.f

        ans = set()

        for key in self.get_keys(simhash):
            dups = self.bucket[key]
            self.log.debug('key:%s', key)
            if len(dups) > 200:
                self.log.warning('Big bucket found. key:%s, len:%s', key, len(dups))

            for dup in dups:
                sim2, obj_id = dup.split(',', 1)
                sim2 = Simhash(long(sim2, 16), self.f)

                d = simhash.distance(sim2)
                if d <= self.k:
                    ans.add(obj_id)
        return list(ans)

    def add(self, obj_id, simhash):
        """
        `obj_id` is a string
        `simhash` is an instance of Simhash
        """
        assert simhash.f == self.f

        for key in self.get_keys(simhash):
            v = '%x,%s' % (simhash.value, obj_id)
            self.bucket[key].add(v)

    def delete(self, obj_id, simhash):
        """
        `obj_id` is a string
        `simhash` is an instance of Simhash
        """
        assert simhash.f == self.f

        for key in self.get_keys(simhash):
            v = '%x,%s' % (simhash.value, obj_id)
            if v in self.bucket[key]:
                self.bucket[key].remove(v)

    @property
    def offsets(self):
        """
        You may optimize this method according to <http://www.wwwconference.org/www2007/papers/paper215.pdf>
        """
        return [self.f // (self.k + 1) * i for i in range(self.k + 1)]

    def get_keys(self, simhash):
        for i, offset in enumerate(self.offsets):
            if i == (len(self.offsets) - 1):
                m = 2 ** (self.f - offset) - 1
            else:
                m = 2 ** (self.offsets[i + 1] - offset) - 1
            c = simhash.value >> offset & m
            yield '%x:%x' % (c, i)

    def bucket_size(self):
        return len(self.bucket)

使用時,import引入即可:

# -*- coding: utf-8 -*-

from simhash import Simhash, SimhashIndex

if __name__ == '__main__':
    sh = Simhash('How are you? I Am fine. ablar ablar xyz blar blar blar blar blar blar blar Thanks.')
    sh2 = Simhash('How are you i am fine.ablar ablar xyz blar blar blar blar blar blar blar than')
    dis = sh.distance(sh2)

    print "sh: ", sh.value
    print "sh2: ", sh2.value
    print "dis: ", dis

Relevant Link:

https://www.mit.edu/~andoni/LSH/
http://www.cs.princeton.edu/courses/archive/spr04/cos598B/bib/CharikarEstim.pdf
http://people.csail.mit.edu/indyk/ 
https://blog.csdn.net/laobai1015/article/details/78011870 
https://github.com/LittleHann/simhash
https://www.cnblogs.com/hxsyl/p/4518506.html
https://zhuanlan.zhihu.com/p/32078737
https://www.kancloud.cn/kancloud/the-art-of-programming/41614
https://wizardforcel.gitbooks.io/the-art-of-programming-by-july/content/06.03.html
http://yanyiwu.com/work/2014/01/30/simhash-shi-xian-xiang-jie.html
https://www.cnblogs.com/maybe2030/p/5203186.html

0x7:Simhash為什麼能實現區域性敏感的效果 - Simhash底層的思想原理

筆者認為Simhash之所以可以實現區域性敏感,主要原有有兩個:

  • rooling piece wise分片思想
  • Sice Token權重思想

1. rooling piece wise分片思想

simhash的hash不是直接通過原始輸入文字計算得到的,而是通過ngram分片,將原始輸入文字通過滑動視窗分片得到slice token列表,對每一個slice token分別通過某種合理的方式計算一段hash,然後通過某種合理的方式將所有hash綜合起來,得到最終的hash。

我們通過一個例子來說明,假設有兩段文字:

1. how are u?
2. how are you?

分別使用4-gram進行切片,得到:

1. [u'howa', u'owar', u'ware', u'areu']
2. [u'howa', u'owar', u'ware', u'arey', u'reyo', u'eyou']

可以看到,因為ngram切片的原因,輸入文字中的修改隻影響到最終ngram list中的最後3個slice token,從而輸入文字對最終Hash的影響也從整個雜湊空間縮小到了最後3個slice token中,這就是所謂的區域性敏感演算法。

其實基於ngram的切片式特徵工程本身就是一個有損資訊抽取的特徵提取方式,這種資訊損失,一方面損失了精度,但是另一方面也帶來了對輸入區域性修改的容忍度。

但是simhash僅僅是感知區域性slice token的變化嗎?不是,光一個rooling slice checksum是無法提供足夠的區域性修改容忍度的。

2. Sice Token權重思想

除了rooling piece wise分片思想之外,Simhash還引入了”Sice Token權重思想“,即每個slice Token具體對最終的Hash能產生多大的影響,取決於這些slice Token的權重。

我們還是用一個例子來說明這句話的意思,假設有三段文字:

1. how are u?
2. how are you?
3. how are u? and u? and u? and u? and u?

可以看到,這3段文字都不一樣,但是如果我們以第一段文字為基準,可以發現另外2段文字的修改程度是不一樣的。

  • 第二段文字只是修改了一個單詞(修改了語法),但是主體語義沒有變。
  • 第二段文字修改的比較多,整體語法、語義上已經發生了變化。

還是使用4-gram進行切片,得到slice token list:

1. [u'howa', u'owar', u'ware', u'areu']
2. [u'howa', u'owar', u'ware', u'arey', u'reyo', u'eyou']
3. [u'howa', u'owar', u'ware', u'areu', u'reua', u'euan', u'uand', u'andu', u'ndua', u'duan', u'uand', u'andu', u'ndua', u'duan', u'uand', u'andu', u'ndua', u'duan', u'uand', u'andu']

可以看到,後兩個輸入文字都造成了很多slice token的改變。那最終的simhash受了多少影響呢?影響slice token數量多就是影響多嗎?

simhash在slice token之上,還引入了slice token weight一維度資訊,simhash不僅統計受影響的slice token,還會統計每個slice token的權重(例如是詞頻統計,也可以是TF-iDF)。

例如對上面的slice token list進行詞頻統計得:

1. {u'ware': 1, u'owar': 1, u'howa': 1, u'areu': 1}
2. {u'ware': 1, u'owar': 1, u'howa': 1, u'arey': 1, u'reyo': 1, , u'eyou': 1}
3. {u'ware': 1, u'owar': 1, u'howa': 1, u'areu': 1, u'reua': 1, , u'euan': 1, u'ndua': 3, , u'duan': 3, u'andu': 4, u'uand': 4}

可以看到,第二個文字雖然變動了2個slice token,但是權重不高,對最終的hash的影響有限。但是第三個文字中,不僅出現了較多token變動,而且每個token的權重比較高,它們對最終hash的影響就相對很大了。

為了說明上述的觀點,我們來執行一段示例程式碼:

# -*- coding: utf-8 -*-

from simhash import Simhash, SimhashIndex

if __name__ == '__main__':
    sh1 = Simhash('how are u?')
    sh2 = Simhash('how are you?')
    sh3 = Simhash('how are u? and u? and u? and u? and u?')
    dis_1_2 = sh1.distance(sh2)
    dis_1_3 = sh1.distance(sh3)

    print "sh1: ", sh1.value
    print "sh2: ", sh2.value
    print "sh3: ", sh3.value
 
    print "dis_1_2: ", dis_1_2
    print "dis_1_3: ", dis_1_3

可以看到,文字2和文字1的距離,小於文字3和文字1的距離。 

筆者認為,Simhash比Ssdeep效果好的主要原因之一就在於這第二點,即Slice Token權重思想,藉助權重均值化這種hash化方法,使得Simhash對多處少量的區域性可以具備更大的容忍度。

0x8:Simhas的降維過程

這裡提醒讀者朋友注意一個細節,simhash的降維過程分成了2個環節。第一個環節中,原始ascii特徵空間被降維到了ngram token特徵空間;第二個環節中,ngram token特徵空間被降維到了一個定長的fingerprint hashbit空間中,第二步降維的本質上也是一個線性變換的過程,從矩陣列向量的角度可以看的非常明顯。

0x9:Simhash與隨機超平面hash演算法的聯絡與區別 

Simhash演算法與上文提到隨機超平面雜湊之間是什麼關係呢?一言以蔽之:Simhash是隨機超平面投影的一種特殊實現,本質上屬於隨機超平面投影的一種

怎麼理解這句話呢?筆者帶領大家從列向量的視角來重新審視一下simhash的計算過程。simhash的具體原理這裡不再贅述,文章前面已經詳細討論過了,這裡直接進入正題。

假設輸入文字經過ngram之後得到5個詞token,並通過詞頻統計得到這5個詞token的權重向量d,d = (w1=1,w2=2,w3=0,w4=3,w5=0) 

simhash中是通過雜湊雜湊的方法得到每個詞token的一個向量化表示,這裡我們抓住其本質,即雜湊雜湊每一個詞token的本質目的就是為了定義一個低維的向量空間。

假設這5個詞token對應的3維向量分別為:

h(w1) = (1, -1, 1)
h(w2) = (-1, 1, 1)
h(w3) = (1, -1, -1)
h(w4) = (-1, -1, 1)
h(w5) = (1, 1, -1)

按照simhash的演算法,是將每個詞token向量乘上對應的權重w,然後再按照列相加起來,即

m = w1 * h(w1) + w2 * h(w2) + w3 * h(w3) + w4 * h(w4) + w5 * h(w5)
= 1 * h(w1) + 2 * h(w2) + 0 * h(w3) + 3 * h(w4) + 0 * h(w5) = (-4, -2, 6)

實際上,上述過程可以使用列向量矩陣的方式來一步完成:

接下來simhash的0/1歸一化,其實就是sgn符號函式。

可以看到,simhash演算法產生的結果與隨機超平面投影的結果是一致的。

更進一步地說,在simhash中,隨機超平面,被詞token的權重向量代替了,詞token權重向量作為超平面和原始向量進行內積計算,計算其夾角。

simhash演算法得到的兩個簽名的漢明距離,可以用來衡量原始向量的夾角。

Relevant Link:

http://www.cs.princeton.edu/courses/archive/spr04/cos598B/bib/CharikarEstim.pdf

 

8. Kernel LSH

前面討論的幾種LSH演算法,基本可以解決一般情況下的問題,不過對於某些特定情況還是不行,比如:

  • 輸入的key值不是均勻分佈在整個空間中,可能只是集中在某個小區域內,需要在這個區域內放大距離尺度
  • 如果我們採用直方圖作為特徵,往往會dense一些,向量只分布在大於0的區域中,不適合採用cosine距離,而stable Distribution投影方法引數太過敏感,實際設計起來較為困難和易錯

其實如果我們從計算公式的角度來看前面討論的幾種LSH,發現其形式都可以表示成內積的形式,提到內積自然會想到kernel方法,LSH也同樣可以使用kernel核方法,關於Kernel LSH的工作可參看下面這三篇文章。

Relevant Link:

http://www.robots.ox.ac.uk/~vgg/rg/papers/klsh.pdf 2009年ICCV上的 Kernelized Locality-Sensitive Hashing for Scalable Image Search
http://machinelearning.wustl.edu/mlpapers/paper_files/NIPS2009_0146.pdf 2009年NIPS上的Locality-Sensitive Binary Codes From Shift-Invariant Kernels
http://pages.cs.wisc.edu/~brecht/papers/07.rah.rec.nips.pdf 2007年NIPS上的Random Features for Large-Scale Kernel Machines

 

9. SSDEEP模糊化雜湊演算法

模糊雜湊演算法,又叫基於內容分割的分片分片雜湊演算法(context triggered piecewise hashing, CTPH)。

0x1:ssdeep主要演算法思想

筆者認為,ssdeep演算法的主要思想有以下幾點:

  • Dynamic piecewise hashing動態分片雜湊思想:基於輸入文字的長度,進行動態分片,將區域性的改變限制在一個有限長度的視窗內。

  • Slice piece hash cutoff壓縮對映:本質是資訊裁剪,通過主動丟失資訊的方式換取一定的改變容忍度。

0x2:演算法流程

1. 動態決定分片閾值

ssdeep的分片不是ngram那種固定size的滑動視窗機制,而是根據輸入文字的長度動態算出的一個n值。

我們知道,即便對弱雜湊,都具備隨機均勻雜湊的性質,即產生的結果在其對映空間上是接近於均勻分佈的。

在ssdeep中,n的值始終取2的整數次方,這樣Alder-32雜湊值(每個byte的滾動hash)除以n的餘數也接近於均勻分佈。僅當餘數等於n-1時分片,就相當於只有差不多1/n的情況下會分片。也就是說,對一個檔案,沒往前讀取一個byte,就有1/n的可能要分片。

在ssdeep中,每次都是將n除以或者乘以2,來調整,使最終的片數儘可能在32到64之間。

 bs = 3
 while bs * MAX_LENGTH < length:
   bs *= 2

同時在ssdeep中,n的值會作為一個最終結果的一部分出現,在比較的時候,n會作為一個考量因素被計入考量,具體細節後面會討論。 

上述策略下,一個新問題出現了。這是一種比較極端的情況。假設一個檔案使用的分片值n。在該檔案中改動一個位元組(修改、插入、刪除等),且這個改動影響了分片的數量,使得分片數增加或減少,例如把n乘以或者除以2。因此,即便對檔案的一個位元組改動,也可能導致分片條件n的變化,從而導致分片數相差近一倍,而得到的結果可能會發生巨大的變化,如何解決這個問題?

ssdeep解決這種問題的思考是加入冗餘因子,將邊界情況也納入進來。

對每一個檔案,它同時使用n和n/2作為分片值,算得兩個不同的模糊雜湊值,而這兩個值都使用。因此,最後得到的一個檔案的模糊雜湊值是:

n : h(n) : h(n/2)

而在比較時,如果兩個檔案的分片值分別為n和m,則判斷是否有n==m, n==2m, 2n==m三種情況,如果有之一,則將兩者相應的模糊雜湊值進行比較。例如,如果n==2m,則比較h(n/2)與h(m)是否相似。這樣,在一定程式上解決了分片值變化的問題。

2. 逐位元組讀取,計算Rooling Hash,並進行動態分片

ssdeep逐位元組讀取輸入文字內容,並採用滾動雜湊演算法(rolling hashing)不斷疊加式計算最新的hash,在ssdeep中,使用Alder-32 [4] 演算法作為弱雜湊。它實際是一種用於校驗和的弱雜湊,類似於CRC32,不能用於密碼學演算法,但計算快速,生成4位元組雜湊值,並且是滾動雜湊。

得到了當前byte對應的滾動hash值後,ssdeep基於動態分片閾值(上一節討論過)以及滾動Hash的當前State狀態值動態決定每一步(byte)是否分片。

  • 雜湊值除以n的餘數恰好等於n-1時,就在當前位置分片

  • 否則,不分片,視窗往後滾動一個位元組,然後再次計算Alder-32雜湊值並判斷,如此繼續

3. token雜湊化

和simhash一樣,對每個token進行隨機雜湊雜湊化,可以使用傳統的雜湊演算法,例如MD5。在ssdeep中,使用一個名為Fowler-Noll-Vo hash的雜湊演算法。

這一步沒有什麼特別意義,純粹是一個資訊傳遞過程。

4. token壓縮對映

對每一個檔案分片,計算得到一個雜湊值以後,可以選擇將結果壓縮短。例如,在ssdeep中,只取FNV(Fowler-Noll-Vo hash的雜湊演算法)雜湊結果的最低6位,並用一個ASCII字元表示出來,作為這個分片的最終雜湊結果。

這一步的壓縮對映損失了一部分的資訊,但是帶來了一定的冗餘度的提升。 

5. 連線雜湊值

將每片壓縮後的雜湊值連線到一起,就得到這個檔案的模糊雜湊值了(hash)。如果分片條件引數n對不同檔案可能不同,還應該將n納入模糊雜湊值中。

':'.join([str(bs), hash1, hash2])

注意,上文提到的h(n)和h(n/2)都要拼接進來

6. 比較雜湊值

在ssdeep中,採用的如下思路。由於ssdeep對每一片得到的雜湊值是一個ASCII字元,最終得到的檔案模糊雜湊值就是一個字串了。假設是s1、s2,將s1到s2的“加權編輯距離”(weighted edit distance)作為評價其相似性的依據。

接下來,ssdeep將這個距離除以s1和s2的長度和,以將絕對結果變為相對結果,再對映到0-100的一個整數值上,其中,100表示兩個字串完全一致,而0表示完全不相似。

0x3:ssdeep對輸入文字改動的容忍情況分析

我們來模擬分析一下模糊雜湊是如何面對不同程度的文字修改,以及又是如何在各種修改情況下進行相似性分析的,通過這個例子我們可以更清晰地理解ssdeep的工作原理。

我們以歸納推理的方法來展開分析,不管對原始輸入文字進行如何程度的修改,都可以從單個字元的修改這裡推演得到,複雜的增刪改查是簡單原子的修改的組合與疊加,這是部分與整體的關係。

如果在一個輸入文字中修改一個位元組,對ssdeep hash來說,有幾種情況:

 

0x4:python程式碼實現

# -*- coding: utf-8 -*-

import numpy as np
import collections
import doctest
import pprint


def INSERTION(A, cost=1):
    return cost


def DELETION(A, cost=1):
    return cost


def SUBSTITUTION(A, B, cost=1):
    return cost


Trace = collections.namedtuple("Trace", ["cost", "ops"])


class WagnerFischer(object):
    # Initializes pretty printer (shared across all class instances).
    pprinter = pprint.PrettyPrinter(width=75)

    def __init__(self,
                 A,
                 B,
                 insertion=INSERTION,
                 deletion=DELETION,
                 substitution=SUBSTITUTION):
        # Stores cost functions in a dictionary for programmatic access.
        self.costs = {"I": insertion, "D": deletion, "S": substitution}
        # Initializes table.
        self.asz = len(A)
        self.bsz = len(B)
        self._table = [[None for _ in range(self.bsz + 1)]
                       for _ in range(self.asz + 1)]
        # From now on, all indexing done using self.__getitem__.
        ## Fills in edges.
        self[0][0] = Trace(0, {"O"})  # Start cell.
        for i in range(1, self.asz + 1):
            self[i][0] = Trace(self[i - 1][0].cost + self.costs["D"](A[i - 1]),
                               {"D"})
        for j in range(1, self.bsz + 1):
            self[0][j] = Trace(self[0][j - 1].cost + self.costs["I"](B[j - 1]),
                               {"I"})
        ## Fills in rest.
        for i in range(len(A)):
            for j in range(len(B)):
                # Cleans it up in case there are more than one check for match
                # first, as it is always the cheapest option.
                if A[i] == B[j]:
                    self[i + 1][j + 1] = Trace(self[i][j].cost, {"M"})
                # Checks for other types.
                else:
                    costD = self[i][j + 1].cost + self.costs["D"](A[i])
                    costI = self[i + 1][j].cost + self.costs["I"](B[j])
                    costS = self[i][j].cost + self.costs["S"](A[i], B[j])
                    min_val = min(costI, costD, costS)
                    trace = Trace(min_val, set())
                    # Adds _all_ operations matching minimum value.
                    if costD == min_val:
                        trace.ops.add("D")
                    if costI == min_val:
                        trace.ops.add("I")
                    if costS == min_val:
                        trace.ops.add("S")
                    self[i + 1][j + 1] = trace
        # Stores optimum cost as a property.
        self.cost = self[-1][-1].cost

    def __repr__(self):
        return self.pprinter.pformat(self._table)

    def __iter__(self):
        for row in self._table:
            yield row

    def __getitem__(self, i):
        """
        Returns the i-th row of the table, which is a list and so
        can be indexed. Therefore, e.g.,  self[2][3] == self._table[2][3]
        """
        return self._table[i]

    # Stuff for generating alignments.

    def _stepback(self, i, j, trace, path_back):
        """
        Given a cell location (i, j) and a Trace object trace, generate
        all traces they point back to in the table
        """
        for op in trace.ops:
            if op == "M":
                yield i - 1, j - 1, self[i - 1][j - 1], path_back + ["M"]
            elif op == "I":
                yield i, j - 1, self[i][j - 1], path_back + ["I"]
            elif op == "D":
                yield i - 1, j, self[i - 1][j], path_back + ["D"]
            elif op == "S":
                yield i - 1, j - 1, self[i - 1][j - 1], path_back + ["S"]
            elif op == "O":
                return  # Origin cell, so we"re done.
            else:
                raise ValueError("Unknown op {!r}".format(op))

    def alignments(self):
        """
        Generate all alignments with optimal-cost via breadth-first
        traversal of the graph of all optimal-cost (reverse) paths
        implicit in the dynamic programming table
        """
        # Each cell of the queue is a tuple of (i, j, trace, path_back)
        # where i, j is the current index, trace is the trace object at
        # this cell, and path_back is a reversed list of edit operations
        # which is initialized as an empty list.
        queue = collections.deque(
            self._stepback(self.asz, self.bsz, self[-1][-1], []))
        while queue:
            (i, j, trace, path_back) = queue.popleft()
            if trace.ops == {"O"}:
                # We have reached the origin, the end of a reverse path, so
                # yield the list of edit operations in reverse.
                yield path_back[::-1]
                continue
            queue.extend(self._stepback(i, j, trace, path_back))

    def IDS(self):
        """
        Estimates insertions, deletions, and substitution _count_ (not
        costs). Non-integer values arise when there are multiple possible
        alignments with the same cost.
        """
        npaths = 0
        opcounts = collections.Counter()
        for alignment in self.alignments():
            # Counts edit types for this path, ignoring "M" (which is free).
            opcounts += collections.Counter(op for op in alignment
                                            if op != "M")
            npaths += 1
        # Averages over all paths.
        return collections.Counter({o: c / npaths
                                    for (o, c) in opcounts.items()})


FNV_PRIME = 0x01000193
FNV_INIT = 0x28021967
MAX_LENGTH = 64
B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"


class Last7chars(object):
    def __init__(self):
        self._reset_rollhash()

    def _reset_rollhash(self):
        self.roll_h1 = 0
        self.roll_h2 = 0
        self.roll_h3 = 0
        self.ringbuffer = [0] * 7
        self.writeindex = 0

    def _roll_hash(self, char):
        char7bf = self.readwrite(char)
        self.roll_h2 += 7 * char - self.roll_h1
        self.roll_h1 += char - char7bf
        self.roll_h3 <<= 5
        self.roll_h3 &= 0xffffffff
        self.roll_h3 ^= char
        return self.roll_h1 + self.roll_h2 + self.roll_h3

    def readwrite(self, num):
        retval = self.ringbuffer[self.writeindex]
        self.ringbuffer[self.writeindex] = num
        self.writeindex = (self.writeindex + 1) % 7
        return retval

    def __repr__(self):
        arr = self.ringbuffer[
            self.writeindex:] + self.ringbuffer[:self.writeindex]
        return " ".join(map(str, arr))


def _update_fnv(fnvhasharray, newchar):
    fnvhasharray *= FNV_PRIME
    fnvhasharray &= 0xffffffff
    fnvhasharray ^= newchar
    return fnvhasharray


def _calc_initbs(length):
    bs = 3
    while bs * MAX_LENGTH < length:
        bs *= 2

    if bs > 3:  #proably checking for integer overflow here?
        return bs
    return 3


def ssdeep_hash(content):
    bs = _calc_initbs(len(content))
    #print "bs: ", bs

    hash1 = ''
    hash2 = ''

    last7chars = Last7chars()

    while True:
        last7chars._reset_rollhash()
        fnv1 = FNV_INIT
        fnv2 = FNV_INIT
        hash1 = ''
        hash2 = ''
        fnvarray = np.array([fnv1, fnv2])

        for i in range(len(content)):   # 逐bytes掃描
            c = ord(content[i])
            # 使用Alder-32 [4] 演算法作為弱雜湊。它實際是一種用於校驗和的弱雜湊,類似於CRC32,不能用於密碼學演算法,但計算快速,生成4位元組雜湊值,並且是滾動雜湊。
            h = last7chars._roll_hash(c)
            #print "h_roll_hash: ", h
            fnvarray = _update_fnv(fnvarray, c)

            # 當Alder-32雜湊值除以n的餘數恰好等於n-1時,就在當前位置分片;否則,不分片,視窗往後滾動一個位元組,然後再次計算Alder-32雜湊值並判斷,如此繼續
            # 1. 使用bs作為分片值
            if h % bs == (bs - 1) and len(hash1) < (MAX_LENGTH - 1):
                # 對每片分別計算雜湊了。可以使用傳統的雜湊演算法,例如MD5。在ssdeep中,使用一個名為Fowler-Noll-Vo hash的雜湊演算法
                b64char = B64[fnvarray[0] & 63]
                hash1 += b64char
                fnvarray[0] = FNV_INIT
            # 2. 使用2*bs作為分片值
            if h % (2 * bs) == (2 * bs - 1) and len(hash2) < (
                    MAX_LENGTH / 2 - 1):
                b64char = B64[fnvarray[1] & 63]
                hash2 += b64char
                fnvarray[1] = FNV_INIT

        # 將每片壓縮後的雜湊值連線到一起,就得到這個檔案的模糊雜湊值了
        hash1 += B64[fnvarray[0] & 63]  # 對每一個檔案分片,計算得到一個雜湊值以後,可以選擇將結果壓縮短。例如,在ssdeep中,只取FNV雜湊結果的最低6位,並用一個ASCII字元表示出來,作為這個分片的最終雜湊結果
        hash2 += B64[fnvarray[1] & 63]  # 這裡 &63,等價於取最低6bit

        if bs <= 3 or len(hash1) > (MAX_LENGTH / 2):
            break
        bs = int(bs / 2)
        if bs < 3:
            bs = 3
    # 對每一個檔案,它同時使用n和n/2作為分片值,算得兩個不同的模糊雜湊值,而這兩個值都使用。因此,最後得到的一個檔案的模糊雜湊值是: n:h(n):h(n/2)
    return ':'.join([str(bs), hash1, hash2])


#from https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Longest_common_substring#Python_2
def longest_common_substring(s1, s2):
    m = [[0] * (1 + len(s2)) for i in xrange(1 + len(s1))]
    longest, x_longest = 0, 0
    for x in xrange(1, 1 + len(s1)):
        for y in xrange(1, 1 + len(s2)):
            if s1[x - 1] == s2[y - 1]:
                m[x][y] = m[x - 1][y - 1] + 1
                if m[x][y] > longest:
                    longest = m[x][y]
                    x_longest = x
            else:
                m[x][y] = 0
    return s1[x_longest - longest:x_longest]


def _likeliness(min_lcs, a, b):
    # 如果最長公共子串長度不滿足要求,則直接退出
    if longest_common_substring(a, b) < min_lcs:
        return 0

    # Wagner Fischer演算法(字串編輯距離,Edit Distance)
    dist = WagnerFischer(a, b).cost
    # ssdeep將這個距離除以s1和s2的長度和,以將絕對結果變為相對結果,再對映到0-100的一個整數值上,其中,100表示兩個字串完全一致,而0表示完全不相似
    dist = int(dist * MAX_LENGTH / (len(a) + len(b)))
    dist = int(100 * dist / 64)
    if dist > 100:
        dist = 100
    return 100 - dist


def ssdeep_compare(hashA, hashB, min_lcs=7):
    bsA, hs1A, hs2A = hashA.split(':')  #blocksize, hash1, hash2
    bsB, hs1B, hs2B = hashB.split(':')

    bsA = int(bsA)
    bsB = int(bsB)

    like = 0

    # 在比較時,如果兩個檔案的分片值分別為n和m,則判斷是否有n==m, n==2m, 2n==m三種情況,如果有之一,則將兩者相應的模糊雜湊值進行比較。例如,如果n==2m,則比較h(n/2)與h(m)是否相似
    #block size comparison
    if bsA == bsB:
        #compare both hashes
        like1 = _likeliness(min_lcs, hs1A, hs1B)
        like2 = _likeliness(min_lcs, hs2A, hs2B)
        like = max(like1, like2)
    elif bsA == 2 * bsB:
        # Compare hash_bsA with hash_2*bsB
        like = _likeliness(min_lcs, hs1A, hs2B)
    elif 2 * bsA == bsB:
        # Compare hash_2*bsA with hash_bsB
        like = _likeliness(min_lcs, hs2A, hs1B)
    else:  #nothing suitable to compare
        like = 0
    return like


if __name__ == '__main__':
    import sys
    content1 = "this is a test!"
    content2 = "this is a test."
    hash1 = ssdeep_hash(content1)
    print hash1
    hash2 = ssdeep_hash(content2)
    print hash2
    similarity = ssdeep_compare(hash1, hash2)
    print similarity

Relevant Link: 

https://github.com/LittleHann/ssdeeppy
https://ssdeep-project.github.io/ssdeep/
https://www.claudxiao.net/2012/02/fuzzy_hashing/#comment-457473

 

相關文章