命名實體識別(biLSTM+crf)
為什麼要用biLSTM?為了使特徵提取自動化。當使用CRF++工具來進行命名實體識別時,需要自定義模板(或者使用預設的模板)。
任務和資料
任務是進行命名實體識別(named entity recognition),例如:
在CoNLL2003任務中,實體是LOC,PER,ORG,MISC,也就是位置,人名,組織名和雜項(miscellaneous),非實體表示為“0”。由於一些實體由多個單片語成,使用標籤結構來區分實體的開始(B-...),和實體內(I-...),其他還有例如“IOBES”等結構。
想一想,我們需要的就是一個能給句子中的每個詞一個類別的系統,而這個類別也就是對應的標籤。
但為什麼我們不直接把所有的地點,常見姓名和組織名儲存成一個列表呢?是因為有很多實體,例如姓名和組織名是人為構造的,而怎樣構造,我們沒有先驗知識。所以,我們其實是需要能夠從句子中提取出上下文資訊的工具。
假設資料儲存在.txt檔案中,一行是一個單詞和是否是實體的標誌,如下所示:
模型
正如大多數NLP系統一樣,我們在某些部分依賴迴圈神經網路。將模型分成以下3部分:
1.詞表示。使用緊密(dense)向量表示每個詞,載入預先訓練好的詞向量(GloVe,Word2Vec,Senna等)。我們也將從單個字(單個字母)中提取一些含義。為什麼也需要單個字呢?正如我們之前所說,有很多實體沒有預先訓練好的詞向量,而且開頭是大寫字母會對識別出實體有用。
2.上下文單詞表示。對上下文中的每一個詞,需要有一個有意義的向量表示。使用LSTM來獲取上下文中單詞的向量表示。
3.解碼。當我們有每個詞的向量表示後,來進行實體標籤的預測。
詞向量表示
對每一個詞,我們需要構建一個向量來獲取這個詞的意思以及對實體識別有用的一些特徵,這個向量由Glove訓練的詞向量和從字母中提取出特徵的向量堆疊而成。一個選擇是使用手動提取的特徵,例如單詞是否是大寫字母開頭等。另一種更美好的選擇是使用某種神經網路來自動提取特徵。在這裡,對單個字母使用bi-LSTM,當然也可以使用其他迴圈神經網路,或者對單個字母或n-gram使用CNN。
組成一個單詞的每個字母都由一個向量表示(注意大寫和小寫是區分開來的),對每個字母使用bi-LSTM,並將最後狀態堆疊起來獲得一個固定長度的向量。直覺上,這個向量獲取到了這個詞的形態。然後,我們將詞向量和字母向量合併,獲得這個詞最終的向量表示。
Tensorflow處理批量的詞和資料,因此需要將句子填充到相同的長度,定義2個placeholder:
# shape = (batch size, max length of sentence in batch)
word_ids = tf.placeholder(tf.int32, shape=[None, None])
# shape = (batch size)
sequence_lengths = tf.placeholder(tf.int32, shape=[None])
使用tensorflow內建的函式來載入詞向量。假設embeddings是一個儲存這GloVe向量的陣列,那麼embeddings[i]就是第i和單詞的詞向量。
L = tf.Variable(embeddings, dtype=tf.float32, trainable=False)
# shape = (batch, sentence, word_vector_size)
pretrained_embeddings = tf.nn.embedding_lookup(L, word_ids)
接下來構建單個字母的表示,對詞也需要填充到相同的長度,定義2個placeholder:
# shape = (batch size, max length of sentence, max length of word)
char_ids = tf.placeholder(tf.int32, shape=[None, None, None])
# shape = (batch_size, max_length of sentence)
word_lengths = tf.placeholder(tf.int32, shape=[None, None])
為什麼我們到處都使用None?為什麼我們需要使用None呢?
這取決於我們如何填充,在這裡,我們選擇動態填充,例如在一個batch中,將batch中的句子填充到這個batch的最大長度。因此,句子長度和詞的長度取決與這個batch。
接下來構造字母向量(character embeddings)。我們沒有預訓練的字母向量,使用tf.get_variable來初始化一個矩陣。然後改變這個4維tensor的形狀來滿足bidirectional_dynamic_rnn的輸入要求。sequence_length這個引數使我們確保我們獲得的最後狀態是有效的最後狀態。(因為batch中句子的實際長度不一樣)
# 1. get character embeddings
K = tf.get_variable(name="char_embeddings", dtype=tf.float32,
shape=[nchars, dim_char])
# shape = (batch, sentence, word, dim of char embeddings)
char_embeddings = tf.nn.embedding_lookup(K, char_ids)
# 2. put the time dimension on axis=1 for dynamic_rnn
s = tf.shape(char_embeddings) # store old shape
# shape = (batch x sentence, word, dim of char embeddings)
char_embeddings = tf.reshape(char_embeddings, shape=[-1, s[-2], s[-1]])
word_lengths = tf.reshape(self.word_lengths, shape=[-1])
# 3. bi lstm on chars
cell_fw = tf.contrib.rnn.LSTMCell(char_hidden_size, state_is_tuple=True)
cell_bw = tf.contrib.rnn.LSTMCell(char_hidden_size, state_is_tuple=True)
_, ((_, output_fw), (_, output_bw)) = tf.nn.bidirectional_dynamic_rnn(cell_fw,
cell_bw, char_embeddings, sequence_length=word_lengths,
dtype=tf.float32)
# shape = (batch x sentence, 2 x char_hidden_size)
output = tf.concat([output_fw, output_bw], axis=-1)
# shape = (batch, sentence, 2 x char_hidden_size)
char_rep = tf.reshape(output, shape=[-1, s[1], 2*char_hidden_size])
# shape = (batch, sentence, 2 x char_hidden_size + word_vector_size)
word_embeddings = tf.concat([pretrained_embeddings, char_rep], axis=-1)
上下文單詞表示
當我們得到詞最終的向量表示後,對詞向量的序列進行LSTM或bi-LSTM。
這次,我們使用每一個時間點的隱藏狀態,而不僅僅是最終狀態。輸入m個詞向量,獲得m個隱藏狀態的向量,然而詞向量只是包含詞級別的資訊,而隱藏狀態的向量考慮了上下文。
cell_fw = tf.contrib.rnn.LSTMCell(hidden_size)
cell_bw = tf.contrib.rnn.LSTMCell(hidden_size)
(output_fw, output_bw), _ = tf.nn.bidirectional_dynamic_rnn(cell_fw,
cell_bw, word_embeddings, sequence_length=sequence_lengths,
dtype=tf.float32)
context_rep = tf.concat([output_fw, output_bw], axis=-1)
解碼
在解碼階段計算標籤得分,使用每個詞對應的隱藏狀態向量來做最後預測,可以使用一個全連線神經網路來獲取每個實體標籤的得分。
假設我們有9個類別,使用和來計算得分,可以將s[i]理解為詞w對應標籤i的得分。
W = tf.get_variable("W", shape=[2*self.config.hidden_size, self.config.ntags],
dtype=tf.float32)
b = tf.get_variable("b", shape=[self.config.ntags], dtype=tf.float32,
initializer=tf.zeros_initializer())
ntime_steps = tf.shape(context_rep)[1]
context_rep_flat = tf.reshape(context_rep, [-1, 2*hidden_size])
pred = tf.matmul(context_rep_flat, W) + b
scores = tf.reshape(pred, [-1, ntime_steps, ntags])
對標籤得分進行解碼,有兩個選擇。不管是哪個選擇,都會計算標籤序列的概率並找到概率最大的序列。
1.softmax:使用將得分轉化為代表這個單詞屬於某個類別(標籤)的概率,概率和為1。最後,標籤序列的概率是每個位置標籤概率的乘積。
2.線性crf: softmax方法是做區域性選擇,換句話說,即使bi-LSTM產生的h中包含了一些上下文資訊,但標籤決策仍然是區域性的。我們沒有利用周圍的標籤來幫助決策。例如:“New York”,當我們給了York “location”這個標籤後,這應該幫助我們決定“New”對應location的起始位置。線性CRF定義了全域性得分C
其中,T是9*9的轉換矩陣,e,b是9維的向量,表示某個標籤作為開頭和結尾的成本。T包含了標籤決策內的線性依賴關係,下一個標籤依賴於上一個標籤。
如果單看每個位置的得分,則標籤序列PER-PER-LOC的得分(10+4+11)比PER-O-LOC的得分(10+3+11)高,但如果考慮標籤之間的依賴關係,PER-O-LOC的得分(31)高於PER-PER-LOC的得分(26),而Pierre loves Pairs的標籤序列就是PER-O-LOC。
要實現CRF計算得分,需要做2件事:
1. 找到得分最高的標籤序列
2. 計算所有標籤序列的概率分佈?
要找到得分最高的標籤序列,不可能計算所有的個標籤得分,並甚至將每個標籤得分標準化為概率。其中,m是句子的長度。使用動態方法找到得分最高的序列,假設有從t+1,...,m的序列得分,那麼從t,...,m的序列得分是
每一個迴圈步驟的複雜度是,共m步,則總的複雜度是
對一個10個詞的句子來說,複雜度從下降到9*9*10=810
線性鏈CRF的最後一步是對所有可能序列的得分執行softmax,從而得到給定序列的概率。
所有可能序列得分的和。
是在時間t,標籤為yt的所有序列得分的和。【為什麼要定義這個值?為了對Z進行遞迴計算,減少計算量】
則給定標籤序列的概率是
使用交叉熵損失函式作為目標函式進行訓練,交叉熵損失定義為
,其中是正確的標籤序列,
使用crf對應的標籤序列概率為
直接使用softmax對應的標籤序列概率是
以下程式碼計算損失並返回轉移矩陣T,計算crf的對數概率只需要一行程式碼
# shape = (batch, sentence)
labels = tf.placeholder(tf.int32, shape=[None, None], name="labels")
log_likelihood, transition_params = tf.contrib.crf.crf_log_likelihood(
scores, labels, sequence_lengths)
loss = tf.reduce_mean(-log_likelihood)
直接使用softmax後計算損失時,要注意padding,使用tf.sequence_mask來將序列長度轉換成是否向量。
losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=scores, labels=labels)
# shape = (batch, sentence, nclasses)
mask = tf.sequence_mask(sequence_lengths)
# apply mask
losses = tf.boolean_mask(losses, mask)
loss = tf.reduce_mean(losses)
之後定義訓練操作:
optimizer = tf.train.AdamOptimizer(self.lr)
train_op = optimizer.minimize(self.loss)
當訓練好模型後,如何使用模型進行預測?
當直接使用softmax時,則最好的序列就是在每個時間點選擇最高得分的標籤,用以下程式碼實現:
labels_pred = tf.cast(tf.argmax(self.logits, axis=-1), tf.int32)
當使用CRF時,需要動態程式設計,但也只需要一行程式碼。
# shape = (sentence, nclasses)
score = ...
viterbi_sequence, viterbi_score = tf.contrib.crf.viterbi_decode(
score, transition_params)