1. 机器翻译和数据集
目前为止,我们已经知晓如何将循环神经网络应用于语言模型:在给定之前token的情况下预测下一个token。现在学习一种新的应用模式,机器翻译,它的输出不再是一个token而是一个token列表。
机器翻译(MT)是指将一段文本从一种语言翻译为另一种语言。用神经网络解决这个问题通常称为神经机器翻译(NMT)。与语料库仅包含一种语言的语言模型相比,机器翻译的数据集至少含有两种语言,即源语言和目标语言。 将源语言的每一个句子对应翻译成目标语言。因此,机器翻译的数据预处理和语言模型的处理方式有所不同。
from mxnet import np, npx, gluon
from d2l import mxnet as d2l
import plotly.express as px
import pandas as pd
import os
npx.set_np()
ctx = npx.gpu() if npx.num_gpus() > 0 else npx.cpu()
1.1 读取和预处理数据集
下载数据集,数据集内容源语言为英语,目标语言为法语,其实这个是可以互换的,反正就是英语和法语的翻译数据集,每一行都是一句英语一句法语通过tab隔开,两个语种语义相同,互为翻译。
d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip','94646ad1522d915e7b0f9296181140edcf86a4f5')def read_data_nmt():data_dir = d2l.download_extract('fra-eng')with open(os.path.join(data_dir, 'fra.txt'), 'r') as f:return f.read()raw_text = read_data_nmt()
print(raw_text[0:106])
我们对原始文本数据执行几个预处理步骤:
- 忽略大小写
- 使用空格替换UTF-8不间断空格
- 在单词和标点符号之间添加空格
def preprocess_nmt(text):def no_space(char, prev_char):return char in set(',.!') and prev_char != ' 'text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()out = [ ' ' + char if i!=0 and no_space(char, text[i-1]) else char for i, char in enumerate(text)]return ''.join(out)text = preprocess_nmt(raw_text)
print(text[:95])
1.2 转化为token
跟将字符设置为token不同,这里是将单词和标点符号设置为token。现在编写一个函数目的是标记文本数据以返回source和target的token列表。为了训练更快,我们获取num_examples数量的句子的样本。
def tokenize_nmt(text, num_examples=None):source, target = [], []for i, line in enumerate(text.split('\n')):if num_examples and i > num_examples:breakparts = line.split('\t')if len(parts) == 2:source.append(parts[0].split(' '))target.append(parts[1].split(' '))return source, targetsource, target = tokenize_nmt(text)
source[0:3], target[0:3]
我们可视化了每个句子的标记数量的直方图。可以看出,一个句子平均包含5个令牌,大多数句子少于10个令牌。
df = pd.DataFrame(data = [[len(l) for l in source], [len(l) for l in target]], index=['source', 'target']).Tfig = px.histogram(df, width=800, height=480)
fig.update_layout(xaxis_title='freqs')
1.3 词汇
由于源语言中的token可能与目标语言中的token不同,所以需要为它们中的每个token建立词汇表。我们使用单词而不是字符作为token,因此词汇表的数量大大增加。这里,数量小于3会映射到<UNK>
token,除此之外,我们还需要其他特殊标记,例如填充和句子开头。
src_vocab = d2l.Vocab(source, min_freq=3, reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab)# 9140
1.4 加载数据集
在语言模型中, 每一个示例都是语料库中长度为 num_steps
的序列, 它可能是句子的一部分, 也可能跨多个句子。在机器翻译中, 示例应该包含一对源语言和目标语言. 两种语言的句子长度可能不相同, 但是我们需要相同长度的示例来组成一个小批量。
解决这个问题的一种方法是,如果句子的长度大于 num_steps
就修剪其长度, 否则使用 <pad>进行填充。因此可以将任何句子固定为num_steps
长度。
def truncate_pad(line, num_steps, padding_token):if len(line) > num_steps:return line[:num_steps] # 长度大于修剪return line + [padding_token] * (num_steps - len(line)) # 长度小于填充truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])# [47, 4, 1, 1, 1, 1, 1, 1, 1, 1]
通过上面的函数我们可以将句子列表转换为索引数组。我们还记录了没有填充token的每一个句子的长度,称为有效长度,某些模型可能会使用该长度。另外,使用特殊符号 “<bos>”和 “<eos>” 标记添加到目标句子,以便我们的模型直到用于开始和结束预测的信号。然后基于这些数组构造微型批处理器。
def build_array(lines, vocab, num_steps, is_source):lines = [vocab[l] for l in lines]if not is_source:lines = [[vocab['<bos>']] + l + [vocab['<eos>']] for l in lines]array = np.array([truncate_pad(l, num_steps, vocab['<pad>']) for l in lines])valid_len = (array != vocab['<pad>']).sum(axis=1)return array, valid_len
1.5 获取处理后数据
使用上面的函数编写一个用于返回数据迭代器,以及源语言和目标语言词汇表的函数。
def load_data_nmt(batch_size, num_steps, num_examples=1000):text = preprocess_nmt(read_data_nmt())source, target = tokenize_nmt(text, num_examples)src_vocab = d2l.Vocab(source, min_freq=3, reserved_tokens=['<pad>', '<bos>', '<eos>'])tgt_vocab = d2l.Vocab(target, min_freq=3, reserved_tokens=['<pad>', '<bos>', '<eos>'])src_array, src_valid_len = build_array(source, src_vocab, num_steps, True)tgt_array, tgt_valid_len = build_array(target, tgt_vocab, num_steps, False)data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)data_iter = d2l.load_array(data_arrays, batch_size)return src_vocab, tgt_vocab, data_iter
读取第一个batch。
src_vocab, tgt_vocab, train_iter = load_data_nmt(batch_size=2, num_steps=8)
for X, X_vlen, Y, Y_vlen in train_iter:print('X:', X.astype('int32'))print('valid lengths for X:', X_vlen)print('Y:', Y.astype('int32'))print('valid lengths for Y:', Y_vlen)break
2. Encoder-Decoder架构
Encoder-Decoder是一种神经网络模式。这个体系分为两个部分:编码器和解码器。编码器的作用是以输入编码为状态,这个状态通常包含多个张量,然后将状态传递到解码器以生成输出。在机器翻译中,编码器将原句,如“hello world”转换为状态(如向量),以捕获其语义信息。然后,解码器使用这个状态生成翻译后的目标句子。
2.1 编码器
编码器是一个普通的神经网络,它接受输入以返回输出。
from mxnet.gluon import nn
class Encoder(nn.Block):def __init__(self, **kwargs):super(Encoder, self).__init__(**kwargs)def forward(self, X, *args):raise NotImplementedError
2.2 解码器
解码器具有一种附加方法init_state, 可以使用可能的附加信息(例如输入有效长度)来解析编码器的输出,以返回所需的状态。在forward方法中,解码器同时接收两个输入,例如目标语句和状态。如果编码器包含RNN层,它将返回输出,并修改状态。
class Decoder(nn.Block):"""encoder-decoder模型的解码器接口"""def __init__(self, **kwargs):super(Decoder, self).__init__(**kwargs)def init_state(self, enc_outputs, *args):raise NotImplementedErrordef forward(self, X, state):raise NotImplementedError
2.3 模型
Encoder-Decoder模型包含编码器和解码器。我们执行forward方法训练。它需要编码器输入和解码器输入,以及可选的附加参数。在计算期间,它首先计算编码器输出来初始化解码器的状态,然后返回解码器输出。
class EncoderDecoder(nn.Block):"""encoder-decoder模型的基础类"""def __init__(self, encoder, decoder, **kwargs):super(EncoderDecoder, self).__init__(**kwargs)self.encoder = encoderself.decoder = decoderdef forward(self, enc_X, dec_X, *args):enc_outputs = self.encoder(enc_X, *args)dec_state = self.decoder.init_state(enc_outputs, *args)return self.decoder(dec_X, dec_state)
3. seq2seq模型
seq2seq模型以encoder-decoder模型为基础通过输入序列生成输出序列,如下图。编码器和解码器都是用RNN来处理不定长的序列。通过将编码器的隐藏状态直接用于初始化为解码器的隐藏状态的方式将信息从编码器传递到解码器。
编码器和解码器中的层:
3.1 创建编码器
seq2seq可以通过编码将不定长的输入转化为为固定长度的向量c\mathbf{c}c。我们通常在编码器中使用RNN层。假设我们有一个输入序列 x1,…,xTx_1, \ldots, x_Tx1?,…,xT?, 在 xtx_txt? 位置是 ttht^\mathrm{th}tth 单词. 在时间步ttt, RNN有两个向量作为输入: xtx_txt?转换的特征向量xt\mathbf{x}_txt?以及最后时间步的隐藏状态ht?1\mathbf{h}_{t-1}ht?1?。让我们用函数fff表示转换RNN隐藏状态:
ht=f(xt,ht?1)\mathbf{h}_t = f (\mathbf{x}_t, \mathbf{h}_{t-1}) ht?=f(xt?,ht?1?)
接下来, 编码器捕获所有隐藏状态的信息,并将其通过qqq编码为上下文向量c\mathbf{c}c:
c=q(h1,…,hT)\mathbf{c} = q (\mathbf{h}_1, \ldots, \mathbf{h}_T) c=q(h1?,…,hT?)
举个例子我们设定q的功能是指定向量为最后一个隐藏状态: q(h1,…,hT)=hTq (\mathbf{h}_1, \ldots, \mathbf{h}_T) = \mathbf{h}_Tq(h1?,…,hT?)=hT?, 那么上下文向量则为 hT\mathbf{h}_ThT?。
上面提到的是单向RNN的情况,其中每一个时间步的隐藏状态仅取决于之前的时间步。除此之外,还可以使用其他形式的RNN(如, GRU, LSTM, Bi-RNN)对输入进行编码。
现在让我们实现seq2seq编码器。这里通过将输入语言的词索引通过词嵌入层获得词特征向量。这些特征向量输入到多层LSTM中。编码器的输入是一批序列, 是一个形状为 (批量大小, 序列长度)的二维张量。编码器同时输出LSTM的输出即所有时间步的隐藏状态以及最后时间步的记忆单元。
class Seq2SeqEncoder(d2l.Encoder):def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,dropout=0, **kwargs):super(Seq2SeqEncoder, self).__init__(**kwargs)self.embedding = nn.Embedding(vocab_size, embed_size)self.rnn = rnn.LSTM(num_hiddens, num_layers, dropout=dropout)def forward(self, X, *args):# 输入X的形状 (`batch_size`, `seq_len`, `embed_size`)X = self.embedding(X)# 交换第一和第二个纬度,因为需要使用seq_len作为时间步X = X.swapaxes(0, 1)state = self.rnn.begin_state(batch_size=X.shape[1], ctx=X.ctx)out, state = self.rnn(X, state)# 输出的形状为: (`seq_len`, `batch_size`, `num_hiddens`)# 状态的形状为: (`num_layers`, `batch_size`, `num_hiddens`)return out, state
接下来,我们创建一个批量大小为4时间步长为7的序列作为输入。我们假设 LSTM 单元的隐藏层数为2以及隐藏单元数为16。编码器对输入进行正向计算后返回的输出形状为 (时间步数, 批量大小, 隐藏单元数)。 最后时间步门控循环单元的多层隐藏状态的形状为 (隐藏层数, 批量大小, 隐藏单元树)。 对于门控循环单元, state
列表只包含一个元素——隐藏状态。如果使用长短记忆, state
列表还包含记忆单元。
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,num_layers=2)
encoder.initialize()
X = np.zeros((4, 7))
output, state = encoder(X)
output.shape# (7, 4, 16)
由于这里使用的是LSTM,state
列表中同时包含隐藏状态和形状相同的存储单元。但是,如果使用GRU,那么是包含隐藏状态。
len(state), state[0].shape, state[1].shape# (2, (2, 4, 16), (2, 4, 16))
3.2 创建解码器
就像我刚刚介绍的, 上下文向量 c\mathbf{c}c 对整个输入序列中的信息进行编码 x1,…,xTx_1, \ldots, x_Tx1?,…,xT?。假定训练集的给定输出为 y1,…,yT′y_1, \ldots, y_{T'}y1?,…,yT′?。对于时间步 t′t't′, 输出的条件概率 yt′y_{t'}yt′? 取决于先前的输出顺序 y1,…,yt′?1y_1, \ldots, y_{t'-1}y1?,…,yt′?1?和上下文向量 c\mathbf{c}c:
P(yt′∣y1,…,yt′?1,c).P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \mathbf{c}).P(yt′?∣y1?,…,yt′?1?,c).
因此, 我们可以使用另一个RNN作为解码器。 对于时间步 t′t't′, 解码器需要通过三个输入更新 st′\mathbf{s}_{t'}st′?: yt′?1y_{t'-1}yt′?1?的特征向量 yt′?1\mathbf{y}_{t'-1}yt′?1?, 上下文向量c\mathbf{c}c以及最终时间步的隐藏状态st′?1\mathbf{s}_{t'-1}st′?1?。通过函数表示解码器中RNN的隐藏状态的转换函数ggg:
st′=g(yt′?1,c,st′?1)\mathbf{s}_{t'} = g(\mathbf{y}_{t'-1}, \mathbf{c}, \mathbf{s}_{t'-1}) st′?=g(yt′?1?,c,st′?1?)
在实现解码器时,我们直接使用编码器最后一步的隐藏状态作为编码器的初始隐藏状态。 这要求编码器和解码器在RNN的层数和隐藏单元数上保持一致。
解码器中LSTM的前向计算与编码器相似。 不同的是解码器在LSTM层之后链接了一个隐藏单元数为词汇量大小的全连接层。全连接层用来预测每个单侧的置信度得分。
class Seq2SeqDecoder(d2l.Decoder):def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,dropout=0, **kwargs):super(Seq2SeqDecoder, self).__init__(**kwargs)self.embedding = nn.Embedding(vocab_size, embed_size)self.rnn = rnn.LSTM(num_hiddens, num_layers, dropout=dropout)self.dense = nn.Dense(vocab_size, flatten=False)# 编码器的state作为初始def init_state(self, enc_outputs, *args):return enc_outputs[1] def forward(self, X, state):X = self.embedding(X).swapaxes(0, 1)out, state = self.rnn(X, state)# 为了简化loss计算将批次回到第一纬度out = self.dense(out).swapaxes(0, 1)return out, state
创建一个超参数和编码器一样的解码器,输出的形状为:(批量大小,序列长度,词汇量大小);
decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
decoder.initialize()
state = decoder.init_state(encoder(X))
out, state = decoder(X, state)
out.shape, len(state), state[0].shape, state[1].shape# ((4, 7, 10), 2, (2, 4, 16), (2, 4, 16))
3.3 损失函数
每一步, 解码器都会输出一个词汇量置信度分数向量来预测单词。与语言模型相似, 我们可以使用 softmax 来获取概率并且通过交叉熵损失函数计算损失值。我们填充目标句子使他们长度相同,但是我们不需要计算填充部分的损失。为了过滤掉这些条目的损失使用了一个名为SequenceMask
的函数:它可以屏蔽第一个纬度(axis=0
)或是第二个纬度 (axis=1
). 如果选择屏蔽第二个纬度, 输入 len
向量 和一个二维输入 X
, 将对所有iii 进行X[i, len[i]:] = 0
操作。
X = np.array([[1, 2, 3], [4, 5, 6]])
npx.sequence_mask(X, np.array([1, 2]), True, axis=1)
同样适用于n为张量,而且可以通过value=-1
指定值覆盖。
X = np.ones((2, 3, 4))
npx.sequence_mask(X, np.array([1, 2]), True, value=-1, axis=1)
这里创建交叉熵损失函数的屏蔽版本。每个Gluon损失函数都允许指定每个示例的权重,默认情况下为1s。对于每个要删除的示例,我们都可以使用零权重。我们的自定义损失函数接受一个附加valid_len参数来忽略每个序列中的某些需要屏蔽的元素。
class MaskedSoftmaxCELoss(gluon.loss.SoftmaxCELoss):# 预测形状为: (`batch_size`, `seq_len`, `vocab_size`)# 标签形状为: (`batch_size`, `seq_len`)# `valid_len`形状为: (`batch_size`, )def forward(self, pred, label, valid_len):# 权重形状为 (batch_size, seq_len, 1)weights = np.expand_dims(np.ones_like(label), axis=-1)weights = npx.sequence_mask(weights, valid_len, True, axis=1)return super(MaskedSoftmaxCELoss, self).forward(pred, label, weights)
测试一下功能,创建三个相同序列,第一个序列保留4个元素,第二个保留2个,第三个保留1一个。那么第一个的损失应该是第二个的2被,而最后一个损失应该为0:
loss = MaskedSoftmaxCELoss()
loss(np.ones((3, 4, 10)), np.ones((3, 4)), np.array([4, 2, 0]))# array([2.3025851, 1.1512926, 0. ])
3.4 训练
在训练过程中,如果目标序列长度为n, 我们先将n-1长度输入到解码器,最后一个n-1作为标签。
import time
def train(model, data_iter, lr, num_epochs, device):model.initialize(init.Xavier(), force_reinit=True, ctx=device)trainer = gluon.Trainer(model.collect_params(), 'adam', {
'learning_rate': lr})loss = MaskedSoftmaxCELoss()loss_sum, tokens_sum, loss_lst, epochs_lst = 0, 0, [], []start = time.time()for epoch in range(1, num_epochs + 1):for batch in data_iter:X, X_vlen, Y, Y_vlen = [x.as_in_ctx(device) for x in batch]Y_input, Y_label, Y_vlen = Y[:, :-1], Y[:, 1:], Y_vlen-1with autograd.record():Y_hat, _ = model(X, Y_input, X_vlen, Y_vlen)l = loss(Y_hat, Y_label, Y_vlen)l.backward()d2l.grad_clipping(model, 1)num_tokens = Y_vlen.sum()trainer.step(num_tokens)loss_sum += l.sum()tokens_sum += num_tokensif epoch % 10 == 0:loss_lst.append(loss_sum / tokens_sum)epochs_lst.append(epoch+1)print(f'loss {loss_sum / tokens_sum:.3f}, {tokens_sum / (time.time()-start):.1f} 'f'tokens/sec on {str(device)}')fig = px.line(pd.DataFrame([epochs_lst, loss_lst], index=['epoch', 'loss']).T, x='epoch', y='loss', width=800, height=480)fig.show()
使用一些超参数然后进行训练:
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.0
batch_size, num_steps = 64, 10
lr, num_epochs = 0.005, 300src_vocab, tgt_vocab, train_iter = d2l.load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
model = d2l.EncoderDecoder(encoder, decoder)
train(model, train_iter, lr, num_epochs, ctx)# loss 0.075, 17570.5 tokens/sec on gpu(0)
3.5 预测
我们使用最简单的实现方法——贪婪搜索,来生成一个输出序列。如下图所示,在预测过程中, 我们将相同 “<bos>” token作为起始输入到解码器中。但是之后的输入则为前一步的预测token。
def predict(model, src_sentence, src_vocab, tgt_vocab, num_steps, device):src_tokens = src_vocab[src_sentence.lower().split(' ')]enc_valid_len = np.array([len(src_tokens)], ctx=device)src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])enc_X = np.array(src_tokens, ctx=device)# 添加一个batch_size的纬度enc_outputs = model.encoder(np.expand_dims(enc_X, axis=0), enc_valid_len)dec_state = model.decoder.init_state(enc_outputs, enc_valid_len)dec_X = np.expand_dims(np.array([tgt_vocab['<bos>']], ctx=device), axis=0)predict_tokens = []for _ in range(num_steps):Y, dec_state = model.decoder(dec_X, dec_state)# 最高分的预测将作为下一次的输入dec_X = Y.argmax(axis=2)py = dec_X.squeeze(axis=0).astype('int32').item()if py == tgt_vocab['<eos>']:breakpredict_tokens.append(py)return ' '.join(tgt_vocab.to_tokens(predict_tokens))
通过几个例子测试一下:
for sentence in ['Go .', 'Wow !', "I'm OK .", 'I won !']:print(sentence + ' => ' + predict(model, sentence, src_vocab, tgt_vocab, num_steps, ctx))
4. 参考
https://d2l.ai/chapter_recurrent-modern/seq2seq.html
5. 代码
github