TensorFlow中Sequence-to-Sequence样例代码详解

在NLP领域,sequence to sequence模型有很多应用,比如机器翻译、自动应答机器人等。在看懂了相关的论文后,我开始研读TensorFlow提供的源代码,刚开始看时感觉非常晦涩,现在基本都弄懂了,我在这里主要介绍Sequence-to-Sequence Models用到的理论,然后对源代码进行详解,也算是对自己这两周的学习进行一下总结,如果也能够对您有所帮助的话,那就再好不过了~

sequence-to-sequence模型

在NLP中最为常见的模型是language model,它的研究对象是单一序列,而本文中的sequence to sequence模型同时研究两个序列。经典的sequence-to-sequence模型由两个RNN网络构成,一个被称为“encoder”,另一个则称为“decoder”,前者负责把variable-length序列编码成fixed-length向量表示,后者负责把fixed_length向量表示解码成variable-length输出,它的基本网络结构如下,

其中每一个小圆圈代表一个cell,比如GRUcell、LSTMcell、multi-layer-GRUcell、multi-layer-GRUcell等。这里比较直观的解释就是,encoder的最终隐状态c包含了输入序列的所有信息,因此可以使用c进行解码输出。尽管“encoder”或者“decoder”内部存在权值共享,但encoder和decoder之间一般具有不同的一套参数。在训练sequence-to-sequence模型时,类似于有监督学习模型,最大化目标函数 θ ∗ = a r g max ⁡ θ ∑ n = N ∑ t = 1 T n l o g P ( y t n ∣ y &lt; t n , x n ) \theta^{*}=arg\max_{\theta}\sum_{n=}^{N}\sum_{t=1}^{T_{n}}logP(y_{t}^{n}|y_{&lt;t}^{n},x^{n}) θ=argθmaxn=Nt=1TnlogP(ytny<tn,xn)  其中 p ( y t ∣ y 1 , . . , y t − 1 , c ) = g ( y t − 1 , s t , c ) = 1 Z e x p ( w t T ϕ ( y t − 1 , z t , c t ) + b t ) p({y_{t}|y_{1},..,y_{t-1}}, c)=g(y_{t-1},s_{t},c)=\frac{1}{Z}exp(w_{t}^{T}\phi (y_{t-1},z_{t},c_{t})+b_{t}) p(yty1,..,yt1,c)=g(yt1,st,c)=Z1exp(wtTϕ(yt1,zt,ct)+bt)   其中 w t w_{t} wt称作输出投影, b t b_{t} bt称作输出偏置,标准化常数计算式为 Z = ∑ k : y k ∈ V e x p ( w k T ϕ ( y t − 1 , z t , c t ) + b k ) Z=\sum_{k:y_{k}\in V}exp(w_{k}^{T}\phi (y_{t-1},z_{t},c_{t})+b_{k}) Z=k:ykVexp(wkTϕ(yt1,zt,ct)+bk)   Dzmitry Bahdanau大牛考虑到fixed-length向量表示会限制encoder-decoder架构的性能,于是进行了改进,使得模型在输出单一word时,能够自动查找到有贡献的输入sub-sequence,新的模型架构如下图所示,


  这里的编码器为双向RNN架构,定义条件概率 p ( y i ∣ y 1 , . . , y i − 1 , x ) = g ( y i − 1 , s i , c i ) p(y_{i}|y_{1},..,y_{i-1}, \mathbf{x})=g(y_{i-1},s_{i},c_{i}) p(yiy1,..,yi1,x)=g(yi1,si,ci),其中隐状态 s i s_{i} si计算公式为 s i = f ( s i − 1 , y i − 1 , c i ) s_{i}=f(s_{i-1},y_{i-1},c_{i}) si=f(si1,yi1,ci),上下文向量计算公式为 c i = ∑ j = 1 T x α i j h j c_{i}=\sum_{j=1}^{T_{x}}\alpha_{ij}h_{j} ci=j=1Txαijhj   其中,权值参数 α i j = e x p ( e i j ) ∑ k = 1 T x e x p ( e i k ) \alpha_{ij}=\frac{exp^{(e_{ij})}}{\sum_{k=1}^{T_{x}}exp(e_{ik})} αij=k=1Txexp(eik)exp(eij)    e i j = a c t i v a t i o n ( s i − 1 , h j ) e_{ij}=activation(s_{i-1},h_{j}) eij=activation(si1,hj)是一个“alignment model”,用于表征输入序列的第j个位置和输出序列的第i个位置的匹配程度, h j h_{j} hj表示双向RNN隐状态的合并,即 h j = [ h j L ; h j R ] h_{j}=[h_{j}^{L};h_{j}^{R}] hj=[hjL;hjR],根据RNN序列的特点, h j h_{j} hj中包含了更多的邻域窗序列内的信息,那么显然 α i j \alpha_{ij} αij是对 e i j e_{ij} eij标准化后的形式, c i c_{i} ci的计算公式的几何意义就是,对输入序列中所有位置的信息进行加权求和,从而达到了在输出序列的任一time step,都能够从输入序列中动态获取最为相关的子序列信息的效果,在作者文章中,这种效果被称作为"attention mechanism"。

