NLP之句子相似度之入門篇


如下在師兄的博文基礎上修改:靜覓 » 自然語言處理中句子相似度計算的幾種方法

1.基於統計的方法

1.1.編輯距離計算

編輯距離,英文叫做 Edit Distance,又稱 Levenshtein 距離,是指兩個字串之間,由一個轉成另一個所需的最少編輯操作次數,如果它們的距離越大,說明它們越是不同。許可的編輯操作包括將一個字符替換成另一個字符,插入一個字符,刪除一個字符。

例如我們有兩個字符串:string 和 setting,如果我們想要把 string 轉化爲 setting,需要這麼兩步:

  • 第一步,在 s 和 t 之間加入字符 e。
  • 第二步,把 r 替換成 t。

所以它們的編輯距離差就是 2,這就對應着二者要進行轉化所要改變(添加、替換、刪除)的最小步數。

那麼用 Python 怎樣來實現呢,我們可以直接使用 distance 庫:

import distance

def edit_distance(s1, s2):
    return distance.levenshtein(s1, s2)

s1 = 'string'
s2 = 'setting'
print(edit_distance(s1, s2))

這裏我們直接使用 distance 庫的 levenshtein() 方法,傳入兩個字符串,即可獲取兩個字符串的編輯距離了。
運行結果如下:

2

這裏的 distance 庫我們可以直接使用 pip3 來安裝:pip3 install distance
這樣如果我們想要獲取相似的文本的話可以直接設定一個編輯距離的閾值來實現,如設置編輯距離爲 2,下面是一個樣例:

import distance

def edit_distance(s1, s2):
    return distance.levenshtein(s1, s2)

strings = [
    '你在幹什麼',
    '你在幹啥子',
    '你在做什麼',
    '你好啊',
    '我喜歡喫香蕉'
]

target = '你在幹啥'
results = list(filter(lambda x: edit_distance(x, target) <= 2, strings))
print(results)

這裏我們定義了一些字符串,然後定義了一個目標字符串,然後用編輯距離 2 的閾值進行設定,最後得到的結果就是編輯距離在 2 及以內的結果,運行結果如下:

['你在幹什麼', '你在幹啥子']

通過這種方式我們可以大致篩選出類似的句子,但是發現一些句子例如“你在做什麼” 就沒有被識別出來,但他們的意義確實是相差不大的,因此,編輯距離並不是一個好的方式,但是簡單易用。

1.2.傑卡德係數計算

傑卡德係數,英文叫做 Jaccard index, 又稱爲 Jaccard 相似係數,用於比較有限樣本集之間的相似性與差異性。Jaccard 係數值越大,樣本相似度越高。
實際上它的計算方式非常簡單,就是兩個樣本的交集除以並集得到的數值,當兩個樣本完全一致時,結果爲 1,當兩個樣本完全不同時,結果爲 0。
算法非常簡單,就是交集除以並集,下面我們用 Python 代碼來實現一下:

from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
 
 
def jaccard_similarity(s1, s2):
    def add_space(s):
        return ' '.join(list(s))
    
    # 將字中間加入空格
    s1, s2 = add_space(s1), add_space(s2)
    # 轉化爲TF矩陣
    cv = CountVectorizer(tokenizer=lambda s: s.split())
    corpus = [s1, s2]
    vectors = cv.fit_transform(corpus).toarray()
    # 求交集
    numerator = np.sum(np.min(vectors, axis=0))
    # 求並集
    denominator = np.sum(np.max(vectors, axis=0))
    # 計算傑卡德係數
    return 1.0 * numerator / denominator
 
 
s1 = '你在幹嘛呢'
s2 = '你在幹什麼呢'
print(jaccard_similarity(s1, s2))

這裏我們使用了 Sklearn 庫中的 CountVectorizer 來計算句子的 TF 矩陣,然後利用 Numpy 來計算二者的交集和並集,隨後計算傑卡德係數。

這裏值得學習的有 CountVectorizer 的用法,通過它的 fit_transform() 方法我們可以將字符串轉化爲詞頻矩陣,例如這裏有兩句話“你在幹嘛呢”和“你在幹什麼呢”,首先 CountVectorizer 會計算出不重複的有哪些字,會得到一個字的列表,結果爲:

['麼', '什', '你', '呢', '嘛', '在', '幹']

這個其實可以通過如下代碼來獲取,就是獲取詞表內容:cv.get_feature_names()
接下來通過轉化之後,vectors 變量就變成了:

[[0 0 1 1 1 1 1]
 [1 1 1 1 0 1 1]]

它對應的是兩個句子對應詞表的詞頻統計,這裏是兩個句子,所以結果是一個長度爲 2 的二維數組,比如第一句話“你在幹嘛呢”中不包含“麼”字,那麼第一個“麼”字對應的結果就是0,即數量爲 0,依次類推。

後面我們使用了 np.min() 方法並傳入了 axis 爲 0,實際上就是獲取了每一列的最小值,這樣實際上就是取了交集,np.max() 方法是獲取了每一列的最大值,實際上就是取了並集。

二者分別取和即是交集大小和並集大小,然後作商即可,結果如下:

0.5714285714285714

這個數值越大,代表兩個字符串越接近,否則反之,因此我們也可以使用這個方法,並通過設置一個相似度閾值來進行篩選。

1.3.TF 計算

第三種方案就是直接計算 TF 矩陣中兩個向量的餘弦相似度了,實際上就是求解兩個向量夾角的餘弦值,就是點乘積除以二者的模長,公式如下:cosθ=ababcosθ=\frac {a \cdot b}{|a||b|}
更多關於餘弦相似度:

上面我們已經獲得了 TF 矩陣,下面我們只需要求解兩個向量夾角的餘弦值就好了,代碼如下:

from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
from scipy.linalg import norm

def tf_similarity(s1, s2):
    def add_space(s):
        return ' '.join(list(s))
    
    # 將字中間加入空格
    s1, s2 = add_space(s1), add_space(s2)
    # 轉化爲TF矩陣
    cv = CountVectorizer(tokenizer=lambda s: s.split())
    corpus = [s1, s2]
    vectors = cv.fit_transform(corpus).toarray()
    # 計算TF係數
    return np.dot(vectors[0], vectors[1]) / (norm(vectors[0]) * norm(vectors[1]))


s1 = '你在幹嘛呢'
s2 = '你在幹什麼呢'
print(tf_similarity(s1, s2))

在在這裏我們使用了 np.dot() 方法獲取了向量的點乘積,然後通過 norm() 方法獲取了向量的模長,經過計算得到二者的 TF 係數,結果如下:

0.7302967433402214

1.4.TFIDF 計算

另外除了計算 TF 係數我們還可以計算 TFIDF 係數,TFIDF 實際上就是在詞頻 TF 的基礎上再加入 IDF 的信息,IDF 稱爲逆文檔頻率,不瞭解的可以看下阮一峯老師的講解:http://www.ruanyifeng.com/blog/2013/03/tf-idf.html,裏面對 TFIDF 的講解也是十分透徹的。
關於idf(t)應該這樣認識:一個詞語在文檔集合中出現了n次,文檔集合總數爲N。idf(t)來源於信息論。那麼每篇文檔出現這個詞語的概率爲:n/Nn/N,所以這篇文檔出現這個詞語的信息量爲:log(n/N)-log(n/N)。這個和信息熵有些類似(P(x)logP(x))(-P(x)logP(x)),在數據挖掘的過濾法進行特徵選擇時,需要用到互信息,其實是計算信息增益,還有決策樹。把log(n/N)-log(n/N)變換一下,log(N/n)log(N/n),爲了避免0的出現,進行平滑處理,就是上面的公式(就像樸素貝葉斯需要拉普拉斯平滑處理一樣)。

下面我們還是藉助於 Sklearn 中的模塊 TfidfVectorizer 來實現,代碼如下:

from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
from scipy.linalg import norm


def tfidf_similarity(s1, s2):
    def add_space(s):
        return ' '.join(list(s))
    
    # 將字中間加入空格
    s1, s2 = add_space(s1), add_space(s2)
    # 轉化爲TF矩陣
    cv = TfidfVectorizer(tokenizer=lambda s: s.split())
    corpus = [s1, s2]
    vectors = cv.fit_transform(corpus).toarray()
    # 計算TF係數
    return np.dot(vectors[0], vectors[1]) / (norm(vectors[0]) * norm(vectors[1]))


