基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

PaperWeekly發表於2018-07-30

前言:計算機視覺的朋友會知道,am-softmax 是人臉識別中的成果。所以這篇文章就是借鑑人臉識別的做法來做句子相似度模型,順便介紹在 Keras 下各種 margin loss 的寫法。

背景

細想之下會發現,句子相似度與人臉識別有很多的相似之處。

已有的做法

在我搜尋到的資料中,深度學習做句子相似度模型,就只有兩種做法:一是輸入一對句子,然後輸出一個 0/1 標籤代表相似程度,也就是視為一個二分類問題,比如 Learning Text Similarity with Siamese Recurrent Networks [1] 中的模型是這樣的:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

▲ 將句子相似度視為二分類模型

包括今年拍拍貸的“魔鏡杯”,也是這種格式。另外一種做法是輸入一個三元組“(句子 A,跟 A 相似的句子,跟 A 不相似的句子)”,然後用 triplet loss 的做法解決,比如文章 Applying Deep Learning To Answer Selection: A Study And An Open Task [2] 中的做法。 

這兩種做法其實也可以看成是一種,本質上是一樣的,只不過 loss 和訓練方法有所差別。但是,這兩種方法卻都有一個很嚴重的問題:負樣本取樣嚴重不足,導致效果提升非常慢。

使用場景

我們不妨回顧一下我們使用句子相似度模型的場景。一般來說,我們事先存好了很多 FAQ 對,也就是“問題-答案”的語料對。當我們碰到一個新問題時,我們就需要比較這個新問題與原來資料庫中所有問題的相似度,找出最相似的那個,根據相似度和閾值決定是否做出回答。 

注意,這裡邊包含了兩個要素,第一是“所有”,理論上來說,我們跟資料庫中的所有問題都比較一次,然後找出最相似的;第二是“閾值”,我們也不知道新問題在資料庫中是否有答案,因此這個閾值決定是我們是否要做出迴應。如果不管三七二十一都取 top 1 來作答,那體驗也會很差的。 

我們先來關心“所有”。“所有”意味著在訓練的時候,對於每個句子,除了僅有的幾個相似句是正樣本,其它所有句子都應該作為負樣本。但如果用前面的做法,其實我們很難完整地取樣所有的負樣本出來,而且就算可以做到,訓練時間也非常長。這就是前面說的弊端所在。

來自人臉的幫助

我一直覺得,在機器學習領域中,其實不應該過分“劃清界線”,比如有些讀者覺得自己是做 NLP 的,於是就不碰影象,反過來做影象的,看到 NLP 的就遠而避之。事實上,整個機器學習領域之間的溝壑並沒有那麼大,很多東西的本質都是一樣的,只是場景不同而已。比如,所謂的句子相似度模型,其實幾乎就完全對應於人臉識別任務,而人臉識別目前已經相當成熟了,顯然我們是可以借鑑的。 

先不說模型,我們來想象一下人臉識別的使用場景。比如公司內可以用人臉識別打卡,當有了一個人臉識別模型後,我們事先會存好一些公司員工的人臉照片,然後每天上班時,先拍一張員工的人臉照(實時拍攝,顯然不會跟已經存好的照片完全吻合),然後要判斷他/她是不是公司的員工,如果是,還要確定是哪一位員工。 

試想一下,將上面的場景中,“人臉”換成“句子”,是不是就是句子相似度模型的使用場景呢? 

顯然,句子相似度模型可以是說 NLP 中的人臉識別了。

模型

句子相似度和人臉識別在各方面都很相似:從模型的使用到構建乃至資料集的量級上,都是如此地接近。所以,幾乎人臉識別的一切模型和技巧,都可以用在句子相似度模型上。 

作為分類問題

事實上,前面說的 triplet loss,也是訓練人臉識別模型的標準方法之一。triplet loss 本身沒有錯,反而,如果能精調引數並且重新訓練,它效果還可能非常好。只是在很多情況下,它實在是太低效了。當前,更標準的做法是:視為一個多分類問題。 

比如,假設訓練集裡邊有 10 萬個不同的人,每個人 5 張人臉圖,那麼就有 50 萬張訓練圖片了。然後我們訓練一個 CNN 模型,對圖片提取特徵,並構建一個 10 萬分類的模型。沒錯,就是跟 mnist 一樣的分類問題,只不過這時候分類數目大得多,有多少個不同的人就有多少類。那麼,句子相似度問題也可以這樣做,可以將訓練集劃分為很多組“同義句”,然後有多少組就有多少類,也將句子相似度問題當作分類問題來做。 