TensorFlow中seq2seq库函数

尽算上述算法看起来比较复杂,但TensorFlow已经把它们封装成了可以直接调用的函数,官方教程已经对这些库函数做了大体介绍,但我感觉讲的还是不够透彻,故在这里重新叙述一下(还有一些其他的函数,但考虑到它们的接口参数都是相似的,就不做太多介绍了~)。

(1)outputs, states = basic_rnn_seq2seq(encoder_inputs, decoder_inputs, cell)

输入参数 :
   encoder_inputs: 它是一个二维tensor构成的列表对象,其中每一个二维tensor代表某一时刻的输入,其尺寸为[batch_size x input_size],这里的            batch_size具体指某一时刻输入的单词个数,input_size指encoder的长度;
   decoder_inputs: 它是一个二维tensor构成的列表对象,其中每一个二维tensor代表某一时刻的输入,其尺寸为[batch_size x output_size],这里的            batch_size具体指某一时刻输入的单词个数,output_size指decoder的长度;
   cell: 它是一个rnn_cell.RNNCell或者multi-layer-RNNCell对象,其中定义了cell函数和hidden units的个数;
  输出参数 :
   outputs: 它是一个二维tensor构成的列表对象,其中每一个二维tensor代表某一时刻输出,其尺寸为[batch_size x output_size],这里的
      batch_size具体指某一时刻输入的单词个数,output_size指decoder的长度;
   state: 它是一个二维tensor,表示每一个decoder cell在最后的time-step的状态,其尺寸为[batch_size x cell.state_size],这里的
      cell.state_size可以表示一个或者多个子cell的状态,视输入参数cell而定;

(2)outputs, states = embedding_attention_seq2seq(encoder_inputs, decoder_inputs, …)

输入参数 :
   encoder_inputs: 与上面的基本函数,它是一个一维tensor构成的列表对象,其中每一个一维tensor的尺寸为[batch_size],代表某一时刻的输入;
   decoder_inputs: 与encoder_inputs的解释类似;
   cell: 它是一个rnn_cell.RNNCell或者multi-layer-RNNCell对象,其中定义了cell函数和hidden units的个数;
   num_encoder_symbols: 具体指输入词库的大小,也即输入单词one-hot表示后的向量长度;
   num_decoder_symbols: 具体指输出词库的大小;
   embedding_size: 词库中每一个单词“嵌套”后向量的长度;
   num_heads: 默认为1(具体的意义我还没弄明白);
   output_projection: 为None或者 (W, B) 元组对象,其中W的尺寸为[output_size x num_decoder_symbols],B的尺寸为 [num_decoder_symbols],
            显然,解码器每一时刻的输出仅共享偏置参数B,权值参数不共享;
   feed_previous: 为True时用于模型测试阶段,基于贪婪算法生成输出序列,为False时用于训练模型参数;
   initial_state_attention: 设置初始attention的状态,也即上图中 α i j \alpha_{ij} αij的取值;
  输出参数 :
   outputs: 它是一个二维tensor构成的列表对象,其中每一个二维tensor代表某一时刻输出,其尺寸为[batch_size x num_decoder_symbols];
   state: 它是一个二维tensor,表示每一个decoder cell在最后的time-step的状态,其尺寸为[batch_size x cell.state_size],这里的
      cell.state_size可以表示一个或者多个子cell的状态,视输入参数cell而定;

