作者丨蘇劍林
研究方向丨NLP,神經網路
個人主頁丨kexue.fm
Bert 是什麼,估計也不用筆者來諸多介紹了。雖然筆者不是很喜歡Bert,但不得不說,Bert 確實在 NLP 界引起了一陣軒然大波。現在不管是中文還是英文,關於 Bert 的科普和解讀已經滿天飛了,隱隱已經超過了當年 Word2Vec 剛出來的勢頭了。有意思的是,Bert 是 Google 搞出來的,當年的 word2vec 也是 Google 搞出來的,不管你用哪個,都是在跟著 Google 大佬的屁股跑。
Bert 剛出來不久,就有讀者建議我寫個解讀,但我終究還是沒有寫。一來,Bert 的解讀已經不少了,二來其實 Bert 也就是基於 Attention 搞出來的大規模語料預訓練的模型,本身在技術上不算什麼創新,而關於 Google 的 Attention 我已經寫過解讀了,所以就提不起勁來寫了。
▲ Bert的預訓練和微調(圖片來自Bert的原論文)
總的來說,我個人對 Bert 一直也沒啥興趣,直到上個月末在做資訊抽取比賽時,才首次嘗試了 Bert。畢竟即使不感興趣,終究也是得學會它,畢竟用不用是一回事,會不會又是另一回事。再加上在 Keras 中使用(fine tune)Bert,似乎還沒有什麼文章介紹,所以就分享一下自己的使用經驗。
當Bert遇上Keras
很幸運的是,已經有大佬封裝好了 Keras 版的 Bert,可以直接呼叫官方釋出的預訓練權重,對於已經有一定 Keras 基礎的讀者來說,這可能是最簡單的呼叫 Bert 的方式了。所謂“站在巨人的肩膀上”,就是形容我們這些 Keras 愛好者此刻的心情了。
keras-bert
個人認為,目前在 Keras 下對 Bert 最好的封裝是:
keras-bert:
https://github.com/CyberZHG/keras-bert
本文也是以此為基礎的。 順便一提的是,除了 keras-bert 之外,CyberZHG 大佬還封裝了很多有價值的 keras 模組,比如 keras-gpt-2(你可以用像用 Bert 一樣用 GPT2 模型了)、keras-lr-multiplier(分層設定學習率)、keras-ordered-neurons(就是前不久介紹的 ON-LSTM)等等。看來也是一位 Keras 鐵桿粉絲,致敬大佬。
彙總可以看:
https://github.com/CyberZHG/summary
事實上,有了 keras-bert 之後,再加上一點點 Keras 基礎知識,而且 keras-bert 所給的 demo 已經足夠完善,呼叫、微調 Bert 都已經變成了意見沒有什麼技術含量的事情了。所以後面筆者只是給出幾個中文的例子,來讓讀者上手 keras-bert 的基本用法。
Tokenizer
正式講例子之前,還有必要先講一下 Tokenizer 相關內容。我們匯入 Bert 的 Tokenizer 並重構一下它:
from keras_bert import load_trained_model_from_checkpoint, Tokenizer
import codecs
config_path = '../bert/chinese_L-12_H-768_A-12/bert_config.json'
checkpoint_path = '../bert/chinese_L-12_H-768_A-12/bert_model.ckpt'
dict_path = '../bert/chinese_L-12_H-768_A-12/vocab.txt'
token_dict = {}
with codecs.open(dict_path, 'r', 'utf8') as reader:
for line in reader:
token = line.strip()
token_dict[token] = len(token_dict)
class OurTokenizer(Tokenizer):
def _tokenize(self, text):
R = []
for c in text:
if c in self._token_dict:
R.append(c)
elif self._is_space(c):
R.append('[unused1]') # space類用未經訓練的[unused1]表示
else:
R.append('[UNK]') # 剩餘的字元是[UNK]
return R
tokenizer = OurTokenizer(token_dict)
tokenizer.tokenize(u'今天天氣不錯')
# 輸出是 ['[CLS]', u'今', u'天', u'天', u'氣', u'不', u'錯', '[SEP]']
這裡簡單解釋一下 Tokenizer 的輸出結果。首先,預設情況下,分詞後句子首位會分別加上 [CLS] 和 [SEP] 標記,其中 [CLS] 位置對應的輸出向量是能代表整句的句向量(反正 Bert 是這樣設計的),而 [SEP] 則是句間的分隔符,其餘部分則是單字輸出(對於中文來說)。
本來 Tokenizer 有自己的 _tokenize 方法,我這裡重寫了這個方法,是要保證 tokenize 之後的結果,跟原來的字串長度等長(如果算上兩個標記,那麼就是等長再加 2)。 Tokenizer 自帶的 _tokenize 會自動去掉空格,然後有些字元會粘在一塊輸出,導致 tokenize 之後的串列不等於原來字串的長度了,這樣如果做序列標註的任務會很麻煩。
而為了避免這種麻煩,還是自己重寫一遍好了。主要就是用 [unused1] 來表示空格類字元,而其餘的不在串列的字元用 [UNK] 表示,其中 [unused*] 這些標記是未經訓練的(隨即初始化),是 Bert 預留出來用來增量新增詞彙的標記,所以我們可以用它們來指代任何新字元。
三個例子
這裡包含 keras-bert 的三個例子,分別是文字分類、關係抽取和主體抽取,都是在官方釋出的預訓練權重基礎上進行微調來做的。
Bert官方Github:
https://github.com/google-research/bert
官方的中文預訓練權重:
https://storage.googleapis.com/bert_models/2018_11_03/chinese_L-12_H-768_A-12.zip
例子所在Github:
https://github.com/bojone/bert_in_keras/
根據官方介紹,這份權重是用中文維基百科為語料進行訓練的。
文字分類
作為第一個例子,我們做一個最基本的文字分類任務,熟悉做這個基本任務之後,剩下的各種任務都會變得相當簡單了。這次我們以之前已經討論過多次的文字感情分類任務 [1] 為例,所用的標註資料 [2] 也是以前所整理的。
讓我們來看看模型部分全貌,完整程式碼見:
https://github.com/bojone/bert_in_keras/blob/master/sentiment.py
bert_model = load_trained_model_from_checkpoint(config_path, checkpoint_path)
for l in bert_model.layers:
l.trainable = True
x1_in = Input(shape=(None,))
x2_in = Input(shape=(None,))
x = bert_model([x1_in, x2_in])
x = Lambda(lambda x: x[:, 0])(x) # 取出[CLS]對應的向量用來做分類
p = Dense(1, activation='sigmoid')(x)
model = Model([x1_in, x2_in], p)
model.compile(
loss='binary_crossentropy',
optimizer=Adam(1e-5), # 用足夠小的學習率
metrics=['accuracy']
)
model.summary()
在 Keras 中呼叫 Bert 來做情感分類任務就這樣寫完了。
是不是感覺還沒有盡興,模型程式碼就結束了?Keras 呼叫 Bert 就這麼簡短。事實上,真正呼叫 Bert 的也就只有 load_trained_model_from_checkpoint 那一行程式碼,剩下的只是普通的 Keras 操作(再次感謝 CyberZHG 大佬)。所以,如果你已經入門了 Keras,那麼呼叫 Bert 是無往不利啊。
如此簡單的呼叫,能達到什麼精度?經過5個epoch的fine tune後,驗證集的最好準確率是95.5%+!之前我們在《文字情感分類(三):分詞 OR 不分詞》[1] 中死調爛調,也就只有 90% 上下的準確率;而用了 Bert 之後,寥寥幾行,就提升了 5 個百分點多的準確率!也難怪 Bert 能在 NLP 界掀起一陣熱潮。
在這裡,用筆者的個人經歷先回答讀者可能關心的兩個問題。
第一個問題應該是大家都很關心的,那就是“要多少視訊記憶體才夠?”。事實上,這沒有一個標準答案,視訊記憶體的使用取決於三個因素:句子長度、batch size、模型複雜度。像上面的情感分析例子,在筆者的 GTX1060 6G 視訊記憶體上也能跑起來,只需要將 batch size 調到 24 即可。
所以,如果你的視訊記憶體不夠大,將句子的 maxlen 和 batch size 都調小一點試試。當然,如果你的任務太複雜,再小的 maxlen 和 batch size 也可能 OOM,那就只有升級顯示卡了。
第二個問題是“有什麼原則來指導 Bert 後面應該要接哪些層?”。答案是:用盡可能少的層來完成你的任務。
比如上述情感分析只是一個二分類任務,你就取出第一個向量然後加個 Dense(1) 就好了,不要想著多加幾層 Dense,更加不要想著接個 LSTM 再接 Dense;如果你要做序列標註(比如 NER),那你就接個 Dense+CRF 就好,也不要多加其他東西。
總之,額外加的東西盡可能少。一是因為 Bert 本身就足夠複雜,它有足夠能力應對你要做的很多工;二來你自己加的層都是隨即初始化的,加太多會對 Bert 的預訓練權重造成劇烈擾動,容易降低效果甚至造成模型不收斂。
關係抽取
假如讀者已經有了一定的 Keras 基礎,那麼經過第一個例子的學習,其實我們應該已經完全掌握了 Bert 的 fine tune 了,因為實在是簡單到沒有什麼好講了。所以,後面兩個例子主要是提供一些參考樣式,讓讀者能體會到如何“用盡可能少的層來完成你的任務”。
在第二個例子中,我們介紹基於 Bert 實現的一個極簡的關係抽取模型,其標註原理跟《基於 DGCNN 和機率圖的輕量級資訊抽取模型》[3] 介紹的一樣,但是得益於 Bert 強大的編碼能力,我們所寫的部分可以大大簡化。
在筆者所給出的一種參考實現中,模型部分如下,完整模型見:
https://github.com/bojone/bert_in_keras/blob/master/relation_extract.py
t = bert_model([t1, t2])
ps1 = Dense(1, activation='sigmoid')(t)
ps2 = Dense(1, activation='sigmoid')(t)
subject_model = Model([t1_in, t2_in], [ps1, ps2]) # 預測subject的模型
k1v = Lambda(seq_gather)([t, k1])
k2v = Lambda(seq_gather)([t, k2])
kv = Average()([k1v, k2v])
t = Add()([t, kv])
po1 = Dense(num_classes, activation='sigmoid')(t)
po2 = Dense(num_classes, activation='sigmoid')(t)
object_model = Model([t1_in, t2_in, k1_in, k2_in], [po1, po2]) # 輸入text和subject,預測object及其關係
train_model = Model([t1_in, t2_in, s1_in, s2_in, k1_in, k2_in, o1_in, o2_in],
[ps1, ps2, po1, po2])
如果讀者已經讀過《基於 DGCNN 和機率圖的輕量級資訊抽取模型》一文 [3],瞭解到不用 Bert 時的模型架構,那麼就會理解到上述實現是多麼的簡介明瞭。
可以看到,我們引入了 Bert 作為編碼器,然後得到了編碼序列 t,然後直接接兩個 Dense(1),這就完成了 subject 的標註模型;接著,我們把傳入的 s 的首尾對應的編碼向量拿出來,直接加到編碼向量序列 t 中去,然後再接兩個 Dense(num_classes),就完成 object 的標註模型(同時標註出了關係)。
這樣簡單的設計,最終 F1 能到多少?答案是:線下 dev 能接近 82%,線上我提交過一次,結果是 85%+(都是單模型)!
相比之下,《基於 DGCNN 和機率圖的輕量級資訊抽取模型》[3] 中的模型,需要接 CNN,需要搞全域性特徵,需要將 s 傳入到 LSTM 進行編碼,還需要相對位置向量,各種拍腦袋的模組融合在一起,單模型也只比它好一點點(大約 82.5%)。
要知道,這個基於 Bert 的簡單模型我只寫了一個小時就寫出來了,而各種技巧和模型融合在一起的 DGCNN 模型,我前前後後除錯了差不多兩個月!Bert 的強悍之處可見一斑。
註:這個模型的 fine tune 最好有 8G 以上的視訊記憶體。另外,因為我在比賽即將結束的前幾天才接觸的 Bert,才把這個基於 Bert 的模型寫出來,沒有花心思好好除錯,所以最終的提交結果並沒有包含 Bert。
用 Bert 做關係抽取的這個例子,跟前面情感分析的簡單例子,有一個明顯的差別是學習率的變化。情感分析的例子中,只是用了恆定的學習率訓練了幾個 epoch,效果就還不錯了。
在關係抽取這個例子中,第一個 epoch 的學習率慢慢從 0 增加到(這樣稱為 warmup),第二個 epoch 再從降到,總的來說就是先增後減,Bert 本身也是用類似的學習率曲線來訓練的,這樣的訓練方式比較穩定,不容易崩潰,而且效果也比較好。
事件主體抽取
最後一個例子來自 CCKS 2019 面向金融領域的事件主體抽取 [4],這個比賽目前還在進行,不過我也已經沒有什麼動力和興趣做下去了,所以放出我現在的模型(準確率為 89%+)供大家參考,祝繼續參賽的選手取得更好的成績。
簡單介紹一下這個比賽的資料,大概是這樣的:
輸入:“公司 A 產品出現新增劑,其下屬子公司 B 和公司 C 遭到了調查”, “產品出現問題”
輸出:“公司 A”
也就是說,這是個雙輸入、單輸出的模型,輸入是一個 query 和一個事件型別,輸出一個物體(有且只有一個,並且是 query 的一個片段)。其實這個任務可以看成是 SQUAD 1.0 [5] 的簡化版,根據這個輸出特性,輸出應該用指標結構比較好(兩個 softmax 分別預測首尾)。剩下的問題是:雙輸入怎麼搞?
前面兩個例子雖然複雜度不同,但它們都是單一輸入的,雙輸入怎麼辦呢?當然,這裡的物體型別只有有限個,直接 Embedding 也行,只不過我使用一種更能體現 Bert 的簡單粗暴和強悍的方案:直接用連線符將兩個輸入連線成一個句子,然後就變成單輸入了!
比如上述示例樣本處理成:
輸入:“___產品出現問題___公司 A 產品出現新增劑,其下屬子公司 B 和公司 C 遭到了調查”
輸出:“公司 A”
然後就變成了普通的單輸入抽取問題了。說到這個,這個模型的程式碼也就沒有什麼好說的了,就簡單幾行,完整程式碼請看:
https://github.com/bojone/bert_in_keras/blob/master/subject_extract.py
x = bert_model([x1, x2])
ps1 = Dense(1, use_bias=False)(x)
ps1 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps1, x_mask])
ps2 = Dense(1, use_bias=False)(x)
ps2 = Lambda(lambda x: x[0][..., 0] - (1 - x[1][..., 0]) * 1e10)([ps2, x_mask])
model = Model([x1_in, x2_in], [ps1, ps2])
另外加上一些解碼的 trick,還有模型融合,提交上去,就可以做到 89%+ 了。在看看目前排行榜,發現最好的結果也就是 90% 多一點點,所以估計大家都差不多是這樣做的了。這個程式碼重覆實驗時波動比較大,大家可以多跑幾次,取最優結果。
這個例子主要告訴我們,用 Bert 實現自己的任務時,最好能整理成單輸入的樣式,這樣一來比較簡單,二來也更加高效。
比如做句子相似度模型,輸入兩個句子,輸出一個相似度,有兩個可以想到的做法,第一種是兩個句子分別過同一個 Bert,然後取出各自的 [CLS] 特徵來做分類;第二種就是像上面一樣,用個記號把兩個句子連線在一起,變成一個句子,然後過一個 Bert,然後將輸出特徵做分類,後者顯然會更快一些,而且能夠做到特徵之間更全面的互動。
文章小結
本文介紹了 Keras 下 Bert 的基本呼叫方法,其中主要是提供三個參考例子,供大家逐步熟悉 Bert 的 fine tune 步驟和原理。其中有不少是筆者自己閉門造車的經驗之談,如果有所偏頗,還望讀者指正。
事實上有了 CyberZHG 大佬實現的 keras-bert,在 Keras 下使用 Bert 也就是小菜一碟,大家折騰個半天,也就上手了。最後祝大家用得痛快~
相關連結
[1] https://kexue.fm/archives/3863
[2] https://kexue.fm/archives/3414
[3] https://kexue.fm/archives/6671
[4] https://biendata.com/competition/ccks_2019_4/
[5] https://rajpurkar.github.io/SQuAD-explorer/explore/1.1/dev/
朋友會在“發現-看一看”看到你“在看”的內容