NER依存关系模型(原理,建模及代码实现)

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可以表示如下:

NER依存关系模型(原理,建模及代码实现)
文章图片
基本的循环神经网络 或者等效展开如下:

NER依存关系模型(原理,建模及代码实现)
文章图片
展开的循环神经网络 如果把神经网络的内部结构画出来,会是下面的结构:

NER依存关系模型(原理,建模及代码实现)
文章图片
RNN内部结构 这里,依次输入“我 去 五道口 吃 肯德基”每个单词的词嵌入向量,每一步都会输出一个隐藏状态(hidden state)。在计算某一步输出的隐藏状态的时候,会结合前一步的输出,生成一个新的隐藏状态。这样,每一步生成的隐藏状态相当于包含了前面所有步骤的信息,这个步骤称为编码(Encoder),最后一步输出的隐藏状态Ht就可以作为整个输入序列的表示,参与下一步的解码(Decoder)过程。
理论上RNN网络结构能够包含输入序列的所有信息,但是实际上它只能记住当前附近的几步输入的信息,随着距离的增加,RNN能记住的有效信息越来越少,这个有点儿类似狗熊掰棒子,记住了最近的信息,忘掉了之前的信息。对于只需要最近几步的依赖(短距离依赖)就可以完成的工作,RNN可以胜任,比如“下雨天我需要一把雨伞”,根据这句话猜测粗体的部分的“雨伞”,由于整个句子比较短,RNN网络需要分析的前后文距离比较短,可以解决这种问题。换一句话,“天气预报今天下雨,我要出远门,.....,我需要一把雨伞”,在这句话中,由于最后的雨伞需要依赖句子开头的“下雨”才能分析出来,距离很长,这种情况下RNN网络就捉襟见肘了。此时需要一种能够长距离记录信息的网络,这种网络是长短时记忆网络(Long-Short term memory, LSTM)。
相比于上面的RNN内部结构包含的单层的神经网络,LSTM结构更加复杂,共包含四层神经网络:

NER依存关系模型(原理,建模及代码实现)
文章图片
LSTM网络内部结构 在LSTM网络结构中,四层神经网络分为三个部分,红框表示的遗忘门(forget gate),蓝框表示的输入门(input gate),和绿框表示的输出门(output gate),它们分别控制如何将之前的记忆删除一部分,如何加入当前的记忆,如何将整合后的记忆和这一步的输入联合起来计算一个输出。图中两条水平向右的线,上面的叫CellState,可以认为是承载着前面遥远记忆的一条传送带,下面的叫HiddenState,是结合了当前输入,前一步输出,以及遥远记忆后的输出。当一句话的所有单词都经过LSTM网络处理后,最后输出的HiddenState Ht就是Encoder编码过程的输出,包含了整个输入序列的信息。
上面给出的是基本的LSTM网络结构,针对LSTM还有很多人提出了很多变种,如下图所示,此处不再一一介绍。

NER依存关系模型(原理,建模及代码实现)
文章图片
添加了窥视孔的LSTM NER依存关系模型(原理,建模及代码实现)
文章图片
将输入门和遗忘门相结合的LSTM NER依存关系模型(原理,建模及代码实现)
文章图片
将输入门和遗忘门合并进输出门的LSTM,GRU 理解了上面的LSTM网络结构,在看下面的seq2seq整体结构就很容易理解了:

NER依存关系模型(原理,建模及代码实现)
文章图片
seq2seq模型原理图 NER依存关系建模 有了上面的理论知识,我们就可以针对实际问题进行建模。我们的目的是将输入的一句话中实体间的关系提取出来。
输入:
我(O) 去(O) 惠新西街甲8号(ADDR) 的(O) 星巴克(CATER) 喝(O) 咖啡(O),预订(O) 电话(O) 18701500685(PHONE_NUM)
【NER依存关系模型(原理,建模及代码实现)】我们在这句话分词后面给出了每个单词的实体类型,其中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_tokendecode_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

    推荐阅读