《动手学深度学习》
Table Of Contents
《动手学深度学习》
Table Of Contents

机器翻译

机器翻译是指将一段文本从一种语言自动翻译到另一种语言。因为一段文本序列在不同语言中长度不一定相同,所以我们使用机器翻译为例来介绍编码器—解码器和注意力机制的应用。

读取和预处理数据

我们先定义一些特殊符号。其中“<pad>”(padding)符号用来添加在较短序列后,直到每个序列等长,而“<bos>”和“<eos>”符号分别表示序列的开始和结束。

In [1]:
import collections
import io
import math
from mxnet import autograd, gluon, init, nd
from mxnet.contrib import text
from mxnet.gluon import data as gdata, loss as gloss, nn, rnn

PAD, BOS, EOS = '<pad>', '<bos>', '<eos>'

接着定义两个辅助函数对后面读取的数据进行预处理。

In [2]:
# 对一个序列,记录所有的词在 all_tokens 中以便之后构造词典,然后将该序列后添加 PAD 直到
# 长度变为 max_seq_len,并记录在 all_seqs 中。
def process_one_seq(seq_tokens, all_tokens, all_seqs, max_seq_len):
    all_tokens.extend(seq_tokens)
    seq_tokens += [EOS] + [PAD] * (max_seq_len - len(seq_tokens) - 1)
    all_seqs.append(seq_tokens)

# 使用所有的词来构造词典。并将所有序列中的词变换为词索引后构造 NDArray 实例。
def build_data(all_tokens, all_seqs):
    vocab = text.vocab.Vocabulary(collections.Counter(all_tokens),
                                  reserved_tokens=[PAD, BOS, EOS])
    indices = [vocab.to_indices(seq) for seq in all_seqs]
    return vocab, nd.array(indices)

为了演示方便,我们在这里使用一个很小的法语—英语数据集。这个数据集里,每一行是一对法语句子和它对应的英语句子,中间使用'\t'隔开。在读取数据时,我们在句末附上“<eos>”符号,并可能通过添加“<pad>”符号使每个序列的长度均为max_seq_len。我们为法语词和英语词分别创建词典。法语词的索引和英语词的索引相互独立。

In [3]:
def read_data(max_seq_len):
    # in 和 out 分别是 input 和 output 的缩写。
    in_tokens, out_tokens, in_seqs, out_seqs = [], [], [], []
    with io.open('../data/fr-en-small.txt') as f:
        lines = f.readlines()
    for line in lines:
        in_seq, out_seq = line.rstrip().split('\t')
        in_seq_tokens, out_seq_tokens = in_seq.split(' '), out_seq.split(' ')
        if max(len(in_seq_tokens), len(out_seq_tokens)) > max_seq_len - 1:
            continue  # 如果加上 EOS 后长于 max_seq_len,则忽略掉此样本。
        process_one_seq(in_seq_tokens, in_tokens, in_seqs, max_seq_len)
        process_one_seq(out_seq_tokens, out_tokens, out_seqs, max_seq_len)
    in_vocab, in_data = build_data(in_tokens, in_seqs)
    out_vocab, out_data = build_data(out_tokens, out_seqs)
    return in_vocab, out_vocab, gdata.ArrayDataset(in_data, out_data)

将序列的最大长度设成 7,然后查看读取到的第一个样本。该样本分别包含法语词索引序列和英语词索引序列。

In [4]:
max_seq_len = 7
in_vocab, out_vocab, dataset = read_data(max_seq_len)
dataset[0]
Out[4]:
(
 [ 6.  5. 46.  4.  3.  1.  1.]
 <NDArray 7 @cpu(0)>,
 [ 9.  5. 28.  4.  3.  1.  1.]
 <NDArray 7 @cpu(0)>)

含注意力机制的编码器—解码器

我们将使用含注意力机制的编码器—解码器来将一段简短的法语翻译成英语。下面我们来介绍模型的实现。

编码器

