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


基本的循环神经网络

或者等效展开如下:


展开的循环神经网络

如果把神经网络的内部结构画出来,会是下面的结构:


RNN内部结构

这里,依次输入“我 去 五道口 吃 肯德基”每个单词的词嵌入向量,每一步都会输出一个隐藏状态(hidden state)。在计算某一步输出的隐藏状态的时候,会结合前一步的输出,生成一个新的隐藏状态。这样,每一步生成的隐藏状态相当于包含了前面所有步骤的信息,这个步骤称为编码(Encoder),最后一步输出的隐藏状态Ht就可以作为整个输入序列的表示,参与下一步的解码(Decoder)过程。

理论上RNN网络结构能够包含输入序列的所有信息,但是实际上它只能记住当前附近的几步输入的信息,随着距离的增加,RNN能记住的有效信息越来越少,这个有点儿类似狗熊掰棒子,记住了最近的信息,忘掉了之前的信息。对于只需要最近几步的依赖(短距离依赖)就可以完成的工作,RNN可以胜任,比如“下雨天我需要一把雨伞”,根据这句话猜测粗体的部分的“雨伞”,由于整个句子比较短,RNN网络需要分析的前后文距离比较短,可以解决这种问题。换一句话,“天气预报今天下雨,我要出远门,.....,我需要一把雨伞”,在这句话中,由于最后的雨伞需要依赖句子开头的“下雨”才能分析出来,距离很长,这种情况下RNN网络就捉襟见肘了。此时需要一种能够长距离记录信息的网络,这种网络是长短时记忆网络(Long-Short term memory, LSTM)。

相比于上面的RNN内部结构包含的单层的神经网络,LSTM结构更加复杂,共包含四层神经网络:


LSTM网络内部结构

在LSTM网络结构中,四层神经网络分为三个部分,红框表示的遗忘门(forget gate),蓝框表示的输入门(input gate),和绿框表示的输出门(output gate),它们分别控制如何将之前的记忆删除一部分,如何加入当前的记忆,如何将整合后的记忆和这一步的输入联合起来计算一个输出。图中两条水平向右的线,上面的叫CellState,可以认为是承载着前面遥远记忆的一条传送带,下面的叫HiddenState,是结合了当前输入,前一步输出,以及遥远记忆后的输出。当一句话的所有单词都经过LSTM网络处理后,最后输出的HiddenState Ht就是Encoder编码过程的输出,包含了整个输入序列的信息。

上面给出的是基本的LSTM网络结构,针对LSTM还有很多人提出了很多变种,如下图所示,此处不再一一介绍。


添加了窥视孔的LSTM
将输入门和遗忘门相结合的LSTM
将输入门和遗忘门合并进输出门的LSTM,GRU

理解了上面的LSTM网络结构,在看下面的seq2seq整体结构就很容易理解了:


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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,688评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,559评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,749评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,581评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,741评论 3 271
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,684评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,122评论 2 292
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,847评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,441评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,939评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,333评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,783评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,275评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,830评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,444评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,553评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,618评论 2 249

推荐阅读更多精彩内容