sequence-to-sequence模型实现中的技巧

做理论和做工程还是有区别的,在对sequence-to-sequence模型进行实现时,Google的工程师们使用了sample softmax策略和bucketing策略,下面我们分别对其进行讲解。

sample softmax策略

解码器RNN序列在每一时刻的输出层为softmax分类器,在对上面的目标函数求梯度时,表达式中会出现对整个target vocabulary的求和项,显然这样做的计算量是非常大的,于是大牛们想到了用target vocabulary中的一个子集,来近似对整个词库的求和,子集中word的选取采用的是均匀采样的策略,从而降低了每次梯度更新步骤的计算复杂度,在tensorflow中可以采用tf.nn.sampled_softmax_loss函数。

bucketing策略

bucketing策略可以用于处理不同长度的训练样例,如果我们把训练样例的输入和输出长度固定,那么在训练整个网络的时候,必然会引入很多的PAD辅助单词,而这些单词却包含了无用信息;如果不引入PAD辅助单词,每一个样例作为一个graph的话,因为每一个样例的输入尺寸和输出尺寸一般是不一样的,所以每一个样例定义出的graph也是不一样的,因此就会定义出非常多的graph,尽管这些graph有相似的sub-graph,但是在训练的时候不能够进行并行计算,势必会大大降低模型的训练效率。所以,一个折中的方法就是,可以设置若干个buckets,每个bucket指定一个输入和输出长度,比如教程给的例子buckets = [(5, 10), (10, 15), (20, 25), (40, 50)],这样的话,经过bucketing策略处理后,会把所有的训练样例分成4份,其中每一份的输入序列和输出序列的长度分别相同。为了更好地理解源代码中bucketing的使用,我们这里补充讲述一下。TensorFlow是先定义出Graph,模型的训练过程就是对Graph中参数进行更新。对于本例中的Graph而言,Graph中encoder部分的长度为40,decoder部分的长度为50,在每次采用梯度下降法更新模型参数时,会随机地从4个buckets中选择一个,并从中随机选取batch个训练样例,此时相当于对当前Graph中的参数进行优化,但考虑到4个graph之间存在“weight share”,因此每个batch中样例的长度不一样也是可以的。

Github源代码解析

整个工程主要使用了四个源文件,seq2seq.py文件是一个用于创建sequence-to-sequence模型的库,data_utils.py中包含了对原始数据进行预处理的一些操作,seq2seq_model.py用于定义machine translation模型,translate.py用于训练和测试所定义的翻译模型。因为源代码较长,下面仅针对每个.py文件,对理解起来可能有困难的代码块进行解析。

seq2seq.py文件

这个文件中比较重要的两个库函数basic_rnn_seq2seq和embedding_attention_seq2seq已经在上一部分作了介绍,这里主要介绍其它的几个功能函数。

(1)sequence_loss_by_example(logits, targets, weights)

这个函数用于计算所有examples的加权交叉熵损失,logits参数是一个2D Tensor构成的列表对象,每一个2D Tensor的尺寸为[batch_size x num_decoder_symbols],函数的返回值是一个1D float类型的Tensor,尺寸为batch_size,其中的每一个元素代表当前输入序列example的交叉熵。另外,还有一个与之类似的函数sequence_loss,它对sequence_loss_by_example函数返回的结果进行了一个tf.reduce_sum运算,因此返回的是一个标称型float Tensor。

