NER依存關係模型:原理,建模及代碼實現

關鍵詞:seq2seq,RNN,LSTM,NER依存關係

命名實體識別(Named Entity Recognization, NER)是AI任務中重要的一類,而且在技術落地方面已經走在各種應用的前列,通過命名實體識別,我們已經能夠識別出諸如 “我 去 五道口 吃 肯德基” 這句話中的地址(五道口)和餐館(肯德基),利用這個信息,我們就可以給用戶展示五道口的導航信息,和肯德基的餐館信息等。目前在各種智能手機上已經廣泛集成了該功能,如小米的傳送門,Oppo/Vivo的智慧識屏等。但是NER識別有個侷限,我們只能識別出獨立的實體,實際上一句話中不同實體間很多時候是存在關聯的,比如上面的例句中“五道口”這個地址就限制了“肯德基”餐館的位置,所以我們就知道用戶想搜索的是五道口的那家肯德基,而不是其他地方的肯德基,那我們如何找出這些實體間的關係,本文將利用seq2seq模型進行獲取。

之前讀過很多文章,它們介紹了各種各樣的seq2seq模型,但是始終沒找到一個從理論到實踐能完全串聯起來的文章,總是讓人覺得雲裏霧裏,似懂非懂。本文試圖通過以下三個部分的講解,提供一個從理論到實踐的完整連貫的介紹:

  1. 首先介紹seq2seq模型的理論基礎,包括循環神經網絡(RNN)和長短時記憶網絡(LSTM)。
  2. 講解針對NER依存關係這個問題,我們怎麼進行建模。
  3. 最後結合代碼介紹如何實現seq2seq模型。

seq2seq理論基礎

seq2seq模型是一種機器學習領域常用的模型,適用於將一個序列轉換成另外一種序列的問題,可以是將一種語言翻譯成另一種語言,將一篇文章轉換成一段摘要,將一段語音轉換成文字,又或者是將一句話的命名實體序列轉換成實體間的關係序列。seq2seq模型通過循環神經網絡(RNN)實現,循環神經網絡可以記錄序列前面幾步的信息,從而推算下一步的輸出。

一個簡單的RNN Cell可以表示如下:


或者等效展開如下:


如果把神經網絡的內部結構畫出來,會是下面的結構:


這裏,依次輸入“我 去 五道口 吃 肯德基”每個單詞的詞嵌入向量,每一步都會輸出一個隱藏狀態(hidden state)。在計算某一步輸出的隱藏狀態的時候,會結合前一步的輸出,生成一個新的隱藏狀態。這樣,每一步生成的隱藏狀態相當於包含了前面所有步驟的信息,這個步驟稱爲編碼(Encoder),最後一步輸出的隱藏狀態Ht就可以作爲整個輸入序列的表示,參與下一步的解碼(Decoder)過程。

理論上RNN網絡結構能夠包含輸入序列的所有信息,但是實際上它只能記住當前附近的幾步輸入的信息,隨着距離的增加,RNN能記住的有效信息越來越少,這個有點兒類似狗熊掰棒子,記住了最近的信息,忘掉了之前的信息。對於只需要最近幾步的依賴(短距離依賴)就可以完成的工作,RNN可以勝任,比如“下雨天我需要一把雨傘”,根據這句話猜測粗體的部分的“雨傘”,由於整個句子比較短,RNN網絡需要分析的前後文距離比較短,可以解決這種問題。換一句話,“天氣預報今天下雨,我要出遠門,.....,我需要一把雨傘”,在這句話中,由於最後的雨傘需要依賴句子開頭的“下雨”才能分析出來,距離很長,這種情況下RNN網絡就捉襟見肘了。此時需要一種能夠長距離記錄信息的網絡,這種網絡是長短時記憶網絡(Long-Short term memory, LSTM)。

相比於上面的RNN內部結構包含的單層的神經網絡,LSTM結構更加複雜,共包含四層神經網絡:


在LSTM網絡結構中,四層神經網絡分爲三個部分,紅框表示的遺忘門(forget gate),藍框表示的輸入門(input gate),和綠框表示的輸出門(output gate),它們分別控制如何將之前的記憶刪除一部分,如何加入當前的記憶,如何將整合後的記憶和這一步的輸入聯合起來計算一個輸出。圖中兩條水平向右的線,上面的叫CellState,可以認爲是承載着前面遙遠記憶的一條傳送帶,下面的叫HiddenState,是結合了當前輸入,前一步輸出,以及遙遠記憶後的輸出。當一句話的所有單詞都經過LSTM網絡處理後,最後輸出的HiddenState Ht就是Encoder編碼過程的輸出,包含了整個輸入序列的信息。

上面給出的是基本的LSTM網絡結構,針對LSTM還有很多人提出了很多變種,如下圖所示,此處不再一一介紹。


理解了上面的LSTM網絡結構,在看下面的seq2seq整體結構就很容易理解了:


NER依存關係建模

有了上面的理論知識,我們就可以針對實際問題進行建模。我們的目的是將輸入的一句話中實體間的關係提取出來。
輸入:
我(O) 去(O) 惠新西街甲8號(ADDR) 的(O) 星巴克(CATER) 喝(O) 咖啡(O),預訂(O) 電話(O) 18701500685(PHONE_NUM)

我們在這句話分詞後面給出了每個單詞的實體類型,其中ADDR代表地址,CATER代表餐館,O代表未識別的其他類型。實體的類型作爲輸入的特徵向量之一,連同每個單詞的次嵌入向量一併作爲LSTM網絡的輸入。

上面的一句話中實體關係表如下:

惠新西街甲8號 星巴克 18701500685
惠新西街甲8號 - right_desc null
星巴克 - - left_desc
星巴18701500685 - - -

按照順序,每個實體依次和其他實體產生一個關係,比如我們認爲惠新西街甲8號是對星巴克的描述,那我們可以定義這種關係爲right_desc(右側描述),惠新西街甲8號和18701500685沒有關係,我們定義爲null, 18701500685也是對星巴克的描述,所以星巴克和18701500685的關係定義爲left_desc(左側描述)。這樣,對於有N個非O類型的實體,它們之間的關係數是N*(N-1)/2個,我們就可以把兩兩之間的關係按照順序作爲輸出序列:
輸出:
right_desc null left_desc

這樣就轉換成了一個標準的seq2seq問題。
輸入向量我們使用預訓練的word embedding,尺寸是500000行128列,代表500000個單詞,每個單詞用128維向量表示。同時,我們將實體類型也用數字表示,加入到128維後面,所以每個單詞用129維的向量表示。

代碼實現

首先構造編碼器:

# 輸入序列第一部分:單詞的embedding (batch_size, 50, 128)
self.sentence_words_emb = tf.nn.embedding_lookup(self.encoder_embedding, self.input_sentence_words_ids)

# 輸入序列第二部分:單詞的ner類型 (batch_size, 50) -> (batch_size, 50, 1)
self.input_sentence_ner_expand = tf.expand_dims(self.input_sentence_ner_ids, 2, name='expand_dims_tag')

# 兩部分合並起來作爲輸入序列 (batch_size, 50, 128+1)
self.input_feature = tf.concat([self.sentence_words_emb, self.input_sentence_ner_expand], 2)

# 構建單個的LSTMCell,同時添加了Dropout信息
self.encode_cell = self.build_encoder_cell()

encode_input_layer = Dense(self.hidden_units, dtype=tf.float32, name='input_projection')
self.encoder_inputs_embedded = encode_input_layer(self.input_feature)

# 將這個embedding信息作爲tf.nn.dynamic_rnn的輸入
# encoder_outputs:[h_0, h_1, ..., h_t]   encoder_output_state: LSTMStateTuple(c_t, h_t)
self.encoder_outputs, self.encoder_output_state = tf.nn.dynamic_rnn(cell=self.encode_cell,
        inputs=self.encoder_inputs_embedded,
        sequence_length=self.encode_inputs_length,
                                                                      # 存儲每句話的實際長度
         dtype=tf.float32,
         time_major=False)

上面的代碼核心是調用tf.nn.dynamic_rnn函數進行編碼,該函數的參數及含義如下:
cell:用於編碼的神經網絡構成,可以是單層RNNCell,也可以是多層RNNCell,這裏我們使用的是MultiRNNCell,具體實現如下:

    def build_encoder_cell(self):
        return MultiRNNCell([self.build_encode_single_cell() for i in range(self.depth)])
    
    def build_decode_single_cell(self):
        cell = LSTMCell(self.hidden_units)

        cell = DropoutWrapper(cell, dtype=tf.float32, output_keep_prob=self.keep_prob_placeholder)

        return cell

