實際上基於bert的實體識別無論理論還是代碼實現應該都是比較簡單的,bert的代碼實現本來是比較複雜的,因為涉及到mask相關的東西,但是google已經給出來了,並且還很良心的給出了一些上游任務的例子,所以基本上沒什麼難點。所以這裡就主要記一下使用estimator構建網路的過程。因為相關主流的演算法github上都有開源實現,所以以前基本上都是copy過來(像一個loser)小修小改就行了,這次雖然已經有開源實現了,但是還是想嘗試一下自己碼一下。代碼地址如下:

cedar33/bert_ner?

github.com
圖標

## 1. bert部分

bert前面已經介紹過了,在這裡我們只需要拿到它隱狀態的輸出即可,如何拿到bert源代碼已經給我們方法了

model = modeling.BertModel(
config=bert_config,
is_training=is_training, # 這個參數在`bilm+crf`訓練的時候也需要訓練
input_ids=input_ids,
input_mask=input_mask,
token_type_ids=segment_ids,
use_one_hot_embeddings=use_one_hot_embeddings)

embedding = model.get_sequence_output() # 拿到最後一層隱狀態的方法

其實看了bert的代碼,可以給自己寫代碼如何分層分包有很大的啟示,再看看自己的代碼感覺差的還很遠,雖然有很多忙啊趕進度啊之類的借口但是這應該是最基本的。好了言歸正傳,這裡有一個地方值得說一下,首先是is_training這個參數,我原以為這個參數在pre-training和fine-tuning的時候訓練好了不用再動了,後來看了bert給出的run_squad.py的方法發現這個參數在特定任務中仍然會訓練,這就跟之前的word2vec、glove以及elmo稍有區別,當然如果限於硬體原因也可以嘗試把所有字的向量形式輸出保存然後在特定任務中再載入,不訓練bert模型,但是效果沒有同時訓練好。bert部分的transformer如果有比較好的gpu的話速度會快很多,親測大概是20倍的加速。

bert部分輸出拿到了,剩下的想怎麼折騰就怎麼折騰了跟之前的word2vec之類的方法無異,因為在標註任務中是不需要[cls]、[sep]這樣的標籤的,作者的文章中也沒有做position embedding我也沒做過實驗。

2. bilstm+crf部分

雖然bilstm+crf已經是老生常談了,但是這裡還是談一談,實際上自己碼一遍代碼會對整個網路有一個更深刻的認識,數據流在每一個節點的狀態,哪些參數需要訓練哪些不需要都會瞭然於胸,順便介紹一個tensorflow中lstm單元的高級封裝LSTMBlockFusedCell以便在CPU上表現得更好,如果GPU空間足夠就是用LSTMBlockCell

關於理論部分這兩篇文章介紹的很好,基本上所有lstm和crf相關的內容都能在這裡找到答案,圖文並茂,良心至極,放在這裡

Understanding LSTM Networks?

colah.github.io圖標Sequence Tagging with Tensorflow?

guillaumegenthial.github.io
圖標

如果發現鏈接通向一堵牆的話就勉為其難的看看我的介紹吧。

1.lstm

這裡假定你已經對lstm有一定了解,所以不會對單元的內部情況進行剖析,只是宏觀上看一看,主要是看一看代碼怎麼調用的

轉載自colah的github.io,lstm網路

先宏觀的看一下,輸入即是bert的輸出,bert的中文預訓練模型給出的輸出每一個字由一個長度為768的向量表示,所以bert的輸出是shape=[batch_size, max_seq_length, 768]的張量作為lstm的輸入,lstm的輸出是一個和lstm單元個數相關的張量shape=[batch_size, max_seq_length, lstm_size] 。在這張圖中肉眼可見的需要指定的參數有兩個,一個是*A*的個數,一個是輸出 h_t 的形狀,LSTMBlockFusedCell的初始化方法和call方法分別指定了這兩個參數,把方法簽名列一下

__init__(
num_units,
forget_bias=1.0,
cell_clip=None,
use_peephole=False,
reuse=None,
dtype=None,
name=lstm_fused_cell
)

__call__(
inputs,
*args,
**kwargs
)

好像並不能明顯的看出在哪裡指定這兩個參數,實際上 h_t 的計算公式

h_t=o_t*	anh(C_t)