注意,這僅僅是訓練,最後訓練出來的分類模型可能毫無用處。這不難想象,我們可以用已有的人臉資料庫來訓練一個人臉識別模型,但我們的使用場景可能是公司打卡,也就是說要識別的人臉是公司內部的員工臉,他們顯然不會在公開的人臉資料庫中。所以分類模型是沒有意義的,真正有意義的是分類之前的特徵提取模型。比如,一個典型的 CNN 分類模型可以簡寫為兩步:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

其中 x 是輸入,p 是每一類的概率輸出,這裡的 softmax 不用加偏置項。作為一個分類問題訓練時,我們輸出的是人臉圖片 x 和對應的 one hot 標籤 p,但是在使用的時候,我們不用整個模型,我們只用 CNN(x) 這部分,這部分負責將每一張人臉圖片轉化為一個固定長度的向量。 

有了這個轉化模型(編碼器,encoder),不管什麼場景下,我們都可以對新人臉進行編碼,然後轉化為這些編碼向量之間的比較,從而就不依賴原來的分類模型。所以,分類模型是一個訓練方案,一旦訓練完成,它就功成身退了,留下的是編碼模型。

分類與排序

這樣就可以了?還沒有。前面說到,我們真正要做的是一個特徵提取模型(編碼器),並且用分類模型作為訓練方案,而最後使用的方法是對特徵提取模型的特徵進行對比排序。 

我們要做特徵排序,但是藉助分類模型訓練,這兩者等價嗎? 

答案是:相關但不等價分類問題是怎麼做的呢?直觀來看,它是選定了一些類別中心,然後說:每個樣本都屬於距離它最近的中心的那一類。 

當然這些類別中心也是訓練出來的,而這裡的“距離”可以有多種可能性,比如歐式距離、cos 值、內積都可以,一般的 softmax 對應的就是內積。分類問題的這種做法,就導致了下面的可能的分類結果:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

▲ 一種可能的分類結果,其中紅色點代表類別中心,其他點代表樣本

這個分類結果有什麼問題呢?我們留意圖上的 z1,z2,z3 三個樣本,其中 z1,z3 距離 c1 最近,所以它們是類別 1 的,z2 距離 c2 最近,所以它是類別 2 的,假設這個分類沒有錯,也就是說 z1,z3 它們可能是同義句,z2 跟它們不是同義的,又或者 z1,z3 是同一個人的人臉圖,而 z2 則是另一個人的。

從分類角度,這結果很合理,但我們已經說過,我們最終不要分類模型,我們需要特徵之間的比較。這樣問題就很明顯了:z1,z2 距離這麼近,卻不是同一類的,z1 跟 z3 距離這麼遠,卻是同一類的。如果我們用特徵排序的方法給 z1 找一個同義句,那麼就會找到 z2 而不是 z3。

Loss

上面說的,就是分類與排序的不等價性,當然,從圖上也可以看出,儘管不完全等價,分類模型還是給了大部分的特徵一個合理的位置分佈,只是在邊緣附近的特徵,就可能出現問題。

Margin Softmax

可以想象,問題出現在分類邊界附近的那些點上面,而出現問題的原因,其實就是分類條件過於寬鬆,如果加強一下分類條件,就可以提升排序效果了,比如改為:每個樣本與它所屬類的距離,必須小於它跟其他類的距離的 1/2。 

原來我們只需要小於它與其他類的距離,現在不但要這樣,還要小於其它距離的一半,顯然條件加強了,而前一個圖所示的分類結果就不夠好了,因為雖然如圖有 ‖z1−c1‖<‖z1−c2‖,但是沒有做到 ‖z1−c1‖<1/2‖z1−c2‖,所以還需要進一步優化 loss。 

假如按照這個條件訓練完成後,我們可以想象,這時候 z1,z2 的距離就被拉大了,而 z1,z3 的距離就被縮小了,這正是我們所希望的結果:增大類間距離,縮小類內距離。 

事實上,上面所說的方案,可以說就是人臉識別中很著名的方案 l-softmax [3]。人臉識別領域中,很多類似的 loss 被提出來,它們都是針對上述分類問題與排序問題的不等價性設計出來的,比如 a-softmax、am-softmax、aam-softmax等,它們都統稱 margin softmax。而且,不僅有 margin softmax,還有 center loss,還有 triplet loss 的一些改進版本等等。

am-softmax

我不是做影象的,因此人臉識別的故事我就講不下去了,還是回到本文的正題。上面說到人臉識別不能用純粹的 softmax 分類,必須要用 margin softmax,而因為句子相似度模型和人臉識別模型的相似性,告訴我們句子相似度模型也是需要 margin softmax 的。總而言之,至少要挑一個 margin softmax 來實現呀。 

