在深度学习中,我们都知道,递归神经元网络解决的是时间序列数据。序列到序列(简称Seq2Seq)是一种为解决 "多对多 "问题而诞生的模型。Seq2Seq有很多应用,最常见的可能是机器翻译。在这篇文章中,我们将探讨它的架构以及如何用代码来应用它。在开始之前,如果你还没有熟悉LSTM和GRU,请阅读这些文章。
架构和机制
我们的模型的输入和输出都是序列。在本文中,输入是一个英语句子,输出是一个法语句子。模型的结构有两个部分:编码器和解码器。当编码器被输入时,解码器输出一个句子。

注意,这个输出被用作下一步的编码器的输入。换句话说,这一步的输出被用作下一步的输入的一部分。
对于第一个词,它被赋予序列开始(SOS)标记。
请注意,输入的句子在送入编码器之前会被反转。例如,"I drink milk "被反转为 "milk drink I"。这确保了英语句子的开头将最后送入编码器,这很有用,因为这通常是解码器需要翻译的第一件事。
现在我们来谈谈细节。请看图2,我将对其进行解释。

嵌入查找
在输入编码器之前,我们需要对我们的输入进行预处理,即文本。所有的标点符号都被去除,然后单词被转换为小写,用空格分割,最后按频率进行索引。0,1,2很特别:它们分别代表填充标记、序列开始和未知单词。
首先,每个词最初由其ID表示(例如288表示 "牛奶",3335表示 "饮料")。然后,嵌入层返回单词嵌入,这是一个具有预定义维数的向量。这些词的嵌入实际上是编码器的输入。
编码器
编码器是一个递归单元的堆栈,可以用GRU单元的LSTM代替,以获得更好的性能。
在每一步,递归单元接受一个对应于输入x_t的嵌入词,并返回隐藏状态h_t.然后下一个递归单元接受h_t和x_t+1作为其输入,并返回h_t+1.这个循环对其他句子重复进行。
语境向量
上下文向量或编码器向量是编码器部分的最终隐藏状态,它封装了输入句子的所有信息,以便使输出更加准确。语境向量也是解码器的输入。
解码器
解码器的结构和操作与编码器相似,但有一点不同:每个递归单元都会返回输出y_t和隐藏状态h_t,它们在几乎所有的情况下都是一样的,每个隐藏状态都是通过这个公式计算的。

每个输出y_t可以被认为是输出词汇(即法语)中一个单词的得分。它们被送入Softmax层以计算概率。

例如,在第一步,"Je "这个词可能有20%的概率,"Tu "可能有1%的概率,以此类推。
请注意,在推理时间(训练后),你不会有目标句子来喂给解码器。相反,只需向解码器提供它在前一步输出的单词,如图3所示。

