TensorFlow深度学习实战(15)——编码器-解码器架构

发布于:2025-05-10 ⋅ 阅读:(8) ⋅ 点赞:(0)

0. 前言

编码器-解码器架构也称 Seq2Seq (Sequence-to-Sequence) 模型,是一种用于处理序列数据的深度学习模型,广泛应用于自然语言处理任务中,如机器翻译、文本摘要、对话生成等。在 Seq2Seq 模型中,输入和输出都是序列形式,因此非常适用于需要将一个序列映射到另一个序列的任务。在本节中,将介绍 Seq2Seq 模型架构,并实现 Seq2Seq 模型用于机器翻译。

1. Seq2Seq 模型简介

多对多网络类型与多对一网络的区别在于,循环神经网络 (Recurrent Neural Network, RNN) 在每个时间步返回输出,而不是在最后返回单个组合输出;此外,另一个特征是输入时间步的数量等于输出时间步的数量。而在编码器-解码器架构中,另一个区别在于,输出与输入是同步的,即网络不必等到所有输入被处理完毕才能生成输出。
编码器-解码器 (Encoder-Decoder) 架构也称为 Seq2Seq 模型,顾名思义,网络由编码器和解码器部分组成,两者都基于 RNN,能够处理并返回对应于多个时间步的输出序列。Seq2Seq 网络最流行的应用是在神经机器翻译中,但它同样适用于可以结构化为翻译问题的其他问题,例如句子解析和图像字幕,Seq2Seq 模型还用于时间序列分析和问答任务。

2. Seq2Seq 模型架构

Seq2Seq 模型中,编码器处理源序列(整数序列),序列的长度等于输入时间步的数量,对应于最大输入序列长度(根据需要进行填充或截断)。因此,输入张量的维度为 (batch_size, number_of_encoder_timesteps),将其传入嵌入层,将每个时间步的整数转换为嵌入向量。嵌入层的输出是形状为 (batch_size, number_of_encoder_timesteps, encoder_embedding_dim) 的张量。该张量输入到循环神经网络 (Recurrent Neural Network, RNN) 中,将每个时间步的向量转换为编码维度对应的大小,结合当前时间步和所有之前时间步的信息。通常,编码器会在最后一个时间步返回输出,表示整个序列的上下文,形状为 (batch_size, encoder_rnn_dim)
解码器网络的架构与编码器相似,但在每个时间步增加了一个全连接层以转换输出。解码器每个时间步的输入是前一个时间步的隐藏状态和解码器在前一时间步生成的预测词元 (token)。对于第一个时间步,隐藏状态是来自编码器的上下文向量,预测词元对应于启动序列生成的词元,例如,对于翻译任务,词元为起始字符串 (beginning-of-string, BOS)。隐藏状态的形状为 (batch_size, encoder_rnn_dim),而预测词元的形状为 (batch_size, number_of_decoder_timesteps)
经过嵌入层后,输出张量的形状是 (batch_size, number_of_decoder_timesteps, decoder_embedding_dim)。然后是解码器的 RNN 层,其输出是形状为 (batch_size, number_of_decoder_timesteps, decoder_rnn_dim) 的张量。然后,每个时间步的输出通过一个全连接层,将向量转换为目标词汇的大小,因此全连接层的输出是 (batch_size, number_of_decoder_timesteps, output_vocab_size)。这可以视为是每个时间步上词元 (token) 的概率分布,因此如果在最后一个维度上计算 argmax,就可以将其转换回目标语言中预测的词元序列。Seq2Seq 架构如下所示:

Seq2Seq架构

接下来,我们将实现一个用于机器翻译的 Seq2Seq 网络。

3. 基于 Seq2Seq 实现机器翻译

为了更深入的理解 Seq2Seq 模型,我们将实现一个用于机器翻译的 Seq2Seq 网络,使用 Tatoeba 项目 (1997-2019) 的法英双语数据集进行英语到法语的翻译。该数据集包含约 167,000 对句子。为了加快训练速度,我们只使用前 30,000 对句子进行训练。

3.1 数据处理

(1) 首先,导入所需库,并定义常量:

import nltk
import numpy as np
import re
import shutil
import tensorflow as tf
import os
import unicodedata
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction

NUM_SENT_PAIRS = 30000
EMBEDDING_DIM = 256
ENCODER_DIM, DECODER_DIM = 1024, 1024
BATCH_SIZE = 64
NUM_EPOCHS = 250