inputs:輸入向量,我們將每個單詞的word embedding(128維)和ner類型(1維)結合起來,構成輸入向量(129維)
sequence_length:batch裏面每句話不考慮填充部分的實際長度矩陣
time_major:inputs和outputs Tensor的格式,如果是true,格式爲[max_time, batch_size, depth],如果是false,格式爲[batch_size, max_time, depth]。這裏我們指定爲false

dynamic_rnn函數返回兩個變量,第一個encoder_outputs是一個包含了編碼過程中每一步輸出的hidden_state的列表[h_0, h_1, ..., h_t] ,第二個變量是一個tuple類型,存儲的是編碼過程最後一步輸出的c_t和h_t,encoder_output_state: LSTMStateTuple(c_t, h_t)。其中h_t就是我們在解碼過程中的輸入,如果使用了Attention機制,還會用到hidden_state列表[h_0, h_1, ..., h_t] 。

解碼過程:
解碼過程要區分訓練還是預測,訓練的時候輸出結果是已知的,預測的時候是未知的。下面是訓練階段的解碼代碼:

        with tf.variable_scope('decoder'):
            const = [[0], [1], [2], [3], [4], [5], [6], [7]]  # decode embedding目前用的是一維的,回頭試試8維,16維或者64維
            initializer = tf.constant_initializer(const)
            self.decoder_embedding = tf.get_variable(name='decoder_embeddings', shape=[self.num_classes, 1],
                                                     initializer=initializer, dtype=tf.float32)
            # 構建輸出層全連接網絡,輸出的類別數目是label的種類8
            decoder_output_layer = Dense(self.num_classes, name='decoder_output_projection')

            if self.mode == 'train':
                decoder_cell, decoder_initial_state = self.build_decoder_cell()
                # 將目標結果轉換成對應的embedding表示  (batch_size, decode_sentence_max_len) -> (batch_size, decode_sentence_max_len, 1)
                decoder_results_embedded = tf.nn.embedding_lookup(self.decoder_embedding, self.targets_train)  # tf.expand_dims(targets, 2)

                # TrainingHelper用於在Decoder過程中自動獲取每個batch的數據
                training_helper = seq2seq.TrainingHelper(inputs=decoder_results_embedded,
                                                         sequence_length=self.train_decoder_results_length,
                                                         time_major=False,
                                                         name='training_helper')

                training_decoder = seq2seq.BasicDecoder(cell=decoder_cell,  # 加入Attention的decoder cell
                                                        helper=training_helper,  # 獲取目標輸出數據的helper函數
                                                        initial_state=decoder_initial_state,
                                                        # Encoder過程輸出的state作爲Decoder過程的輸入State
                                                        output_layer=decoder_output_layer)  # Decoder完成之後經過全連接網絡映射到最終輸出的類別

                # 獲取一個batch裏面最長句子的長度
                max_decoder_length = tf.reduce_max(self.train_decoder_results_length)

                ## 使用training_decoder進行dynamic_decode操作,輸出decoder結果
                decoder_outputs, _, _ = seq2seq.dynamic_decode(decoder=training_decoder,
                                                              impute_finished=True,
                                                              maximum_iterations=max_decoder_length)
                # decoder_outputs = (rnn_outputs, sample_id)
                # 其中:rnn_output: [batch_size, decoder_targets_length, vocab_size],保存decode每個時刻每個單詞的概率,可以用來計算loss
                # sample_id: [batch_size, decode_vocab_size], tf.int32,保存最終的編碼結果,也就是rnn_output每個時刻概率最大值對應的類別。可以表示最後的答案

                # 生成一個和decoder_logits.rnn_output結構一樣的tensor,代表一次訓練的結果
                decoder_logits_train = tf.identity(decoder_outputs.rnn_output)
                # 選擇logits的最大值的位置作爲預測選擇的結果
                self.decoder_pred_train = tf.argmax(decoder_logits_train, axis=-1, name='decoder_pred_train')

                # 根據輸入batch中每句話的長度,和指定處理的最大長度,填充mask數據,這樣可以提高計算效率,同時不影響最終結果
                masks = tf.sequence_mask(lengths=self.train_decoder_results_length,
                                         maxlen=max_decoder_length, dtype=tf.float32, name='masks')

                # 計算loss
                self.loss = seq2seq.sequence_loss(logits=decoder_logits_train,  # 預測值
                                             targets=self.targets_train,  # 實際值
                                             weights=masks,  # mask值
                                             average_across_timesteps=True,
                                             average_across_batch=True, )

                ## 接下來手動進行梯度更新
                # 首先獲得trainable variables
                trainable_params = tf.trainable_variables()

                # 使用gradients函數,計算loss對trainable_params的導數,trainable_params包含各個可訓練的參數
                gradients = tf.gradients(self.loss, trainable_params)

                # 對可訓練參數的梯度進行正則化處理,將權重的更新限定在一個合理範圍內,防止權重更新過於迅猛造成梯度爆炸或梯度消失
                clip_gradients, _ = tf.clip_by_global_norm(gradients, self.max_gradient_norm)

                # 一次訓練結束後更新參數權重
                self.optimizer = tf.train.AdamOptimizer(learning_rate=self.learning_rate).apply_gradients(
                    zip(clip_gradients, trainable_params), global_step=self.global_step)