定时分布的softmax层
为了把模型变成一个序列到序列的模型,我们必须在所有的递归层(甚至最后一个)中设置return_sequences=True,而且我们必须在每个时间步骤中应用输出Dense层。Keras为这一目的提供了一个TimeDistributed层:它包装了任何层(例如Dense层),并在其输入序列的每一个时间步骤上应用它。它能有效地做到这一点,通过重塑输入,使每个时间步骤被视为一个单独的实例(即,它将输入从[批次大小,时间步骤,输入尺寸]重塑为[批次大小×时间步骤,输入尺寸],然后它运行Dense层,最后它将输出重塑为序列(即,它将输出从[批次大小×时间步骤,输出尺寸]重塑为[批次大小,时间步骤,输出尺寸]。
应用
现在是编码的时候了。首先,我们建立一个基本的编码器-解码器模型,如图2所示。
首先,我们声明embed_size和vocab_size,以便我们可以用它们来定义嵌入层。
embed_size = 10
vocab_size = 100
为编码器和解码器创建输入层,注意我们在这里设置shape=[None],dtype=np.int32,因为我们只用整数来表示单词的索引。别忘了,在机器翻译任务中,在上述情况下,编码器的输入是英语的句子,解码器的输入是法语的句子。
encoder_inputs = keras.layers.Input(shape=[None], dtype=np.int32)
decoder_inputs = keras.layers.Input(shape=[None], dtype=np.int32)
sequence_lengths = keras.layers.Input(shape=[], dtype=np.int32)
embeddings = keras.layers.Embedding(vocab_size, embed_size)
接下来,我们向嵌入层输入编码器和解码器的输入。它们被转换为我们的RNN单元可以理解的向量。
encoder_embeddings = embeddings(encoder_inputs)
decoder_embeddings = embeddings(decoder_inputs)
在这个单元中,我们定义了一个有512个单元的LSTM神经元网络。在创建LSTM层时,我们设置return_state=True,这样我们就可以得到它的最终隐藏状态并把它传递给解码器。由于我们使用的是LSTM单元,它实际上返回两个隐藏状态(短期和长期)。
另一个选择是使用GRU单元,但在这种情况下,你需要设置return_sequences=True
encoder = keras.layers.LSTM(512, return_state=True)
encoder_outputs, state_h, state_c = encoder(encoder_embeddings)
encoder_state = [state_h, state_c]
最后,我们定义我们的解码器部分。
import tensorflow_addons as tfa
sampler = tfa.seq2seq.sampler.TrainingSampler()
decoder_cell = keras.layers.LSTMCell(512)
output_layer = keras.layers.Dense(vocab_size)
decoder = tfa.seq2seq.basic_decoder.BasicDecoder(decoder_cell,sampler,output_layer=output_layer)
final_outputs, final_state, final_sequence_lengths = decoder(
decoder_embeddings, initial_state=encoder_state,
sequence_length=sequence_lengths)
Y_proba = tf.nn.softmax(final_outputs.rnn_output)
model = keras.Model(inputs=[encoder_inputs,decoder_inputs,sequence_lengths],outputs=[Y_proba])
TrainingSampler是TensorFlow Addons中的几个采样器之一:它们的作用是告诉解码器在每一步应该假装之前的输出是什么。在推理期间,这应该是实际输出的标记的嵌入。在训练过程中,它应该是之前目标标记的嵌入:这就是为什么我们使用TrainingSampler。
你也可以用TimeDistributed(Dense(...))代替tf.nn.softmax,设置激活='softmax',正如我们之前讨论的那样,它们是相同的。
编译模型。
model.compile(loss="sparse_categorical_crossentropy", optimizer="adam")
如果你想用真实的数据工作(英法翻译),你可以在我的专栏中找到完整的代码和非常清晰的解释。
X = np.random.randint(100, size=10*1000).reshape(1000, 10)
Y = np.random.randint(100, size=15*1000).reshape(1000, 15)
X_decoder = np.c_[np.zeros((1000, 1)), Y[:, :-1]]
seq_lengths = np.full([1000], 15)
history = model.fit([X, X_decoder, seq_lengths], Y, epochs=2)
训练完成后,我们就可以得到结果了。
Epoch 1/2
32/32 [==============================] - 6s 6ms/sample - loss: 4.6053
Epoch 2/2
32/32 [==============================] - 3s 3ms/sample - loss: 4.6031
讨论
在这一部分,我们将讨论我们模型的一些优点和缺点。
-
优点:
由于它的简单性而易于应用,
可以将输入句子与不同长度的目标句子进行映射。 -
缺点:
它
只
通过
一个向量(上下文向量)
接收一个句子并将其转化为另一种语言的句子
。如果输入的是长句子,解码器就很难只通过上下文向量来准确地生成句子,因为我们试图把整个句子塞进编码器,而不管这个句子是由多少个词组成的。
此外,RNN模型或多或少地失去了一些第一节点的信息,所以上下文向量不能学习编码器的所有信息。这个经典模型学习短句子非常好,但在试图捕捉长句子时表现很差。再次,这个缺点来自RNN有限的短期记忆。
这些问题将我们引向另一个模型。例如,在解码器需要输出 "lait "这个词的时间步骤中,它将注意力集中在 "milk "这个词上。这意味着从输入词到其翻译的路径现在要短得多,所以RNN的短期记忆限制的影响要小得多。
我们将在OpenGenus的其他文章中讨论这个问题。