其中,效果比較好而最容易實現的方案,當數 am-softmax,本文就以它為例子來介紹這一類 margin softmax 的實現方案,最終實現一個句子相似度模型。

am-softmax的做法其實很簡單,原來 softmax 是 p=softmax(zW),設:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

那麼 softmax 可以重新寫為:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

然後 loss 取交叉熵,也就是:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

t 為目標標籤。而 am-softmax 做了兩件事情:

1. 將 z 和 ci 都做 l2 歸一化,也就是說,內積變成了 cos 值;

2. 對目標 cos 值減去一個正數 m,然後做比例縮放 s。即 loss 變為:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

其中 θi 代表 z,ci 的夾角。在 am-softmax 原論文中,所使用的是 s=30,m=0.35。

從 am-softmax 中,我們可以看到針對前面所提的問題的解決方案了。首先,s 的存在是必要的,因為 cos 的範圍是 [−1,1],需要做好比例縮放,才允許 pt 能足夠接近於 1(有必要的話)。當然,s 並不改變相對大小,因此這不是核心改變,核心是原來應該是 cosθt 的地方,換成了 cosθt−m。

隨心所欲地margin 

前面提到,從分類問題到特徵排序的不完全等價性,可以通過加強分類條件來解決,所謂加強,其實意思很簡單,就是用一個新的函式 ψ(θt) 來代替 cosθt,只要:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

我們都可以認為是一種加強,而 am-softmax 則是取 ψ(θt)=cosθt−m,這估計是滿足上式的最簡單粗暴的方案了(幸好,它效果也很好)。

理解了這種思想之後,其實我們可以構造各種各樣的 ψ(θt) 了,畢竟理論上滿足 (6) 式的都可以選取。前面我們也提到了 l-softmax 和 a-softmax,它們相當於選擇了 ψ(θt)=cosmθt,其中 m 是一個整數。

但我們知道,cosmθt<cosθt 並非總是成立的,所以論文中基於 cosmθt 構造了一個分段函式出來,顯得特別麻煩,而且也使得模型極難收斂。事實上,我試驗過下面的方式:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

結果媲美 am-softmax(在句子相似度任務上)。所以,上述可以作為 l-softmax 和 a-softmax 的一個簡單的替代品了吧,我稱為 simpler-a-softmax,有興趣的讀者可以試試在人臉上的效果。

實現

最後介紹本文對這些 loss 在 Keras 下的實現。測試環境的 Python 版本為 2.7,Keras 版本為 2.1.5,TensorFlow 後端。 

基本實現

用最基本的方式實現 am-softmax 並不困難,比如:

from keras.models import Model
from keras.layers import *
import keras.backend as K
from keras.constraints import unit_norm


x_in = Input(shape=(maxlen,))
x_embedded = Embedding(len(chars)+2,
                       word_size)(x_in)
x = CuDNNGRU(word_size)(x_embedded)
x = Lambda(lambda x: K.l2_normalize(x, 1))(x)

pred = Dense(num_train,
             use_bias=False,
             kernel_constraint=unit_norm())(x)

encoder = Model(x_in, x) # 最終的目的是要得到一個編碼器
model = Model(x_in, pred) # 用分類問題做訓練

def amsoftmax_loss(y_true, y_pred, scale=30, margin=0.35):
    y_pred = y_true * (y_pred - margin) + (1 - y_true) * y_pred
    y_pred *= scale
    return K.categorical_crossentropy(y_true, y_pred, from_logits=True)

model.compile(loss=amsoftmax_loss,
              optimizer='adam',
              metrics=['accuracy'])

Sparse版實現

上面的程式碼並不難理解,主要基於 y_true 是目標的 one hot 輸入,這樣一來,可以通過普通的乘法來取出目標的 cos 值,減去 margin 後再補回其他部分。 

如果僅僅是玩個 mnist 這樣的 10 分類,那麼上述程式碼完全足夠了。但在人臉識別或句子相似度場景,我們面對的事實上是數萬分類甚至數十萬的分類,這種情況下如果還是用 one ho t輸入,就顯得非常消耗記憶體了(主要是準備資料時也麻煩一些)。

理想情況下,我們希望 y_true 只要輸入對應分類的整數id。對於普通的交叉熵,Keras 也提供了 sparse_categorical_crossentropy 的方案,便是應對這種需求,那麼 am-softmax 能不能寫個 Sparse 版出來呢? 

一種比較簡單的寫法是,將轉換 one hot 的過程寫入到 loss 中,比如:

def sparse_amsoftmax_loss(y_true, y_pred, scale=30, margin=0.35):
    y_true = K.cast(y_true[:, 0], 'int32') # 保證y_true的shape=(None,), dtype=int32
    y_true = K.one_hot(y_true, K.int_shape(y_pred)[-1]) # 轉換為one hot
    y_pred = y_true * (y_pred - margin) + (1 - y_true) * y_pred
    y_pred *= scale
    return K.categorical_crossentropy(y_true, y_pred, from_logits=True)

這樣確實能達成目的,但只不過對問題進行了轉嫁,並沒有真正跳過轉 one hot。我們可以用 TensorFlow 的 gather_nd 函式,來實現真正地跳過轉 one hot 的過程,下面是參考的程式碼:

def sparse_amsoftmax_loss(y_true, y_pred, scale=30, margin=0.35):
    y_true = K.expand_dims(y_true[:, 0], 1) # 保證y_true的shape=(None, 1)
    y_true = K.cast(y_true, 'int32') # 保證y_true的dtype=int32
    batch_idxs = K.arange(0, K.shape(y_true)[0])
    batch_idxs = K.expand_dims(batch_idxs, 1)
    idxs = K.concatenate([batch_idxs, y_true], 1)
    y_true_pred = K.tf.gather_nd(y_pred, idxs) # 目標特徵,用tf.gather_nd提取出來
    y_true_pred = K.expand_dims(y_true_pred, 1)
    y_true_pred_margin = y_true_pred - margin # 減去margin
    _Z = K.concatenate([y_pred, y_true_pred_margin], 1) # 為計算配分函式
    _Z = _Z * scale # 縮放結果,主要因為pred是cos值,範圍[-1, 1]
    logZ = K.logsumexp(_Z, 1, keepdims=True) # 用logsumexp,保證梯度不消失
    logZ = logZ + K.log(1 - K.exp(scale * y_true_pred - logZ)) # 從Z中減去exp(scale * y_true_pred)
    return - y_true_pred_margin * scale + logZ

這個程式碼會比前一個帶 one hot 的程式碼要略微快一些。實現的關鍵是用 tf.gather_nd 把目標列提取出來,然後用 logsumexp 計算對數配分函式,這估計是實現交叉熵的標準方法了。基於此,可以修改為其它形式的 margin softmax loss。現在就可以像 sparse_categorical_crossentropy 一樣只輸入類別 id 了,其它框架也可以參照著實現。 

效果預覽

一個完整的句子相似度模型可以在這裡瀏覽: 

https://github.com/bojone/margin-softmax/blob/master/sent_sim.py 

這是一個基於字的模型,所用到的語料 tongyiju.csv 如圖(語料不共享,需要執行的讀者請自行按照格式準備語料):

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

▲ 句子相似度語料格式

其中前面的 id 表示句子組別,用 \t 隔開,同一組的句子可以認為都是同一句,不同組的句子則是非同義句。

訓練結果:訓練集的分類問題上,能達到 90%+ 的準確率,而驗證集(evaluate 函式)上,幾種 loss 的 top1、top5、top10 的準確率分別為(沒有精細調參):

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

值得一提的是,evaluate 函式完全是按照真實的使用環境測試的,也就是說,驗證集的每一個句子都沒有出現過在訓練集中,執行 evaluate 函式時,僅僅是在驗證集內部進行排序,如果按相似度排序後的前 n 個句子中出現了輸入句子的同義句,那麼 top n 的命中數就加 1。

因此,這樣看來,準確率是很可觀的,能滿足工程使用了。下面是隨便挑幾個匹配的例子:

基於GRU和am-softmax的句子相似度模型 | 附程式碼實現

結論

本文闡述了筆者對句子相似度模型的理解,認為它的最佳做法並非二分類,也並非 triplet loss,而是模仿人臉識別中的 margin loss 來做,這是能最快速提升效果的方案。當然,我並沒有充分比較各種方法,僅僅是從我自己對人臉識別的粗淺瞭解中覺得應該是那樣。歡迎讀者測試並一同討論。

參考文獻

[1]. Paul Neculoiu, Maarten Versteegh, Mihai Rotaru: Learning Text Similarity with Siamese Recurrent Networks. Rep4NLP@ACL 2016: 148-157

[2]. Feng, Minwei, et al. "Applying deep learning to answer selection: A study and an open task." 2015 IEEE Workshop on Automatic Speech Recognition and Understanding (ASRU). IEEE, 2015.

[3]. Liu W, Wen Y, Yu Z, et al. Large-Margin Softmax Loss for Convolutional Neural Networks[C]//Proceedings of The 33rd International Conference on Machine Learning. 2016: 507-516.

相關文章