訓練過程的解碼通過seq2seq.dynamic_decode進行,該函數的參數decoder我們使用BasicDecoder,BasicDecoder參數含義如下:
cell:解碼的網絡結構,該網絡我們在build_decoder_cell函數裏生成
helper:如何在每一步獲取數據
initial_state:編碼過程輸出的h_t
output_layer:解碼數據轉換成最終識別類別的網絡,這裏我們使用Dense構建了一個輸出數量爲num_classes的全連接網絡

build_decoder_cell函數代碼如下:

    def build_decoder_cell(self):  
        encoder_outputs = self.encoder_outputs
        encoder_last_state = self.encoder_output_state
        encoder_inputs_length = self.encode_inputs_length

        # Building attention mechanism: Default Bahdanau
        # 'Bahdanau' style attention: https://arxiv.org/abs/1409.0473
        self.attention_mechanism = attention_wrapper.BahdanauAttention(
            num_units=self.hidden_units, memory=encoder_outputs,
            memory_sequence_length=encoder_inputs_length, )

        # Building decoder_cell
        self.decoder_cell_list = [self.build_decode_single_cell() for i in range(self.depth)]

        def attn_decoder_input_fn(inputs, attention):
            # Essential when use_residual=True
            _input_layer = Dense(self.hidden_units, dtype=tf.float32, name='attn_input_feeding')
            return _input_layer(tf.concat([inputs, attention], -1))

        # AttentionWrapper wraps RNNCell with the attention_mechanism
        # Note: We implement Attention mechanism only on the top decoder layer
        self.decoder_cell_list[-1] = attention_wrapper.AttentionWrapper(
            cell=self.decoder_cell_list[-1],
            attention_mechanism=self.attention_mechanism,
            attention_layer_size=self.hidden_units,
            cell_input_fn=attn_decoder_input_fn,
            initial_cell_state=encoder_last_state[-1],
            alignment_history=False,
            name='Attention_Wrapper')

        # To be compatible with AttentionWrapper, the encoder last state
        # of the top layer should be converted into the AttentionWrapperState form
        # We can easily do this by calling AttentionWrapper.zero_state

        # Also if beamsearch decoding is used, the batch_size argument in .zero_state
        # should be ${decoder_beam_width} times to the origianl batch_size
        batch_size = self.batch_size
        initial_state = [state for state in encoder_last_state]

        initial_state[-1] = self.decoder_cell_list[-1].zero_state(batch_size=batch_size, dtype=tf.float32)
        decoder_initial_state = tuple(initial_state)

        return MultiRNNCell(self.decoder_cell_list), decoder_initial_state