(2)model_with_buckets(encoder_inputs, decoder_inputs, targets, weights, buckets, seq2seq)

    for j, bucket in enumerate(buckets):
      with variable_scope.variable_scope(variable_scope.get_variable_scope(),
                                         reuse=True if j > 0 else None):
        # 函数seq2seq有两个返回值,因为tf.nn.seq2seq.embedding_attention_seq2seq函数有两个返回值
        bucket_outputs, _ = seq2seq(encoder_inputs[:bucket[0]],
                                    decoder_inputs[:bucket[1]])
        outputs.append(bucket_outputs)
        if per_example_loss:
          losses.append(sequence_loss_by_example(
              outputs[-1], targets[:bucket[1]], weights[:bucket[1]],
              softmax_loss_function=softmax_loss_function))
        else:
          losses.append(sequence_loss(
              outputs[-1], targets[:bucket[1]], weights[:bucket[1]],
              softmax_loss_function=softmax_loss_function))

这个函数创建了一个支持bucketing策略的sequence-to-sequence模型,它仍然属于Graph的定义阶段。具体来说,这段程序定义了length(buckets)个graph,每个graph的输入为总模型的输入“占位符”的一部分,但这些graphs共享模型参数,函数的返回值outputs和losses均为列表对象,尺寸为[length(buckets)],其中每一个元素为当前graph的bucket_outputs和bucket_loss。

data_utils.py文件

(1)create_vocabulary(vocabulary_path, data_path, max_vocabulary_size)

这个函数用于根据输入文件创建词库,在这里data_path参数表示输入源文件的路径,vocabulary_path表示输出文件的路径,vocabulary_path文件中每一行代表一个单词,且按照其在data_path中的出现频数从大到小排列,比如第1行为r"_EOS",第2行为r"_UNK",第3行为r’I’,第4行为r"have",第5行为r’dream’,…

(2)def data_to_token_ids(data_path, target_path, vocabulary_path)

这个函数用于把字符串为元素的数据文件转换为以int索引为元素的文件,在这里data_path表示输入源数据文件的路径,target_path表示输出索引数据文件的路径,vocabulary_path表示词库文件的路径。整个函数把数据文件中的每一行转换为在词库文件中的索引值,两单词的索引值之间用空格隔开,比如返回值文件的第一行为’1 123 235’,第二行为‘3 1 234 554 879 355’,…

seq2seq_model.py文件

机器学习模型的定义过程,一般包括输入变量定义、输入信息的forward propagation和误差信息的backward propagation三个部分,这三个部分在这个程序文件中都得到了很好的体现,下面我们结合代码分别进行介绍。

(1)输入变量的定义

    # Feeds for inputs.
    self.encoder_inputs = []
    self.decoder_inputs = []
    self.target_weights = []
    for i in xrange(buckets[-1][0]):  # Last bucket is the biggest one.
      self.encoder_inputs.append(tf.placeholder(tf.int32, shape=[None],
                                                name="encoder{0}".format(i)))
    for i in xrange(buckets[-1][1] + 1):
      self.decoder_inputs.append(tf.placeholder(tf.int32, shape=[None],
                                                name="decoder{0}".format(i)))
      self.target_weights.append(tf.placeholder(dtype, shape=[None],
                                                name="weight{0}".format(i)))

    # Our targets are decoder inputs shifted by one.
    targets = [self.decoder_inputs[i + 1]
               for i in xrange(len(self.decoder_inputs) - 1)]

与前面的几个样例不同,这里输入数据采用的是最常见的“占位符”格式,以self.encoder_inputs为例,这个列表对象中的每一个元素表示一个占位符,其名字分别为encoder0, encoder1,…,encoder39,encoder{i}的几何意义是编码器在时刻i的输入。这里需要注意的是,在训练阶段执行sess.run()函数时会再次用到这些变量名字。另外,跟language model类似,targets变量是decoder inputs平移一个单位的结果,读者可以结合当前模型的损失函数进行理解。