在编码器中,我们将输入语言的词索引通过词嵌入层得到特征表达,然后输入到一个多层门控循环单元中。正如我们在“循环神经网络的简洁实现”一节提到的,Gluon 的rnn.GRU实例在前向计算后也会分别返回输出和最终时间步的多层隐藏状态。其中的输出指的是最后一层的隐藏层在各个时间步的隐藏状态,并不涉及输出层计算。注意力机制将这些输出作为键项和值项。

In [5]:
class Encoder(nn.Block):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 drop_prob=0, **kwargs):
        super(Encoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = rnn.GRU(num_hiddens, num_layers, dropout=drop_prob)

    def forward(self, inputs, state):
        # 输入形状是(批量大小,时间步数)。将输出互换样本维和时间步维。
        embedding = self.embedding(inputs).swapaxes(0, 1)
        return self.rnn(embedding, state)

    def begin_state(self, *args, **kwargs):
        return self.rnn.begin_state(*args, **kwargs)

下面我们来创建一个批量大小为 4,时间步数为 7 的小批量序列输入。设门控循环单元的隐藏层个数为 2,隐藏单元个数为 16。编码器对该输入执行前向计算后返回的输出形状为(时间步数,批量大小,隐藏单元个数)。门控循环单元在最终时间步的多层隐藏状态的形状为(隐藏层个数,批量大小,隐藏单元个数)。对于门控循环单元来说,state列表中只含一个元素,即隐藏状态;如果使用长短期记忆,state列表中还将包含另一个元素,即记忆细胞。

In [6]:
encoder = Encoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
encoder.initialize()
output, state = encoder(nd.zeros((4, 7)), encoder.begin_state(batch_size=4))
output.shape, state[0].shape
Out[6]:
((7, 4, 16), (2, 4, 16))

注意力机制

在介绍如何实现注意力机制的矢量化计算之前,我们先了解一下Dense实例的flatten选项。当输入的维度大于 2 时,默认情况下,Dense实例会将除了第一维(样本维)以外的维度均视作需要仿射变换的特征维,并将输入自动转成行为样本、列为特征的二维矩阵。计算后,输出矩阵的形状为(样本数,输出个数)。如果我们希望全连接层只对输入的最后一维做仿射变换,而保持其他维度上的形状不变,便需要将Dense实例的flatten选项设为False。在下面例子中,全连接层只对输入的最后一维做仿射变换,因此输出形状中只有最后一维变为全连接层的输出个数 2。

In [7]:
dense = nn.Dense(2, flatten=False)
dense.initialize()
dense(nd.zeros((3, 5, 7))).shape
Out[7]:
(3, 5, 2)

我们将实现“注意力机制”一节中定义的函数 \(a\):将输入连结后通过含单隐藏层的多层感知机变换。其中隐藏层的输入是解码器的隐藏状态与编码器在所有时间步上隐藏状态的一一连结,且使用 tanh 作为激活函数。输出层的输出个数为 1。两个Dense实例均不使用偏差,且设flatten=False。其中函数 \(a\) 定义里向量 \(\boldsymbol{v}\) 的长度是一个超参数,即attention_size

In [8]:
def attention_model(attention_size):
    model = nn.Sequential()
    model.add(nn.Dense(attention_size, activation='tanh', use_bias=False,
                       flatten=False),
              nn.Dense(1, use_bias=False, flatten=False))
    return model

注意力模型的输入包括查询项、键项和值项。设编码器和解码器的隐藏单元个数相同。这里的查询项为解码器在上一时间步的隐藏状态,形状为(批量大小,隐藏单元个数);键项和值项均为编码器在所有时间步的隐藏状态,形状为(时间步数,批量大小,隐藏单元个数)。注意力模型返回当前时间步的背景变量,形状为(批量大小,隐藏单元个数)。

In [9]:
def attention_forward(model, enc_states, dec_state):
    # 将解码器隐藏状态广播到跟编码器隐藏状态形状相同后进行连结。
    dec_states = nd.broadcast_axis(
        dec_state.expand_dims(0), axis=0, size=enc_states.shape[0])
    enc_and_dec_states = nd.concat(enc_states, dec_states, dim=2)
    e = model(enc_and_dec_states)  # 形状为(时间步数,批量大小,1)。
    alpha = nd.softmax(e, axis=0)  # 在时间步维度做 softmax 运算。
    return (alpha * enc_states).sum(axis=0)  # 返回背景变量。

在下面的例子中,编码器的时间步数为 10,批量大小为 4,编码器和解码器的隐藏单元个数均为 8。注意力模型返回一个小批量的背景向量,每个背景向量的长度等于编码器的隐藏单元个数。因此输出的形状为(4,8)。

In [10]:
seq_len, batch_size, num_hiddens = 10, 4, 8
model = attention_model(10)
model.initialize()
enc_states = nd.zeros((seq_len, batch_size, num_hiddens))
dec_state = nd.zeros((batch_size, num_hiddens))
attention_forward(model, enc_states, dec_state).shape
Out[10]:
(4, 8)

含注意力机制的解码器

我们直接将编码器在最终时间步的隐藏状态作为解码器的初始隐藏状态。这要求编码器和解码器的循环神经网络使用相同的层数和隐藏单元个数。

在解码器的前向计算中,我们先通过前面介绍的注意力模型计算得到当前时间步的背景向量。由于解码器的输入来自输出语言的词索引,我们将输入通过词嵌入层得到特征表达,然后和背景向量在特征维连结。我们将连接后的结果与上一时间步的隐藏状态通过门控循环单元计算出当前时间步的输出与隐藏状态。最后,我们将输出通过全连接层变换为有关各个输出词的预测,形状为(批量大小,输出词典大小)。

In [11]:
class Decoder(nn.Block):
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 attention_size, drop_prob=0, **kwargs):
        super(Decoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.attention = attention_model(attention_size)
        self.rnn = rnn.GRU(num_hiddens, num_layers, dropout=drop_prob)
        self.out = nn.Dense(vocab_size, flatten=False)

    def forward(self, cur_input, state, enc_states):
        # 使用注意力机制计算背景向量。
        c = attention_forward(self.attention, enc_states, state[0][-1])
        # 将嵌入后的输入和背景向量在特征维连结。
        input_and_c = nd.concat(self.embedding(cur_input), c, dim=1)
        # 为输入和背景向量的连结增加时间步维,时间步个数为 1。
        output, state = self.rnn(input_and_c.expand_dims(0), state)
        # 移除时间步维,输出形状为(批量大小,输出词典大小)。
        output = self.out(output).squeeze(axis=0)
        return output, state

    def begin_state(self, enc_state):
        # 直接将编码器最终时间步的隐藏状态作为解码器的初始隐藏状态。
        return enc_state

训练

我们先实现batch_loss函数计算一个小批量的损失。解码器在最初时间步的输入是特殊字符BOS。之后,解码器在某时间步的输入为样本输出序列在上一时间步的词,即强制教学。此外,同“Word2vec 的实现”一节中的实现一样,我们在这里也使用掩码变量避免填充项对损失函数计算的影响。

In [12]:
def batch_loss(encoder, decoder, X, Y, loss):
    batch_size = X.shape[0]
    enc_state = encoder.begin_state(batch_size=batch_size)
    enc_outputs, enc_state = encoder(X, enc_state)
    # 初始化解码器的隐藏状态。
    dec_state = decoder.begin_state(enc_state)
    # 解码器在最初时间步的输入是 BOS。
    dec_input = nd.array([out_vocab.token_to_idx[BOS]] * batch_size)
    # 我们将使用掩码变量 mask 来忽略掉标签为填充项 PAD 的损失。
    mask, num_not_pad_tokens = nd.ones(shape=(batch_size,)), 0
    l = nd.array([0])
    for y in Y.T:
        dec_output, dec_state = decoder(dec_input, dec_state, enc_outputs)
        l = l + (mask * loss(dec_output, y)).sum()
        dec_input = y  # 使用强制教学。
        num_not_pad_tokens += mask.sum().asscalar()
        # 当遇到 EOS 时,序列后面的词将均为 PAD,相应位置的掩码设成 0。
        mask = mask * (y != out_vocab.token_to_idx[EOS])
    return l / num_not_pad_tokens

在训练函数中,我们需要同时迭代编码器和解码器的模型参数。

In [13]:
def train(encoder, decoder, dataset, lr, batch_size, num_epochs):
    encoder.initialize(init.Xavier(), force_reinit=True)
    decoder.initialize(init.Xavier(), force_reinit=True)
    enc_trainer = gluon.Trainer(encoder.collect_params(), 'adam',
                                {'learning_rate': lr})
    dec_trainer = gluon.Trainer(decoder.collect_params(), 'adam',
                                {'learning_rate': lr})
    loss = gloss.SoftmaxCrossEntropyLoss()
    data_iter = gdata.DataLoader(dataset, batch_size, shuffle=True)
    for epoch in range(num_epochs):
        l_sum = 0
        for X, Y in data_iter:
            with autograd.record():
                l = batch_loss(encoder, decoder, X, Y, loss)
            l.backward()
            enc_trainer.step(1)
            dec_trainer.step(1)
            l_sum += l.asscalar()
        if (epoch + 1) % 10 == 0:
            print("epoch %d, loss %.3f" % (epoch + 1, l_sum / len(data_iter)))

接下来创建模型实例并设置超参数。然后我们就可以训练模型了。

In [14]:
embed_size, num_hiddens, num_layers = 64, 64, 2
attention_size, drop_prob, lr, batch_size, num_epochs = 10, 0.5, 0.01, 2, 50
encoder = Encoder(len(in_vocab), embed_size, num_hiddens, num_layers,
                  drop_prob)
decoder = Decoder(len(out_vocab), embed_size, num_hiddens, num_layers,
                  attention_size, drop_prob)
train(encoder, decoder, dataset, lr, batch_size, num_epochs)
epoch 10, loss 0.563
epoch 20, loss 0.215
epoch 30, loss 0.147
epoch 40, loss 0.095
epoch 50, loss 0.060

预测

“束搜索”一节中我们介绍了三种方法来生成解码器在每个时间步的输出。这里我们实现最简单的贪婪搜索。

In [15]:
def translate(encoder, decoder, input_seq, max_seq_len):
    in_tokens = input_seq.split(' ')
    in_tokens += [EOS] + [PAD] * (max_seq_len - len(in_tokens) - 1)
    enc_input = nd.array([in_vocab.to_indices(in_tokens)])
    enc_state = encoder.begin_state(batch_size=1)
    enc_output, enc_state = encoder(enc_input, enc_state)
    dec_input = nd.array([out_vocab.token_to_idx[BOS]])
    dec_state = decoder.begin_state(enc_state)
    output_tokens = []
    for _ in range(max_seq_len):
        dec_output, dec_state = decoder(dec_input, dec_state, enc_output)
        pred = dec_output.argmax(axis=1)
        pred_token = out_vocab.idx_to_token[int(pred.asscalar())]
        if pred_token == EOS:  # 当任一时间步搜索出 EOS 符号时,输出序列即完成。
            break
        else:
            output_tokens.append(pred_token)
            dec_input = pred
    return output_tokens

简单测试一下模型。输入法语句子“ils regardent.”,翻译后的英语句子应该是“they are watching.”。

In [16]:
input_seq = 'ils regardent .'
translate(encoder, decoder, input_seq, max_seq_len)
Out[16]:
['they', 'are', 'watching', '.']

评价翻译结果

评机器翻译结果通常使用 BLEU(Bilingual Evaluation Understudy)[1]。对于模型预测序列中任意的子序列,BLEU 考察这个子序列是否出现在标签序列中。

具体来说,设词数为 \(n\) 的子序列的精度为 \(p_n\)。它是预测序列与标签序列匹配词数为 \(n\) 的子序列的数量与预测序列中词数为 \(n\) 的子序列的数量之比。举个例子,假设标签序列为 \(A\)\(B\)\(C\)\(D\)\(E\)\(F\),预测序列为 \(A\)\(B\)\(B\)\(C\)\(D\)。那么 \(p_1 = 4/5,\ p_2 = 3/4,\ p_3 = 1/3,\ p_4 = 0\)。设 \(len_{\text{label}}\)\(len_{\text{pred}}\) 分别为标签序列和预测序列的词数。那么,BLEU 的定义为

\[\exp\left(\min\left(0, 1 - \frac{len_{\text{label}}}{len_{\text{pred}}}\right)\right) \prod_{n=1}^k p_n^{1/2^n},\]

其中 \(k\) 是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU 为 1。

因为匹配较长子序列比匹配较短子序列更难,BLEU 对匹配较长子序列的精度赋予了更大权重。例如当 \(p_n\) 固定在 0.5 时,随着 \(n\) 的增大,\(0.5^{1/2} \approx 0.7, 0.5^{1/4} \approx 0.84, 0.5^{1/8} \approx 0.92, 0.5^{1/16} \approx 0.96\)。另外,模型预测较短序列往往会得到较高 \(p_n\) 值。因此,上式中连乘项前面的系数是为了惩罚较短的输出。举个例子,当 \(k=2\) 时,假设标签序列为 \(A\)\(B\)\(C\)\(D\)\(E\)\(F\),而预测序列为 \(A\)\(B\)。虽然 \(p_1 = p_2 = 1\),但惩罚系数 \(\exp(1-6/2) \approx 0.14\),因此 BLEU 也接近 0.14。

下面实现 BLEU 的计算。

In [17]:
def bleu(pred_tokens, label_tokens, k):
    len_pred, len_label = len(pred_tokens), len(label_tokens)
    score = math.exp(min(0, 1 - len_label / len_pred))
    for n in range(1, k + 1):
        num_matches = 0
        for i in range(len_pred - n + 1):
            if ' '.join(pred_tokens[i: i + n]) in ' '.join(label_tokens):
                num_matches += 1
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
    return score

并定义一个辅助打印函数。

In [18]:
def score(input_seq, label_seq, k):
    pred_tokens = translate(encoder, decoder, input_seq, max_seq_len)
    label_tokens = label_seq.split(' ')
    print('bleu %.3f, predict: %s' % (bleu(pred_tokens, label_tokens, k),
                                      ' '.join(pred_tokens)))

预测正确是分数为 1。

In [19]:
score('ils regardent .', 'they are watching .', k=2)
bleu 1.000, predict: they are watching .

测试一个不在训练集中的样本。

In [20]:
score('ils sont canadiens .', 'they are canadian .', k=2)
bleu 0.658, predict: they are russian .

小结

  • 我们可以将编码器—解码器和注意力机制应用于机器翻译中。
  • BLEU 可以用来评价翻译结果。

练习

  • 如果编码器和解码器的隐藏单元个数不同或层数不同,我们该如何改进解码器的隐藏状态初始化方法?
  • 在训练中,将强制教学替换为使用解码器在上一时间步的输出作为解码器在当前时间步的输入。结果有什么变化吗?
  • 试着使用更大的翻译数据集来训练模型,例如 WMT [2] 和 Tatoeba Project [3]。

扫码直达讨论区

image0

参考文献

[1] Papineni, K., Roukos, S., Ward, T., & Zhu, W. J. (2002, July). BLEU: a method for automatic evaluation of machine translation. In Proceedings of the 40th annual meeting on association for computational linguistics (pp. 311-318). Association for Computational Linguistics.

[2] WMT. http://www.statmt.org/wmt14/translation-task.html

[3] Tatoeba Project. http://www.manythings.org/anki/