s1 = '你在幹嘛呢'
s2 = '你在幹什麼呢'
print(tfidf_similarity(s1, s2))

這裏的 vectors 變量實際上就對應着 TFIDF 值,內容如下:

[[0.         0.         0.4090901  0.4090901  0.57496187 0.4090901 0.4090901 ]
 [0.49844628 0.49844628 0.35464863 0.35464863 0.  0.35464863 0.35464863]]

運行結果如下:

0.5803329846765686

所以通過 TFIDF 係數我們也可以進行相似度的計算。

1.5.BM25

BM25 is a bag-of-words retrieval function that ranks a set of documents based on the query terms appearing in each document, regardless of the inter-relationship between the query terms within a document (e.g., their relative proximity). It is not a single function, but actually a whole family of scoring functions, with slightly different components and parameters. One of the most prominent instantiations of the function is as follows.

BM25算法,通常用來作搜索相關性平分。一句話概況其主要思想:對Query進行語素解析,生成語素qi;然後,對於每個搜索結果D,計算每個語素qi與D的相關性得分,最後,將qi相對於D的相關性得分進行加權求和,從而得到Query與D的相關性得分。

BM25算法的一般性公式如下:
Score(Q,d)=inWiR(qi,d)Score(Q,d) = \sum _i ^n W_i \cdot R(q_i,d)
其中,QQ表示Query,qiq_i表示QQ解析之後的一個語素(對中文而言,我們可以把對Query的分詞作爲語素分析,每個詞看成語素qi。);dd表示一個搜索結果文檔;WiW_i表示語素qiq_i的權重;R(qid)R(q_i,d)表示語素qiq_i與文檔dd的相關性得分。

下面我們來看如何定義WiW_i。判斷一個詞與一個文檔的相關性的權重,方法有多種,較常用的是IDF。這裏以IDF爲例,公式如下:
IDF(qi)=logNn(qi)+0.5n(qi)+0.5IDF(q_i) = log \frac {N-n(q_i)+0.5} {n(q_i)+0.5}

其中,NN爲索引中的全部文檔數,n(qi)n(q_i)爲包含了qi的文檔數。

根據IDF的定義可以看出,對於給定的文檔集合,包含了qiq_i的文檔數越多,qiq_i的權重則越低。也就是說,當很多文檔都包含了qiq_i時,qiq_i的區分度就不高,因此使用qiq_i來判斷相關性時的重要度就較低。

我們再來看語素qiq_i與文檔d的相關性得分R(qid)R(q_i,d)。首先來看BM25中相關性得分的一般形式:
R(qi,d)=fi(k1+1)fi+Kdfi(k2+1)qfi+k2R(q_i,d) = \frac {f_i(k_1+1)} {f_i+K} \cdot \frac{df_i(k_2+1)}{qf_i+k_2}
K=k1(1b+bdlavbdl)K = k_1\cdot(1-b+b \cdot \frac{dl}{avbdl})
其中,k1k_1k2k_2bb爲調節因子,通常根據經驗設置,一般k1k_1=2,bb=0.75fif_iqiq_idd中的出現頻率,qfiqf_iqiq_i在Query中的出現頻率。dldl爲文檔d的長度,avgdlavgdl爲所有文檔的平均長度。由於絕大部分情況下,qiq_i在Query中只會出現一次,即qfiqf_i=1,因此公式可以簡化爲:
R(qi,d)=fi(k1+1)fi+KR(q_i,d) = \frac {f_i(k_1+1)} {f_i+K}
KK的定義中可以看到,參數bb的作用是調整文檔長度對相關性影響的大小。bb越大,文檔長度的對相關性得分的影響越大,反之越小。而文檔的相對長度越長,KK值將越大,則相關性得分會越小。這可以理解爲,當文檔較長時,包含qiq_i的機會越大,因此,同等fi的情況下,長文檔與qi的相關性應該比短文檔與qiq_i的相關性弱。