(2)输入信息的forward propagation

    # Training outputs and losses.
    if forward_only:
      # 返回每一个bucket子图模型对应的output和loss
      self.outputs, self.losses = tf.nn.seq2seq.model_with_buckets(
          self.encoder_inputs, self.decoder_inputs, targets,
          self.target_weights, buckets, lambda x, y: seq2seq_f(x, y, True),
          softmax_loss_function=softmax_loss_function)
      # If we use output projection, we need to project outputs for decoding.
      if output_projection is not None:
        for b in xrange(len(buckets)):
          self.outputs[b] = [
              tf.matmul(output, output_projection[0]) + output_projection[1]
              for output in self.outputs[b]
          ]
    else:
      self.outputs, self.losses = tf.nn.seq2seq.model_with_buckets(
          self.encoder_inputs, self.decoder_inputs, targets,
          self.target_weights, buckets,
          lambda x, y: seq2seq_f(x, y, False),
          softmax_loss_function=softmax_loss_function)

从代码中可以看到,输入信息的forward popagation分成了两种情况,这是因为整个sequence to sequence模型在训练阶段和测试阶段信息的流向是不一样的,这一点可以从seq2seqf函数的do_decode参数值体现出来,而do_decoder取值对应的就是tf.nn.seq2seq.embedding_attention_seq2seq函数中的feed_previous参数,forward_only为True也即feed_previous参数为True时进行模型测试,为False时进行模型训练。这里还应用到了一个很重要的函数tf.nn.seq2seq.model_with_buckets,我么在seq2seq文件中对其进行讲解。

(3)误差信息的backward propagation

 # 返回所有bucket子graph的梯度和SGD更新操作,这些子graph共享输入占位符变量encoder_inputs,区别在于,
    # 对于每一个bucket子图,其输入为该子图对应的长度。
    params = tf.trainable_variables()
    if not forward_only:
      self.gradient_norms = []
      self.updates = []
      opt = tf.train.GradientDescentOptimizer(self.learning_rate)
      for b in xrange(len(buckets)):
        gradients = tf.gradients(self.losses[b], params)
        clipped_gradients, norm = tf.clip_by_global_norm(gradients,
                                                         max_gradient_norm)
        self.gradient_norms.append(norm)
        self.updates.append(opt.apply_gradients(
            zip(clipped_gradients, params), global_step=self.global_step))

这一段代码主要用于计算损失函数关于参数的梯度。因为只有训练阶段才需要计算梯度和参数更新,所以这里有个if判断语句。并且,由于当前定义除了length(buckets)个graph,故返回值self.updates是一个列表对象,尺寸为length(buckets),列表中第i个元素表示graph{i}的梯度更新操作。

    # Input feed: encoder inputs, decoder inputs, target_weights, as provided.
    input_feed = {}
    for l in xrange(encoder_size):
      input_feed[self.encoder_inputs[l].name] = encoder_inputs[l]
    for l in xrange(decoder_size):
      input_feed[self.decoder_inputs[l].name] = decoder_inputs[l]
      input_feed[self.target_weights[l].name] = target_weights[l]
    ......
    if not forward_only:
      output_feed = [self.updates[bucket_id],  # Update Op that does SGD.
                     self.gradient_norms[bucket_id],  # Gradient norm.
                     self.losses[bucket_id]]  # Loss for this batch.
    else:
      output_feed = [self.losses[bucket_id]]  # Loss for this batch.
      for l in xrange(decoder_size):  # Output logits.
        output_feed.append(self.outputs[bucket_id][l])

    outputs = session.run(output_feed, input_feed)

模型已经定义完成了,这里便开始进行模型训练了。上面的两个for循环用于为之前定义的输入占位符赋予具体的数值,这些具体的数值源自于get_batch函数的返回值。当session.run函数开始执行时,当前session会对第bucket_id个graph进行参数更新操作。

参考资料:https://www.tensorflow.org/versions/r0.12/tutorials/seq2seq/index.html
     Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation. Kyunghyun Cho, et.
     NEURAL MACHINE TRANSLATION BY JOINTLY LEARNING TO ALIGN AND TRANSLATE. Dzmitry Bahdanau, et.
     On Using Very Large Target Vocabulary for Neural Machine Translation. Sebastien Jean, et.

  • 9
    点赞
  • 58
    收藏
    觉得还不错? 一键收藏
  • 17
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 17
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值