告訴我們 h_t 的形狀是和 	anh 函數輸入變數的形狀相等,在初始化方法中定義了參數num_units這個參數就是來規定 	anh 函數的輸入變數的大小,所以上面提到的輸出 h_t 的形狀shape=[batch_size, max_seq_length, lstm_size]就由此而來(lstm_sizenum_units描述的是同一個東西),有一個有意思的因果關係是num_units到底是來定義 	anh 的屬性,然後因為這個屬性所以 	anh 的輸入的大小必須滿足這個屬性還是這個參數是來定義輸入的的大小的,所以 	anh 函數具備這個屬性。雖然從代碼的角度來講這不重要,因為他們是相等的,所以我定義一個另一個就定了不用管什麼因果關係,但是實際上這個因果關係對lstm的理解很重要。num_units字面上看就是神經網路單元的個數,而神經網路單元一般指的是 sigmoid 或者 	anh (當然還有其他種類的神經元,反正不是矩陣或者向量),所以應該定義的是 	anh 的一個屬性,然後這個屬性決定了輸入的大小。num_units通常對函數的擬合能力影響很大一般選擇128,根據輸入特徵的大小和樣本數量可以做適當的調整。

2.bilstm

雙向的lstm,老生常談了,序列翻過來同樣的方式算一遍然後concat就可以了

t = tf.transpose(embeddings, perm=[1, 0, 2])
lstm_cell_fw = tf.contrib.rnn.LSTMBlockFusedCell(FLAGS.max_seq_length)
lstm_cell_bw = tf.contrib.rnn.LSTMBlockFusedCell(FLAGS.max_seq_length)
lstm_cell_bw = tf.contrib.rnn.TimeReversedFusedRNN(lstm_cell_bw)
output_fw, _ = lstm_cell_fw(t, dtype=tf.float32, sequence_length=seq_length)
output_bw, _ = lstm_cell_bw(t, dtype=tf.float32, sequence_length=seq_length)
output = tf.concat([output_fw, output_bw], axis=-1)
output = tf.transpose(output, perm=[1, 0, 2])
output = tf.layers.dropout(output, rate=0.5, training=is_training)

有兩個地方值得說一下,一個是tf.concat操作,在前面談到bert的時候我們提到過bert是可以真正意義上實現從上下文推斷當前詞的模型,bilstm為什麼不行呢?bilstm通過上下文推斷了啊。然而bilstm的結合只是兩個單向推斷後簡單的concat的結果,實際上每個推斷依然是單向的,而bert模型通過mask的方法真正意義上實現了通過上下文推斷(bert相關的可以翻一下前面的文章)。

另一個值得注意的地方是如果使用tensorflow中最基本的lstm單元的話輸出是不需要轉置的,而LSTMBlockFusedCell得call方法中inputs得shape是 [time_len, batch_size, input_size]所以需要轉置。

3.crf

bilstm+crf和純種得crf得區別在於bilstm+crf模型中用bilstm取代了純種crf中的勢函數,利用了crf的狀態轉移概率和解碼方法取長補短。代碼實現相當簡單

logits = tf.layers.dense(output, 5)
crf_params = tf.get_variable("crf", [5, 5], dtype=tf.float32)
trans = tf.get_variable(
"transitions",
shape=[num_labels, num_labels],
initializer=initializers.xavier_initializer())
pred_ids, trans = tf.contrib.crf.crf_decode(logits, crf_params, seq_length)
log_likelihood, _ = tf.contrib.crf.crf_log_likelihood(
logits, label_ids, seq_length, crf_params)
loss = tf.reduce_mean(-log_likelihood)

上面兩段代碼基本上就是構建整個網路的核心方法,加起來應該不到20行,加上dropout,dense等操作也就30行的樣子,然而整個程序代碼大概有600多行,大量的代碼其實是花在業務邏輯處理和文件讀寫以及格式整理上。還有一個必須要提到的問題是在構建網路的時候所有的計算都是在一個單獨的域中做的

with tf.variable_scope(Graph, reuse=None, custom_getter=None):

可能比較熟悉tensorflow的認為這不值一提,但是這個問題著實坑了我一把,而且會導致比較可怕的結果。如果忽略這一行,程序不會報錯,梯度會下降,但是只會下降到一個很差的結果上。列印出被訓練的變數看不出異常,準確率還沒有猜得准。之所以說很可怕是因為這個時候程序不報錯,一切無異常,梯度還下降只不過沒下降到足夠低,很容易的去調其他超參數,但是都是徒勞的,然後如果你就很可能會認為你的樣本有問題,是不可訓練的,不能用這種方法得到一個比較好的結果,然後準備放棄這種方法找尋其他答案,然而實際上只是因為你的代碼少了一行,不要問我這個心理活動我是怎麼知道的。這個時候最有效的辦法是看觀察計算圖(實際上只要是沒有報錯的異常都可以看計算圖解決)。

3. 效果

在實體識別方面bert在測試集大概有2%左右的提升對於大公司這是肉眼可見的利潤,對於小公司主要是可以在小樣本上表現得很好,沒有人力做大量數據標註的時候也是很不錯的選擇。

推薦閱讀:

相关文章