綜上,BM25算法的相關性得分公式可總結爲:
Score(Q,d)=inIDF(qi)fi(k1+1)fi+k1(1b+bdlavbdl)Score(Q,d) = \sum _i ^n IDF(q_i) \cdot \frac {f_i(k_1+1)} {f_i+k_1\cdot(1-b+b \cdot \frac{dl}{avbdl})}
BM25考慮了4個因素:IDF因子,文檔長度因子,文檔詞頻因子和查詢詞頻因子。lucene內部的BM25要比上面公式的簡單一些,個人認爲並不是很好
從BM25的公式可以看到,通過使用不同的語素分析方法、語素權重判定方法,以及語素與文檔的相關性判定方法,我們可以衍生出不同的搜索相關性得分計算方法,這就爲我們設計算法提供了較大的靈活性。

這裏提供一份簡單的源碼demo,詳見我的github:BM25

更多相關推到詳見:經典檢索算法:BM25原理(格式有有點亂,建議粘貼到Typora裏面看)

2.基於深度學習的方法

2.1.Word2Vec 計算

上述的方法都是基於統計的方法,基於統計的方法無法滿足語義上的相似度匹配,下面的方法是基於深度學習的方法,在一定程度上解決了語義的相似度匹配。

Word2Vec,顧名思義,其實就是將每一個詞轉換爲向量的過程。如果不瞭解的話可以參考:https://blog.csdn.net/itplus/article/details/37969519。

這裏我們可以直接下載訓練好的 Word2Vec 模型,模型的鏈接地址爲:https://pan.baidu.com/s/1TZ8GII0CEX32ydjsfMc0zw,是使用新聞、百度百科、小說數據來訓練的 64 維的 Word2Vec 模型,數據量很大,整體效果還不錯,我們可以直接下載下來使用,這裏我們使用的是 news_12g_baidubaike_20g_novel_90g_embedding_64.bin 數據,然後實現 Sentence2Vec,代碼如下:

import gensim
import jieba
import numpy as np
from scipy.linalg import norm

model_file = './word2vec/news_12g_baidubaike_20g_novel_90g_embedding_64.bin'
model = gensim.models.KeyedVectors.load_word2vec_format(model_file, binary=True)

def vector_similarity(s1, s2):
    def sentence_vector(s):
        words = jieba.lcut(s)
        v = np.zeros(64)
        for word in words:
            v += model[word]
        v /= len(words)
        return v
    
    v1, v2 = sentence_vector(s1), sentence_vector(s2)
    return np.dot(v1, v2) / (norm(v1) * norm(v2))

在獲取 Sentence Vector 的時候,我們首先對句子進行分詞,然後對分好的每一個詞獲取其對應的 Vector,然後將所有 Vector 相加並求平均,這樣就可得到 Sentence Vector 了,然後再計算其夾角餘弦值即可。

調用示例如下:

s1 = '你在幹嘛'
s2 = '你正做什麼'
vector_similarity(s1, s2)

結果如下:

0.6701133967824016

這時如果我們再回到最初的例子看下效果:

strings = [
    '你在幹什麼',
    '你在幹啥子',
    '你在做什麼',
    '你好啊',
    '我喜歡喫香蕉'
]

target = '你在幹啥'

for string in strings:
    print(string, vector_similarity(string, target))

依然是前面的例子,我們看下它們的匹配度結果是多少,運行結果如下:

你在幹什麼 0.8785495016487204
你在幹啥子 0.9789649689827049
你在做什麼 0.8781992402695274
你好啊 0.5174225914249863
我喜歡喫香蕉 0.582990841450621

可以看到相近的語句相似度都能到 0.8 以上,而不同的句子相似度都不足 0.6,這個區分度就非常大了,可以說有了 Word2Vec 我們可以結合一些語義信息來進行一些判斷,效果明顯也好很多。

所以總體來說,Word2Vec 計算的方式是非常好的。

以上五小節便是進行句子相似度計算的基本方法和 Python 實現,本節代碼地址:https://github.com/AIDeepLearning/SentenceDistance。

6.

另外學術界還有一些可能更好的研究成果,這個可以參考知乎上的一些回答:https://www.zhihu.com/question/29978268/answer/54399062。

參考文獻

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章