(2) 法英双语数据集以 zip 文件的形式提供,下载 fra-eng.zip 文件并解压。zip 文件中包含一个名为 fra.txt 的文件,其中每一行都包含一对法语和英语句子,句子间用制表符分隔。将 fra.txt 文件置于 dataset 文件夹中,并从中提取三种不同的数据。
Seq2Seq 网络中,编码器的输入是一系列英文单词。在解码器中,输入是一组法语单词,输出是偏移一个时间步的法语单词序列。

(3) 对输入进行预处理,将字符转换为 ASCII 码,将特定标点符号与其相邻的单词分开,并移除字母字符和特定标点符号之外的所有字符。最后,将句子转换为小写。每个英语句子都转换为一个的单词序列。每个法语句子则转换为两个序列,一个以开始标志 BOS 开头,另一个以结束标志 (end-of-sentence, EOS) 结尾,第一个序列从位置 0 开始,止于句子的倒数第二个单词,而第二个序列从位置 1 开始,一直到句子结束:

def clean_up_logs(data_dir):
    checkpoint_dir = os.path.join(data_dir, "checkpoints")
    if os.path.exists(checkpoint_dir):
        shutil.rmtree(checkpoint_dir, ignore_errors=True)
        os.makedirs(checkpoint_dir)
    return checkpoint_dir

def preprocess_sentence(sent):
    sent = "".join([c for c in unicodedata.normalize("NFD", sent) 
        if unicodedata.category(c) != "Mn"])
    sent = re.sub(r"([!.?])", r" \1", sent)
    sent = re.sub(r"[^a-zA-Z!.?]+", r" ", sent)
    sent = re.sub(r"\s+", " ", sent)
    sent = sent.lower()
    return sent

def download_and_read():
    en_sents, fr_sents_in, fr_sents_out = [], [], []
    local_file = os.path.join("datasets", "fra.txt")
    with open(local_file, "r") as fin:
        for i, line in enumerate(fin):
            en_sent, fr_sent = line.strip().split('\t')[:2]
            en_sent = [w for w in preprocess_sentence(en_sent).split()]
            fr_sent = preprocess_sentence(fr_sent)
            fr_sent_in = [w for w in ("BOS " + fr_sent).split()]
            fr_sent_out = [w for w in (fr_sent + " EOS").split()]
            en_sents.append(en_sent)
            fr_sents_in.append(fr_sent_in)
            fr_sents_out.append(fr_sent_out)
            if i >= NUM_SENT_PAIRS - 1:
                break
return en_sents, fr_sents_in, fr_sents_out

data_dir = "./data"
checkpoint_dir = clean_up_logs(data_dir)

# data preparation
download_url = "http://www.manythings.org/anki/fra-eng.zip"
sents_en, sents_fr_in, sents_fr_out = download_and_read()

(4) 接下来,对输入进行词元化并创建词汇表。由于有两种不同语言的序列,因此将创建两个不同的词元分析器和词汇表,分别对应于不同语言。
tf.keras 框架提供了一个功能强大且灵活的的 Tokenizer 类。本节中,我们将 filters 设置为空字符串,并将 lower 设置为 False,因为在 preprocess_sentence() 函数中已经完成了词元化所需的工作。Tokenizer 创建了各种数据结构,可以从中计算词汇表的大小和查找表,以便能够从单词转换为单词索引,再反之从单词索引转换为单词。然后,使用 pad_sequences() 函数通过在末尾填充零来处理不同长度的单词序列。由于我们的字符串相对较短,所以不进行截断;将句子填充到数据集中的最大句子长度(英语为 8 个单词,法语为 16 个单词):

tokenizer_en = tf.keras.preprocessing.text.Tokenizer(
    filters="", lower=False)
tokenizer_en.fit_on_texts(sents_en)
data_en = tokenizer_en.texts_to_sequences(sents_en)
data_en = tf.keras.preprocessing.sequence.pad_sequences(data_en, padding="post")

tokenizer_fr = tf.keras.preprocessing.text.Tokenizer(
    filters="", lower=False)
tokenizer_fr.fit_on_texts(sents_fr_in)
tokenizer_fr.fit_on_texts(sents_fr_out)
data_fr_in = tokenizer_fr.texts_to_sequences(sents_fr_in)
data_fr_in = tf.keras.preprocessing.sequence.pad_sequences(data_fr_in, padding="post")
data_fr_out = tokenizer_fr.texts_to_sequences(sents_fr_out)
data_fr_out = tf.keras.preprocessing.sequence.pad_sequences(data_fr_out, padding="post")

