作者:王嶽王院長
知乎:https://www.zhihu.com/people/wang-yue-40-21
github: https://github.com/wavewangyue
前言
最近在做命名實體識別(Named Entity Recognition, NER)的工作,也就是序列標註(Sequence Tagging),老 NLP task 了,就是從一段文本中抽取到找到任何你想要的東西,可能是某個字,某個詞,或者某個短語
為什麼說流水的NLP鐵打的NER?NLP四大任務嘛,分類、生成、序列標註、句子對標註。分類任務,面太廣了,萬物皆可分類,各種方法層出不窮;句子對標註,經常是體現人工智能(zhang)對人類語言理解能力的標準秤,孿生網絡、DSSM、ESIM 各種模型一年年也是秀的飛起;生成任務,目前人工智障 NLP 能力的天花板,雖然經常會處在說不出來人話的狀態,但也不斷吸引 CopyNet、VAE、GAN 各類選手前來挑戰;唯有序列標註,數年如一日,不忘初心,原地踏步,到現在一提到 NER,還是會一下子只想到 LSTM-CRF,鐵打不動的模型,沒得挑也不用挑,用就完事了,不用就是不給面子
雖然之前也做過 NER,但是想細緻地捋一下,看一下自從有了 LSTM-CRF 之後,NER 在做些什麼,順便記錄一下最近的工作,中間有些經驗和想法,有什麼就記點什麼
因為能力有限,還是跟之前一樣,就少講理論少放公式,多畫模型圖多放代碼,還是主要從工程實現角度記錄和分享下經驗,也記錄一些個人探索過程。如果有新人苦於不知道怎麼實現一個 NER 模型,不知道 LSTM-CRF、BERT-CRF 怎麼寫,看到代碼之後便可以原地起飛,從此打開新世界的大門;或者有老 NLPer 從我的某段探索過程裡感覺還挺有意思的,那我就太開心了。就這樣
還是先放結論
命名實體識別雖然是一個歷史悠久的老任務了,但是自從2015年有人使用了BI-LSTM-CRF模型之後,這個模型和這個任務簡直是郎才女貌,天造地設,輪不到任何妖怪來反對。直到後來出現了BERT。在這裡放兩個問題:
- 2015-2019年,BERT出現之前4年的時間,命名實體識別就只有 BI-LSTM-CRF 了嗎?
- 2019年BERT出現之後,命名實體識別就只有 BERT-CRF(或者 BERT-LSTM-CRF)了嗎?
經過我不完善也不成熟的調研之後,好像的確是的,一個能打的都沒有
既然模型打不動了,然後我找了找 ACL2020 做NER的論文,看看現在的NER還在做哪些事情,主要分幾個方面:
- 多特徵:實體識別不是一個特別複雜的任務,不需要太深入的模型,那麼就是加特徵,特徵越多效果越好,所以字特徵、詞特徵、詞性特徵、句法特徵、KG表徵等等的就一個個加吧,甚至有些中文 NER 任務裡還加入了拼音特徵、筆畫特徵。。?心有多大,特徵就有多多
- 多任務:很多時候做 NER 的目的並不僅是為了 NER,而是服務於一個更大的目標,比如信息抽取、問答系統等等的,如果把整個大任務做一個端到端的模型,就需要做成一個多任務模型,把 NER 作為其中一個子任務;另外,如果單純為了 NER,本身也可以做成多任務,比如實體類型多的時候,單獨用一個任務來識別實體,另一個用來判斷實體類型
- 時令大雜燴:把當下比較流行的深度學習話題或方法跟NER結合一下,比如結合強化學習的NER、結合 few-shot learning 的NER、結合多模態信息的NER、結合跨語種學習的NER等等的,具體就不提了
所以沿著上述思路,就在一箇中文NER任務上做一些實踐,寫一些模型。都列在下面了,首先是 LSTM-CRF 和 BERT-CRF,然後就是幾個多任務模型, Cascade 開頭的(因為實體類型比較多,把NER拆成兩個任務,一個用來識別實體,另一個用來判斷實體類型),後面的幾個模型裡,WLF 指的是 Word Level Feature(即在原本字級別的序列標註任務上加入詞級別的表徵),WOL 指的是 Weight of Loss(即在loss函數方面通過設置權重來權衡Precision與Recall,以達到提高F1的目的),具體細節後面再講
- 代碼:上述所有模型的代碼都在這裡:https://github.com/wavewangyue/ner,帶 BERT 的可以自己去下載BERT_CHINESE預訓練的 ckpt 模型,然後解壓到 bert_model 目錄下
- 環境:Python3, Tensorflow1.12
- 數據:一個電商場景下商品標題中的實體識別,因為是工作中的數據,並且通過遠程監督弱標註的質量也一般,完整數據就不放了。但是我 sample 了一些數據留在 git 裡了,為了直接 git clone 完,代碼原地就能跑,方便你我他
ok 下面正經開工
1. BI-LSTM+CRF
用純 HMM 或者 CRF 做 NER 的話就不講了,比較古老了。從 LSTM+CRF 開始講起,應該是2015年被提出的模型[1],模型架構在今天來看非常簡單,直接上圖
BI-LSTM 即 Bi-directional LSTM,也就是有兩個 LSTM cell,一個從左往右跑得到第一層表徵向量 l,一個從右往左跑得到第二層向量 r,然後兩層向量加一起得到第三層向量 c
如果不使用CRF的話,這裡就可以直接接一層全連接與softmax,輸出結果了;如果用CRF的話,需要把 c 輸入到 CRF 層中,經過 CRF 一通專業縝密的計算,它來決定最終的結果
這裡說一下用於表示序列標註結果的 BIO 標記法。序列標註裡標記法有很多,最主要的還是 BIO 與 BIOES 這兩種。B 就是標記某個實體詞的開始,I 表示某個實體詞的中間,E 表示某個實體詞的結束,S 表示這個實體詞僅包含當前這一個字。區別很簡單,看圖就懂。一般實驗效果上差別不大,有些時候用 BIOES 可能會有一內內的優勢
另外,如果在某些場景下不考慮實體類別(比如問答系統),那就直接完事了,但是很多場景下需要同時考慮實體類別(比如事件抽取中需要抽取主體客體地點機構等等),那麼就需要擴展 BIO 的 tag 列表,給每個“實體類型”都分配一個 B 與 I 的標籤,例如用“B-brand”來代表“實體詞的開始,且實體類型為品牌”。當實體類別過多時,BIOES 的標籤列表規模可能就爆炸了
「基於 Tensorflow 來實現 LSTM+CRF 代碼也很簡單,直接上」
<code>self.inputs_seq = tf.placeholder(tf.int32, [None, None], name="inputs_seq") # B * S self.inputs_seq_len = tf.placeholder(tf.int32, [None], name="inputs_seq_len") # B self.outputs_seq = tf.placeholder(tf.int32, [None, None], name='outputs_seq') # B * S with tf.variable_scope('embedding_layer'): embedding_matrix = tf.get_variable("embedding_matrix", [vocab_size_char, embedding_dim], dtype=tf.float32) embedded = tf.nn.embedding_lookup(embedding_matrix, self.inputs_seq) # B * S * D with tf.variable_scope('encoder'): cell_fw = tf.nn.rnn_cell.LSTMCell(hidden_dim) cell_bw = tf.nn.rnn_cell.LSTMCell(hidden_dim) ((rnn_fw_outputs, rnn_bw_outputs), (rnn_fw_final_state, rnn_bw_final_state)) = tf.nn.bidirectional_dynamic_rnn( cell_fw=cell_fw, cell_bw=cell_bw, inputs=embedded, sequence_length=self.inputs_seq_len, dtype=tf.float32 ) rnn_outputs = tf.add(rnn_fw_outputs, rnn_bw_outputs) # B * S * D with tf.variable_scope('projection'): logits_seq = tf.layers.dense(rnn_outputs, vocab_size_bio) # B * S * V probs_seq = tf.nn.softmax(logits_seq) # B * S * V if not use_crf: preds_seq = tf.argmax(probs_seq, axis=-1, name="preds_seq") # B * S else: log_likelihood, transition_matrix = tf.contrib.crf.crf_log_likelihood(logits_seq, self.outputs_seq, self.inputs_seq_len) preds_seq, crf_scores = tf.contrib.crf.crf_decode(logits_seq, transition_matrix, self.inputs_seq_len) with tf.variable_scope('loss'): if not use_crf: loss = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=logits_seq, labels=self.outputs_seq) # B * S masks = tf.sequence_mask(self.inputs_seq_len, dtype=tf.float32) # B * S loss = tf.reduce_sum(loss * masks, axis=-1) / tf.cast(self.inputs_seq_len, tf.float32) # B else: loss = -log_likelihood / tf.cast(self.inputs_seq_len, tf.float32) # B /<code>
Tensorflow 裡調用 CRF 非常方便,主要就 crf_log_likelihood 和 crf_decode 這兩個函數,結果和 loss 就都給你算出來了。它要學習的參數也很簡單,就是這個 transition_matrix,形狀為 V*V,V 是輸出端 BIO 的詞表大小。但是有一個小小的缺點,就是官方實現的 crf_log_likelihood 裡某個未知的角落有個 stack 操作,會悄悄地吃掉很多的內存。如果 V 較大,內存佔用量會極高,訓練時間極長。比如我的實驗裡有 500 個實體類別,也就是 V=500*2+1=1001,訓練 1epoch 的時間從 30min 暴增到 400min
<code>/usr/local/lib/python3.6/dist-packages/tensorflow/python/ops/gradients_impl.py:112: UserWarning: Converting sparse IndexedSlices to a dense Tensor of unknown shape. This may consume a large amount of memory. "Converting sparse IndexedSlices to a dense Tensor of unknown shape. " /<code>
不過好消息是,Tensorflow2.0 裡,這個問題不再有了
壞消息是,Tensorflow2.0 直接把 tf.contrib.crf 移除了,目前還沒有官方實現的 CRF 接口
再說一下為什麼要加 CRF。從開頭的 Leaderboard 裡可以看到,BiLSTM 的 F1 Score 在72%,而 BiLSTM+CRF 達到 80%,提升明顯
那麼為什麼提升這麼大呢?CRF 的原理,網上隨便搜就一大把,就不講了(因為的確很難,我也沒太懂),但是從實驗的角度可以簡單說說,就是 LSTM 只能通過輸入判斷輸出,但是 CRF 可以通過學習轉移矩陣,看前後的輸出來判斷當前的輸出。這樣就能學到一些規律(比如“O 後面不能直接接 I”“B-brand 後面不可能接 I-color”),這些規律在有時會起到至關重要的作用
例如下面的例子,A 是沒加 CRF 的輸出結果,B 是加了 CRF 的輸出結果,一看就懂不細說了
2. BERT+CRF & BERT+LSTM+CRF
用 BERT 來做,結構上跟上面是一樣的,只是把 LSTM 換成 BERT 就 ok 了,直接上代碼
首先把 BERT 這部分模型搭好,直接用 BERT 的官方代碼。這裡我把序列長度都標成了“S+2”是為了提醒自己每條數據前後都加了“[CLS]”和“[SEP]”,出結果時需要處理掉
<code>from bert import modeling as bert_modeling self.inputs_seq = tf.placeholder(shape=[None, None], dtype=tf.int32, name="inputs_seq") # B * (S+2) self.inputs_mask = tf.placeholder(shape=[None, None], dtype=tf.int32, name="inputs_mask") # B * (S+2) self.inputs_segment = tf.placeholder(shape=[None, None], dtype=tf.int32, name="inputs_segment") # B * (S+2) self.outputs_seq = tf.placeholder(shape=[None, None], dtype=tf.int32, name='outputs_seq') # B * (S+2) bert_config = bert_modeling.BertConfig.from_json_file("./bert_model/bert_config.json") bert_model = bert_modeling.BertModel( config=bert_config, is_training=True, input_ids=self.inputs_seq, input_mask=self.inputs_mask, token_type_ids=self.inputs_segment, use_one_hot_embeddings=False ) bert_outputs = bert_model.get_sequence_output() # B * (S+2) * D /<code>
然後在後面接東西就可以了,可以接 LSTM,可以接 CRF
<code>if not use_lstm: hiddens = bert_outputs else: with tf.variable_scope('bilstm'): cell_fw = tf.nn.rnn_cell.LSTMCell(300) cell_bw = tf.nn.rnn_cell.LSTMCell(300) ((rnn_fw_outputs, rnn_bw_outputs), (rnn_fw_final_state, rnn_bw_final_state)) = tf.nn.bidirectional_dynamic_rnn( cell_fw=cell_fw, cell_bw=cell_bw, inputs=bert_outputs, sequence_length=inputs_seq_len, dtype=tf.float32 ) rnn_outputs = tf.add(rnn_fw_outputs, rnn_bw_outputs) # B * (S+2) * D hiddens = rnn_outputs with tf.variable_scope('projection'): logits_seq = tf.layers.dense(hiddens, vocab_size_bio) # B * (S+2) * V probs_seq = tf.nn.softmax(logits_seq) if not use_crf: preds_seq = tf.argmax(probs_seq, axis=-1, name="preds_seq") # B * (S+2) else: log_likelihood, transition_matrix = tf.contrib.crf.crf_log_likelihood(logits_seq, self.outputs_seq, inputs_seq_len) preds_seq, crf_scores = tf.contrib.crf.crf_decode(logits_seq, transition_matrix, inputs_seq_len) /<code>
其實我原來不太相信 BERT 在中文上的效果,加上我比較排斥這種不講道理的龐然大物
真正實驗了發現,BERT確實強啊
把我顯存都給吃光了,但確實強啊
訓練一輪要那麼久,但確實強啊
講不出任何道理,但確實強啊
相比較單純使用 BERT,增加了 CRF 後效果有所提高但區別不大,再增加 BiLSTM 後區別很小,甚至降低了那麼一內內
另外,BERT 還有一個至關重要的訓練技巧,就是調整學習率。BERT內的參數在 fine-tuning 時,學習率一定要調小,特別時後面還接了別的東西時,一定要按兩個學習率走,甚至需要嘗試多次反覆調,要不然 BERT 很容易就步子邁大了掉溝裡爬不上來,個人經驗
參數優化時分兩個學習率,實現起來就是這樣
<code>with tf.variable_scope('opt'): params_of_bert = [] params_of_other = [] for var in tf.trainable_variables(): vname = var.name if vname.startswith("bert"): params_of_bert.append(var) else: params_of_other.append(var) opt1 = tf.train.AdamOptimizer(1e-4) opt2 = tf.train.AdamOptimizer(1e-3) gradients_bert = tf.gradients(loss, params_of_bert) gradients_other = tf.gradients(loss, params_of_other) gradients_bert_clipped, norm_bert = tf.clip_by_global_norm(gradients_bert, 5.0) gradients_other_clipped, norm_other = tf.clip_by_global_norm(gradients_other, 5.0) train_op_bert = opt1.apply_gradients(zip(gradients_bert_clipped, params_of_bert)) train_op_other = opt2.apply_gradients(zip(gradients_other_clipped, params_of_other)) /<code>
3. Cascade
上面提到過,如果需要考慮實體類別,那麼就需要擴展 BIO 的 tag 列表,給每個“實體類型”都分配一個 B 與 I 的標籤,但是當類別數較多時,標籤詞表規模很大,相當於在每個字上都要做一次類別數巨多的分類任務,不科學,也會影響效果
從這個點出發,就嘗試把 NER 改成一個多任務學習的框架,兩個任務,一個任務用來單純抽取實體,一個任務用來判斷實體類型,直接上圖看區別
這個是參考 ACL2020 的一篇論文[2]的思路改的,“Cascade”這個詞是這個論文裡提出來的。翻譯過來就是“級聯”,直觀來講就是“鎖定對應關係”。結合模型來說,在第一步得到實體識別的結果之後,返回去到 LSTM 輸出那一層,找各個實體詞的表徵向量,然後再把實體的表徵向量輸入一層全連接做分類,判斷實體類型
關於如何得到實體整體的表徵向量,論文裡是把各個實體詞的向量做平均,但是我搞了好久也沒明白這個操作是怎麼通過代碼實現的,後來看了他的源碼,好像只把每個實體最開頭和最末尾的兩個詞做了平均。然後我就更省事,只取了每個實體最末尾的一個詞
具體實現上這樣寫:在訓練時,每個詞,無論是不是實體詞,都過一遍全連接,做實體類型分類計算 loss,然後把非實體詞對應的 loss 給 mask 掉;在預測時,就取實體最後一個詞對應的分類結果,作為實體類型。上圖解釋
代碼不貼了,感興趣的可以在 git 裡看
說一下效果。將單任務 NER 改成多任務 NER 之後,基於 LSTM 的模型效果降低了 0.4%,基於 BERT 的模型提高了 2.7%,整體還是提高更明顯。另外,由於 BIO 詞表得到了縮減,CRF 運行時間以及消耗內存迅速減少,訓練速度得到提高
P.S. 另外,既然提到了 NER 中的實體類型標籤較多的問題,就提一下之前看過的一篇文章[3]。這篇論文主要就是為了解決實體類型標籤過多的問題(成千上萬的數量級)。文中的方法是:把標籤作為輸入,也就是把所有可能的實體類型標籤都一個個試一遍,根據輸入的標籤不同,模型會有不同的實體抽取結果。文章沒給代碼,我復現了一下,效果並不好,具體表現就是無論輸入什麼標籤,模型都傾向於把所有的實體都抽出來,不管這個實體是不是對應這個實體類型標籤。也可能是我復現的有問題,不細講了,就是順便提一句,看有沒有人遇到了和我一樣的情況
❝
Scaling Up Open Tagging from Tens to Thousands: Comprehension Empowered Attribute Value Extraction from Product Title. ACL 2019
❞
4. Word-Level Feature
中文 NER 和英文 NER 有個比較明顯的區別,就是英文 NER 是從單詞級別(word level)來做,而中文 NER 一般是字級別(character level)來做。不僅是 NER,很多 NLP 任務也是這樣,BERT 也是這樣
因為中文沒法天然分詞,只能靠分詞工具,分出來的不一定對,比如“黑啤酒精釀”,如果被錯誤分詞為“黑啤、酒精、釀”,那麼“啤酒”這個實體就抽取不到了。類似情況有很多
但是無論字級別、詞級別,都是非常貼近文本原始內容的特徵,蘊含了很重要的信息。比如對於英文來說,給個單詞“Geilivable”你基本看不懂啥意思,但是看到它以“-able”結尾,就知道可能不是名詞;對於中文來說,給個句子“小龍女說我也想過過過兒過過的生活”就一時很難找到實體在哪,但是如果分好詞給你,一眼就能找到了。就這個理解力來說,模型跟人是一樣的
在英文 NLP 任務中,想要把字級別特徵加入到詞級別特徵上去,一般是這樣:單獨用一個BiLSTM 作為 character-level 的編碼器,把單詞的各個字拆開,送進 LSTM 得到向量 vc;然後和原本 word-level 的(經過 embedding matrix 得到的)的向量 vw 加在一起,就能得到融合兩種特徵的表徵向量。如圖所示
但是對於中文 NER 任務,我的輸入是字級別的,怎麼把詞級別的表徵結果加入進來呢?
ACL2018 有個文章[4]是做這個的,提出了一種 Lattice-LSTM 的結構,但是涉及比較底層的改動,不好實現。後來在 ACL2020 論文裡看到一篇文章[5],簡單明瞭。然後我就再簡化一下,直接把字和詞分別通過 embedding matrix 做表徵,按照對應關係,拼在一起就完事了,看圖就懂
具體代碼就不放了,感興趣可以上 git 看
從結果上看,增加了詞級別特徵後,提升很明顯
很可惜,我還沒有找到把詞級別特徵結合到 BERT 中的方法。因為 BERT 是字級別預訓練好的模型,如果單純從 embedding 層這麼拼接,那後面那些 Transformer 層的參數就都失效了
上面的論文裡也提到了和 BERT 結合的問題,論文裡還是用 LSTM 來做,只是把句子通過 BERT 得到的編碼結果作為一個“額外特徵”拼接過來。但是我覺得這不算“結合”,至少不應該。但是也非常容易理解為什麼論文裡要這麼做,BERT 當道的年代,不講道理,打不過就只能加入,方法不同也得強融,麼得辦法
5. Weight of Loss
本來打算到這就結束了,後來臨時決定再加一點,因為感覺這點應該還挺有意思的
大多數 NLP task 的評價指標有這三個:Precision / Recall / F1Score,Precision 就是找出來的有多少是正確的,Recall 是正確的有多少被找出來了,F1Score是二者的一個均衡分。這裡有三點常識
- 方法固定的條件下,一般來說,提高了 Precision 就會降低 Recall,提高了 Recall 就會降低 Precision,結合指標定義很好理解
- 通常來說,F1Score 是最重要的指標,為了讓 F1Score 最大化,通常需要調整權衡 Precision 與 Recall 的大小,讓兩者達到近似,此時 F1Score 是最大的
- 但是 F1Score 大,不代表模型就好。因為結合工程實際來說,不同場景不同需求下,對 P/R 會有不同的要求。有些場景就是要求準,不允許出錯,所以對 Precision 要求比較高,而有些則相反,不希望有漏網之魚,所以對 Recall 要求高
對於一個分類任務,是很容易通過設置一個可調的“閾值”來達到控制 P/R 的目的的。舉個例子,判斷一張圖是不是 H 圖,做一個二分類模型,假設模型認為圖片是 H 圖的概率是 p,人為設定一個閾值 a,假如 p>a 則認為該圖片是 H 圖。默認情況 p=0.5,此時如果降低 p,就能達到提高 Recall 降低 Precision 的目的
但是 NER 任務怎麼整呢,他的結果是一個完整的序列,你又不能給每個位置都卡一個閾值,沒有意義
然後我想了一個辦法,通過控制模型學習時的 Loss 來控制 P/R:如果模型沒有識別到一個本應該識別到的實體,就增大對應的 Loss,加重對模型的懲罰;如果模型識別到了一個不應該識別到的實體,就減小對應的 Loss,當然是選擇原諒他
實現上也是通過 mask 來實現,看圖就懂
實現也非常簡單,放一下對應的代碼
<code># logits_bio 是預測結果,形狀為 B*S*V,softmax 之後就是每個字在BIO詞表上的分佈概率,不過不用寫softmax,因為下面的函數會幫你做 # self.outputs_seq_bio 是期望輸出,形狀為 B*S # 這是原本計算出來的 loss loss_bio = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=logits_bio, labels=self.outputs_seq_bio) # B * S # 這是根據期望的輸出,獲得 mask 向量,向量裡出現1的位置代表對應的字是一個實體詞,而 O_tag_index 就是 O 在 BIO 詞表中的位置 masks_of_entity = tf.cast(tf.not_equal(self.outputs_seq_bio, O_tag_index), tf.float32) # B * S # 這是基於 mask 計算 weights weights_of_loss = masks_of_entity + 0.5 # B *S # 這是加權後的 loss loss_bio = loss_bio * weights_of_loss # B * S /<code>
從實驗效果來看,原本 Precision 遠大於 Recall,通過權衡,把兩個分數拉到同個水平,可以提升最終的 F1Score
除此之外,在所有深度學習任務上,都可以通過調整 Loss 來達到各種特殊的效果,還是挺有意思的,放飛想象,突破自我
總結
總結放在開頭了,就這樣
完結,撒花
「參考」
- Bidirectional LSTM-CRF Models for Sequence Tagging
- A Novel Cascade Binary Tagging Framework for Relational Triple Extraction
- Scaling Up Open Tagging from Tens to Thousands: Comprehension Empowered Attribute Value Extraction from Product Title
- Chinese NER Using Lattice LSTM
- Simplify the Usage of Lexicon in Chinese NER