這段代碼我們構建瞭解碼的網絡,可以使一個單一的RNNCell,也可以是多個RNNCell,我們使用的後者。在最後一個Cell上,我們添加了Attention機制,Attention機制通過AttentionWrapper實現,作用在decode_cell_list的最後一個Cell上,AttentionWrapper各參數含義:
cell:需要被Wrapper的網絡節點本身,這裏是我們節點列表的最後一個節點
attention_mechanism:attention_mechanism我們使用BahdanauAttention,BahdanauAttention的介紹見下面解釋
attention_layer_size:網絡輸出層尺寸
cell_input_fn:如何整合網絡的原始輸入和attention,這裏我們簡單將兩個tensor連接起來,通過一個Dense全連接網絡
initial_cell_state:編碼過程最後一個節點輸出的h_t

BahdanauAttention各參數的含義:
num_units:Attention機制覆蓋的距離,整合多大範圍內的記憶
memory:encode輸出的hidden_state列表[h_0, h_1, ..., h_t]
memory_sequence_length:輸入句子的不考慮填充部分的實際長度

上面介紹的是train過程的解碼過程,下面介紹預測過程的解碼過程。

            decoder_cell_2, decoder_initial_state_2 = self.build_decoder_cell()
            # Start_tokens: [batch_size,] `int32` vector
            start_tokens = tf.ones([self.batch_size, ], tf.int32) * self.output_start_token

            decode_input_layer = Dense(self.hidden_units, dtype=tf.float32, name='decode_input_layer')

            # 解碼過程中前一步的輸出通過embedding_lookup轉換成嵌入向量,並經過一個全連接網絡,輸出的是8個目標類別中每個類別的概率
            def embed_and_input_proj(inputs):  # todo: tensor經過Dense後變成什麼??
                return decode_input_layer(tf.nn.embedding_lookup(self.decoder_embedding, inputs))

            # Helper to feed inputs for greedy decoding: uses the argmax of the output
            predict_decoding_helper = seq2seq.GreedyEmbeddingHelper(start_tokens=start_tokens,
                                                                    end_token=self.output_end_token,
                                                                    embedding=embed_and_input_proj)
            # Basic decoder performs greedy decoding at each time step
            print("building greedy decoder..")
            inference_decoder = seq2seq.BasicDecoder(cell=decoder_cell_2,
                                                     helper=predict_decoding_helper,
                                                     initial_state=decoder_initial_state_2,
                                                     output_layer=decoder_output_layer)

            predict_logits, final_state, final_sequence_lengths = seq2seq.dynamic_decode(
                decoder=inference_decoder,
                output_time_major=False,
                # impute_finished=True, # error occurs
                maximum_iterations=self.decode_sentence_max_len)

            #  [batch_size, max_time_step, 1]
            self.decoder_pred_decode = tf.expand_dims(predict_logits.sample_id, -1)

預測的過程和訓練過程一樣,也是通過dynamic_decode進行,主要區別在於BasicDecoder的helper參數不同,在訓練的時候用到的是TrainingHelper,而預測過程用到的是GreedyEmbeddingHelper,區別在於訓練過程不管每一步預測輸出的是什麼結果,下一步輸入都不會使用這個數據,而是使用標記數據對應的正確結果作爲輸入,這樣防止某個步驟輸出的結果錯誤傳遞給後續的步驟,這是TrainingHelper的實現。而預測過程需要將某一步的輸出通過argmax獲取概率最大的作爲結果,然後將這個結果轉換成embedding作爲下一步的輸入,這就是GreedyEmbeddingHelper做的事情。GreedyEmbeddingHelper的參數如下:
start_tokens:輸出序列的開始標誌
end_token:輸出序列的結束標誌
embedding:如何將前一步的輸出轉換成下一步的輸入,可以看到,我們的方法是先獲取前一步輸出的embeddings,然後經過一個全連接網絡,再將輸出作爲下一步的輸入

dynamic_decode輸出的三個參數中的第一個predict_logits就是最終預測的結果,通過predict_logits.sample_id可以獲取到每一步的預測結果,這就是我們最終需要的結果。

這樣,從理論基礎,到建模過程,再到最後的代碼實現,我們完整的講解了利用seq2seq模型根據輸入序列生成輸出序列的全過程,希望能夠讓你係統的瞭解到seq2seq模型是怎麼回事,以及怎樣運行的。

參考:
http://colah.github.io/posts/2015-08-Understanding-LSTMs/
https://zhuanlan.zhihu.com/p/28919765

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章