vocab_size_en = len(tokenizer_en.word_index)
vocab_size_fr = len(tokenizer_fr.word_index)
word2idx_en = tokenizer_en.word_index
idx2word_en = {v:k for k, v in word2idx_en.items()}
word2idx_fr = tokenizer_fr.word_index
idx2word_fr = {v:k for k, v in word2idx_fr.items()}
print("vocab size (en): {:d}, vocab size (fr): {:d}".format(
    vocab_size_en, vocab_size_fr))

maxlen_en = data_en.shape[1]
maxlen_fr = data_fr_out.shape[1]
print("seqlen (en): {:d}, (fr): {:d}".format(maxlen_en, maxlen_fr))

(5) 最后,将数据转换为 TensorFlow 数据集,然后将其分成训练集和测试集:

batch_size = BATCH_SIZE
dataset = tf.data.Dataset.from_tensor_slices((data_en, data_fr_in, data_fr_out))
dataset = dataset.shuffle(10000)
test_size = NUM_SENT_PAIRS // 4
test_dataset = dataset.take(test_size).batch(batch_size, drop_remainder=True)
train_dataset = dataset.skip(test_size).batch(batch_size, drop_remainder=True)

3.2 Seq2Seq 模型构建与训练

(1) 用于训练 Seq2Seq 网络的数据训练完毕后,接下来定义网络。
编码器中嵌入层后面接一个门控循环单元 (Gated Recurrent Unit, GRU) 层,编码器的输入是一个整数序列,嵌入层将其转换为大小为 embedding_dim 的嵌入向量序列,该向量序列通过 GRU 在每个 num_timesteps 时间步中将输入转换为大小为 encoder_dim 的向量。使用参数 return_sequences=False,仅返回最后一个时间步的输出。
解码器与编码器具有几乎相同的结构,不同之处在于它包含一个额外的全连接层,将从编码器输出的向量转换为代表目标词汇的概率分布向量,解码器还返回所有时间步的输出。
在本节中,嵌入维度为 128,编码器和解码器的 GRU 维度均为 1024。需要注意的是,对于英语和法语词汇,我们需要在词汇表大小上加 1,以考虑在 pad_sequences() 步骤中添加的 PAD 字符:

class Encoder(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, num_timesteps, 
            encoder_dim, **kwargs):
        super(Encoder, self).__init__(**kwargs)
        self.encoder_dim = encoder_dim
        self.embedding = tf.keras.layers.Embedding(
            vocab_size, embedding_dim, input_length=num_timesteps)
        self.rnn = tf.keras.layers.GRU(
            encoder_dim, return_sequences=False, return_state=True)

    def call(self, x, state):
        x = self.embedding(x)
        x, state = self.rnn(x, initial_state=state)
        return x, state

    def init_state(self, batch_size):
        return tf.zeros((batch_size, self.encoder_dim))

class Decoder(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, num_timesteps,
            decoder_dim, **kwargs):
        super(Decoder, self).__init__(**kwargs)
        self.decoder_dim = decoder_dim
        self.embedding = tf.keras.layers.Embedding(
            vocab_size, embedding_dim, input_length=num_timesteps)
        self.rnn = tf.keras.layers.GRU(
            decoder_dim, return_sequences=True, return_state=True)
        self.dense = tf.keras.layers.Dense(vocab_size)

    def call(self, x, state):
        x = self.embedding(x)
        x, state = self.rnn(x, state)
        x = self.dense(x)
        return x, state

embedding_dim = EMBEDDING_DIM
encoder_dim, decoder_dim = ENCODER_DIM, DECODER_DIM

encoder = Encoder(vocab_size_en+1, embedding_dim, maxlen_en, encoder_dim)
decoder = Decoder(vocab_size_fr+1, embedding_dim, maxlen_fr, decoder_dim)

(2) 定义了编码器和解码器类后,观察它们输入和输出的维度。以下代码可用于打印模型的各种输入和输出的维度:

for encoder_in, decoder_in, decoder_out in train_dataset:
    encoder_state = encoder.init_state(batch_size)
    encoder_out, encoder_state = encoder(encoder_in, encoder_state)
    decoder_state = encoder_state
    decoder_pred, decoder_state = decoder(decoder_in, decoder_state)
    break
print("encoder input          :", encoder_in.shape)
print("encoder output         :", encoder_out.shape, "state:", encoder_state.shape)
print("decoder output (logits):", decoder_pred.shape, "state:", decoder_state.shape)
print("decoder output (labels):", decoder_out.shape)

输出如下所示,符合我们的预期。编码器输入是一个批次的整数序列,每个序列的大小为 8 (英语句子中最大的单词数),因此其维度为 (batch_size, maxlen_en)。编码器的输出是一个张量 (return_sequences=False),形状为 (batch_size, encoder_dim),表示批数据的上下文向量,代表输入句子。编码器状态张量具有相同的维度。解码器的输出也是一个批次的整数序列,但法语句子的最大长度为 16;因此,维度为 (batch_size, maxlen_fr)
解码器的预测是一个跨所有时间步的概率分布;因此,其维度为 (batch_size, maxlen_fr, vocab_size_fr+1),解码器状态的维度与编码器状态的维度相同,均为 (batch_size, decoder_dim)

输出结果

(3) 接下来,定义损失函数。因为我们对句子进行了填充,所以我们不希望在计算损失时考虑标签和预测之间填充词的相等性。损失函数通过将预测与标签进行掩码处理,移除标签中的填充位置,因此我们只使用标签和预测中的非零元素来计算损失:

def loss_fn(ytrue, ypred):
    scce = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
    mask = tf.math.logical_not(tf.math.equal(ytrue, 0))
    mask = tf.cast(mask, dtype=tf.int64)
    loss = scce(ytrue, ypred, sample_weight=mask)
    return loss

(4) 由于 Seq2Seq 模型不容易封装成一个简单的 tf.Keras 模型,还需要手动处理训练循环。train_step() 函数处理数据流,并计算每一步的损失,将损失的梯度应用于可训练权重上,并返回损失值。需要注意的是,训练代码与之前讲解的 Seq2Seq 模型描述并不完全相同。在这里,整个解码器输入 decoder_input 一次性输入到解码器中,以产生偏移一个时间步的输出;然而在讲解中,我们提到这是顺序进行的,前一个时间步生成的预测单词作为下一个时间步的输入。
这是一种常见的训练 Seq2Seq 网络的技术,称为 Teacher Forcing,其中解码器的输入是真实标签,而不是上一个时间步的预测。这种方法能够加快训练速度,但也会导致预测质量的一定程度的降低。为了弥补这一点,可以使用调度采样 (Scheduled Sampling) 等技术,根据某个阈值随机从真实标签或前一个时间步的预测中抽样输入(阈值取决于具体问题,通常取值在 0.10.4 之间):

@tf.function
def train_step(encoder_in, decoder_in, decoder_out, encoder_state):
    with tf.GradientTape() as tape:
        encoder_out, encoder_state = encoder(encoder_in, encoder_state)
        decoder_state = encoder_state
        decoder_pred, decoder_state = decoder(decoder_in, decoder_state)
        loss = loss_fn(decoder_out, decoder_pred)
    
    variables = encoder.trainable_variables + decoder.trainable_variables
    gradients = tape.gradient(loss, variables)
    optimizer.apply_gradients(zip(gradients, variables))
    return loss

(5) predict() 方法从数据集中随机采样一个英语句子,并使用模型预测法语句子。作为参考,还显示了标签法语句子。evaluate() 方法计算了测试集中所有记录标签与预测之间的双语评估 (BiLingual Evaluation Understudy, BLEU) 分数。BLEU 分数通常用于存在多个真实标签的情况(本节中只有一个),并比较参考和候选句子中的最多 4 个词组( n-gramn=4)。在每个 epoch 结束时,都会调用 predict()evaluate() 方法:

def predict(encoder, decoder, batch_size, 
        sents_en, data_en, sents_fr_out, 
        word2idx_fr, idx2word_fr):
    random_id = np.random.choice(len(sents_en))
    print("input    : ",  " ".join(sents_en[random_id]))
    print("label    : ", " ".join(sents_fr_out[random_id]))

    encoder_in = tf.expand_dims(data_en[random_id], axis=0)
    decoder_out = tf.expand_dims(sents_fr_out[random_id], axis=0)

    encoder_state = encoder.init_state(1)
    encoder_out, encoder_state = encoder(encoder_in, encoder_state)
    decoder_state = encoder_state

    decoder_in = tf.expand_dims(
        tf.constant([word2idx_fr["BOS"]]), axis=0)
    pred_sent_fr = []
    while True:
        decoder_pred, decoder_state = decoder(decoder_in, decoder_state)
        decoder_pred = tf.argmax(decoder_pred, axis=-1)
        pred_word = idx2word_fr[decoder_pred.numpy()[0][0]]
        pred_sent_fr.append(pred_word)
        if pred_word == "EOS":
            break
        decoder_in = decoder_pred
    
    print("predicted: ", " ".join(pred_sent_fr))

def evaluate_bleu_score(encoder, decoder, test_dataset, 
        word2idx_fr, idx2word_fr):

    bleu_scores = []
    smooth_fn = SmoothingFunction()
    for encoder_in, decoder_in, decoder_out in test_dataset:
        encoder_state = encoder.init_state(batch_size)
        encoder_out, encoder_state = encoder(encoder_in, encoder_state)
        decoder_state = encoder_state
        decoder_pred, decoder_state = decoder(decoder_in, decoder_state)

        # compute argmax
        decoder_out = decoder_out.numpy()
        decoder_pred = tf.argmax(decoder_pred, axis=-1).numpy()

        for i in range(decoder_out.shape[0]):
            ref_sent = [idx2word_fr[j] for j in decoder_out[i].tolist() if j > 0]
            hyp_sent = [idx2word_fr[j] for j in decoder_pred[i].tolist() if j > 0]
            # remove trailing EOS
            ref_sent = ref_sent[0:-1]
            hyp_sent = hyp_sent[0:-1]
            bleu_score = sentence_bleu([ref_sent], hyp_sent, 
                smoothing_function=smooth_fn.method1)
            bleu_scores.append(bleu_score)

    return np.mean(np.array(bleu_scores))

(6) 训练循环如下。使用 Adam 优化器进行模型训练。同时设置一个检查点,以便在每 10epoch 训练后保存模型。模型训练 250epoch,并打印损失、抽样句子及其翻译,并在整个测试集上计算的 BLEU 分数:

optimizer = tf.keras.optimizers.Adam()
checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt")
checkpoint = tf.train.Checkpoint(optimizer=optimizer,
                                 encoder=encoder,
                                 decoder=decoder)

num_epochs = NUM_EPOCHS
eval_scores = []

for e in range(num_epochs):
    encoder_state = encoder.init_state(batch_size)

    for batch, data in enumerate(train_dataset):
        encoder_in, decoder_in, decoder_out = data
        # print(encoder_in.shape, decoder_in.shape, decoder_out.shape)
        loss = train_step(
            encoder_in, decoder_in, decoder_out, encoder_state)
    
    print("Epoch: {}, Loss: {:.4f}".format(e + 1, loss.numpy()))

    if e % 10 == 0:
        checkpoint.save(file_prefix=checkpoint_prefix)
    
    predict(encoder, decoder, batch_size, sents_en, data_en,
        sents_fr_out, word2idx_fr, idx2word_fr)

    eval_score = evaluate_bleu_score(encoder, decoder, test_dataset, word2idx_fr, idx2word_fr)
    print("Eval Score (BLEU): {:.3e}".format(eval_score))
    # eval_scores.append(eval_score)

checkpoint.save(file_prefix=checkpoint_prefix)

训练结果如下所示,可以看到,损失从 1.5 降到了 0.07BLEU 分数也增加了大约 2.5 倍。同时,前 5 个和最后 5epoch 之间翻译质量有了质的差异:

翻译结果

小结

Seq2Seq 模型的核心思想是将一个输入序列映射到一个输出序列。它通常由两部分组成,编码器将输入序列逐步处理,并将其转换成一个固定长度的上下文向量,包含了输入序列的关键信息;解码器根据上下文向量生成输出序列,逐步预测每一个输出元素。在本节中,实现了一个用于机器翻译的 Seq2Seq 模型,使用 Tatoeba 项目 (1997-2019) 的法英双语数据集进行英语到法语的翻译。

系列链接

TensorFlow深度学习实战(1)——神经网络与模型训练过程详解
TensorFlow深度学习实战(2)——使用TensorFlow构建神经网络
TensorFlow深度学习实战(3)——深度学习中常用激活函数详解
TensorFlow深度学习实战(4)——正则化技术详解
TensorFlow深度学习实战(5)——神经网络性能优化技术详解
TensorFlow深度学习实战(6)——回归分析详解
TensorFlow深度学习实战(7)——分类任务详解
TensorFlow深度学习实战(8)——卷积神经网络
TensorFlow深度学习实战(9)——构建VGG模型实现图像分类
TensorFlow深度学习实战(10)——迁移学习详解
TensorFlow深度学习实战(11)——风格迁移详解
TensorFlow深度学习实战(12)——词嵌入技术详解
TensorFlow深度学习实战(13)——神经嵌入详解
TensorFlow深度学习实战(14)——循环神经网络详解


网站公告

今日签到

点亮在社区的每一天
去签到