
Part.0
前言
在《动手学深度学习》(英文名Dive into Deep Learning)一书中,编码器-解码器模型的代码相对较难理解,甚至堪称全书中最难理解的部分。除了编码器-解码器模型本身的复杂性,代码的可读性欠佳也是原因之一。本文试图对这部分代码做一个详细的释读。

编码器-解码器模型的代码大致可以分为两部分:抽象基类Encoder/Decoder/EncoderDecoder,以及用于序列到序列(sequence to sequence,seq2seq)学习的派生类Seq2SeqEncoder/Seq2SeqDecoder及相关代码。
Part.1
基类
基类的代码如下:
from torch import nn
classEncoder(nn.Module):
"""编码器-解码器架构的基本编码器接口"""
def__init__(self, **kwargs):
super(Encoder, self).__init__(**kwargs)
defforward(self, X, *args):
raise NotImplementedError
classDecoder(nn.Module):
"""编码器-解码器架构的基本解码器接口"""
def__init__(self, **kwargs):
super(Decoder, self).__init__(**kwargs)
definit_state(self, enc_outputs, *args):
raise NotImplementedError
defforward(self, X, state):
raise NotImplementedError
classEncoderDecoder(nn.Module):
"""编码器-解码器架构的基类"""
def__init__(self, encoder, decoder, **kwargs):
super(EncoderDecoder, self).__init__(**kwargs)
self.encoder = encoder
self.decoder = decoder
defforward(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)这部分代码比较简单,只定义了编码器-解码器相关接口,具体逻辑将在派生类中实现。三个基类都派生自PyTorch库的nn.Module类,这意味着其forward()方法将在类的__call__()方法中被调用。
需要注意的是EncoderDecoder.forward()方法。它接受两个参数:enc_X(编码器输入)和dec_X(解码器输入)。编码器输入与其他模型的输入一样,是特征-标签(feature-label)对中的特征。
解码器输入则难理解一些,在训练时,它是原始输出序列(即词元标签),这就是“强制教学”(teacher forcing),相关细节将在后文中提到。那么,在预测时,编码器输入是什么呢?其实在预测时,根本不会调用EncoderDecoder.forward(),只会单独调用Encoder.forward()和Decoder.forward()。这是容易造成误解的地方之一。
EncoderDecoder.forward()的逻辑如下:
1.将enc_X(词元特征)输入编码器,得到enc_outputs(编码器输出),其中包含标签和编码器状态(即循环神经网络中的隐状态)。
2.将enc_outputs用于初始化解码器,得到dec_state(解码器状态)。
3.将dec_X(解码器输入,即词元标签)和dec_state一起输入解码器,返回解码器输出(含标签及更新后的解码器状态,后文会提到)。
EncoderDecoder.forward()的每一次调用都对应一个特定的特征-标签对。特征或标签由词元列表组成,包含多个时间步的词元。时间步循环在Encoder.forward()和Decoder.forward()内部完成。在第2步中得到的解码器状态,实际上是解码器的初始状态,它会在时间步循环中被更新。
Part.2
Seq2SeqEncoder
编码器Seq2SeqEncoder的代码如下:
classSeq2SeqEncoder(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 = nn.GRU(embed_size, num_hiddens, num_layers,
dropout=dropout)
defforward(self, X, *args):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X)
# 在循环神经网络模型中,第一个轴对应于时间步
X = X.permute(1, 0, 2)
# 如果未提及状态,则默认为0
output, state = self.rnn(X)
# output的形状:(num_steps,batch_size,num_hiddens)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, stateSeq2SeqEncoder是Encoder的派生类。
__init__()方法中创建了一个嵌入层和一个基于门控循环单元的循环层。如原文中所说:“嵌入层的权重是一个矩阵,其行数等于输入词表的大小(vocab_size),其列数等于特征向量的维度(embed_size)。对于任意输入词元的索引i,嵌入层获取权重矩阵的第i行(从0开始)以返回其特征向量。”
在forward()方法中,首先将输入X转换为特征向量。注意,输入时X的形状是(批量大小,时间步数)。经过嵌入层转换后,X的形状是(批量大小,时间步数,特征维度)。也就是说,嵌入层将单个数值(在本例中为词元索引)转换为长度等于特征维度的向量。这和独热编码有相似之处,区别在于独热编码会将数值转换为长度等于词表大小、值为1或0的向量。
随后,通过permute()方法将X的第0维(批量轴)和第1维(时间轴)交换。注意,维度索引是从0开始的。交换后X的形状是(时间步数,批量大小,特征维度)。之所以要进行这个交换,是因为在时间步循环中,会对同一个小批量中的多个特征(即词元列表)进行统一计算,以优化性能。后文会提到,在解码器输出最终结果之前,还要执行逆交换操作。
接下来,调用rnn(循环层)对X进行计算。例子中的循环层是PyTorch提供的,但书中9.5.4节提供了循环神经网络从零开始实现的代码,可供参考。(实际上,PyTorch提供的循环层是基于门控循环单元的,因此更接近9.1.2节的gru()函数,但使用rnn()的代码更易理解。)
rnn()的代码如下:
defrnn(inputs, state, params):
# inputs的形状:(时间步数量,批量大小,词表大小)
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
# X的形状:(批量大小,词表大小)
for X in inputs:
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
Y = torch.mm(H, W_hq) + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H,)代码相对比较直观:先通过输入X和上一个隐状态计算新的隐状态H,再根据新的隐状态计算输出Y。
inputs(即Seq2SeqEncoder.forward()中的X)的形状为(批量大小,时间步数,特征维度)。在对inputs的遍历中,X的形状为(时间步数,特征维度),它表示某个特定批量中包含多个时间步的特征(词元的特征向量)。
输出包括两个值。第一个值是将outputs连结起来得到的,outputs包含多个时间步的Y值(预测值)。在本例中,outputs是一个词元(字符)列表,所以第一个值是一个字符串。第二个值是一个包含变量H的元组,H是最后时间步的隐状态。
回到Seq2SeqEncoder.forward(),output和state对应rnn()返回的两个值。output的形状为(时间步数,批量大小,隐藏单元数),state的形状为(隐藏层数,批量大小,隐藏单元数)。
注意,自定义版本的rnn()返回的state永远只包含H这一个值,但PyTorch提供的循环层返回的state包含多个隐状态,对应多个隐藏层。由于只返回最后时间步的隐状态,因此state中没有时间步数的维度,但对于有多个隐藏层的网络,每个隐藏层都有自己的隐状态。
Seq2SeqEncoder的测试代码如下:
# 测试编码器
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
num_layers=2)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
print(output.shape, state.shape)该测试代码首先创建一个Seq2SeqEncoder实例,词表大小为10,特征维度为8,隐藏单元数为16,隐藏层数为2。然后创建输入张量X,批量大小为4,时间步数为7。由于是测试代码,没有实际数据,因此值均设为0。
将X送入编码器,返回预测值output和隐状态state,它们的形状分别为:
torch.Size([7, 4, 16]) torch.Size([2, 4, 16])output的形状为(时间步数,批量大小,隐藏单元数),因为已经经过permute()交换了批量轴和时间轴。state的形状为(隐藏层数,批量大小,隐藏单元数)。
Part.3
Seq2SeqDecoder
解码器Seq2SeqDecoder的代码如下:
classSeq2SeqDecoder(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 = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
self.dense = nn.Linear(num_hiddens, vocab_size)
definit_state(self, enc_outputs, *args):
return enc_outputs[1]
defforward(self, X, state):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X).permute(1, 0, 2)
# 广播context,使其具有与X相同的num_steps
context = state[-1].repeat(X.shape[0], 1, 1)
X_and_context = torch.cat((X, context), 2)
output, state = self.rnn(X_and_context, state)
output = self.dense(output).permute(1, 0, 2)
# output的形状:(batch_size,num_steps,vocab_size)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, stateSeq2SeqDecoder是Decoder的派生类。
与Seq2SeqEncoder一样,Seq2SeqDecoder.__init__()方法中也创建了一个嵌入层和一个基于门控循环单元的循环层。另外,它还创建了一个稠密层(全连接层),用于将隐藏层的数据转换为词元索引,因此稠密层的形状是(隐藏单元数,词表大小)。
init_state()方法接受编码器输出作为参数。如上文所述,enc_outputs由预测值output和状态state组成。init_state()方法简单地返回第二个值(state)作为解码器的初始状态,output则被放弃了。
同样,在forward()方法中也将输入X转换为特征向量,并交换批量轴和时间轴。
接下来的这一行:
context = state[-1].repeat(X.shape[0], 1, 1)创建了上下文变量context,方法是重复state的第一个轴上的最后一个张量,重复次数是X的第一个轴的长度。state是一个包含多个隐藏层的隐状态的元组,因此state[-1]是最后一个隐藏层的隐状态,形状是(批量大小,隐藏单元数)。
X的形状是(时间步数,批量大小,特征维度),因此X.shape[0]是时间步数。这行代码的作用是重复最后一个隐藏层的隐状态,重复次数等于时间步数。得到的context的形状是(时间步数,批量大小,隐藏单元数)。
接下来,调用torch.cat()将X和context在第2维上进行拼接,而X和context的形状中的第0维和第1维都是一样的。拼接后的结果X_and_context的形状为(时间步数,批量大小,X的特征维度 + 隐藏单元数)。
将X_and_context输入循环层,返回预测值output和隐状态state。然后将output输入稠密层进行转换,输出结果的形状为(时间步数,批量大小,词表大小),其中最后一个维度存储预测的词元分布。
注意,这里还需要再调用一次permute(),将时间轴和批量轴交换回去,得到的形状为(批量大小,时间步数,词表大小)。最后,返回output和state。
编码器的测试代码如下:
# 测试解码器
decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16,
num_layers=2)
decoder.eval()
state = decoder.init_state(encoder(X))
output, state = decoder(X, state)
print(output.shape, state.shape)该测试代码首先创建一个Seq2SeqEncoder实例,词表大小为10,特征维度为8,隐藏单元数为16,隐藏层数为2。注意,这些参数和编码器完全一样。先将输入张量X输入编码器,返回值再输入init_state()方法,得到解码器的初始状态state。如我们在init_state()方法中所见,它只是简单地返回编码器的最终隐状态。最后将X和state一起输入编码器,得到预测值output和解码器状态state。
输出结果:
torch.Size([4, 7, 10]) torch.Size([2, 4, 16])Part.4
损失函数
本例中使用交叉熵损失函数来计算损失。为了将填充词元的预测值排除在计算之外,实现了MaskedSoftmaxCELoss类,代码如下:
defsequence_mask(X, valid_len, value=0):
"""在序列中屏蔽不相关的项"""
maxlen = X.size(1)
mask = torch.arange((maxlen), dtype=torch.float32,
device=X.device)[None, :] < valid_len[:, None]
print(f"mask, {mask}")
X[~mask] = value
return X
classMaskedSoftmaxCELoss(nn.CrossEntropyLoss):
"""带遮蔽的softmax交叉熵损失函数"""
# pred的形状:(batch_size,num_steps,vocab_size)
# label的形状:(batch_size,num_steps)
# valid_len的形状:(batch_size,)
defforward(self, pred, label, valid_len):
weights = torch.ones_like(label)
weights = sequence_mask(weights, valid_len)
self.reduction='none'
unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
pred.permute(0, 2, 1), label)
weighted_loss = (unweighted_loss * weights).mean(dim=1)
return weighted_loss辅助函数sequence_mask()在输入张量的基础上,通过valid_len辨别无效值,替换为指定值。它接受三个参数:X为输入张量,形状为(批量大小,时间步数);valid_len为向量,包含有效长度的元组;value为可选的替换值。显然,valid_len的长度应该与X的第0维的长度(批量大小)相同。
其中这一行:
mask = torch.arange((maxlen), dtype=torch.float32,
device=X.device)[None, :] < valid_len[:, None]torch.arange((maxlen),dtype=torch.float32,device=X.device)生成一个向量长度为maxlen的向量[0, 1, 2, ..., maxlen-1],形状为(maxlen,)。之后的[None, :] 在第0维添加维度,形状变为 (1, maxlen),即[[0, 1, 2, ..., maxlen-1]]。
valid_len 的形状为 (batch_size,),即批量大小的向量,例如 [3, 5, 2]。valid_len[:, None]在第1维添加维度,形状变为 (batch_size, 1),例如 [[3], [5], [2]]。
比较 (1, maxlen) 与 (batch_size, 1),广播后得到形状为(batch_size, maxlen) 的布尔张量。计算方法是mask[i, j] = j < valid_len[i]。
示例:
# 假设 maxlen = 5, valid_len = [3, 5, 2]
# torch.arange(5)[None, :] = [[0, 1, 2, 3, 4]]
# valid_len[:, None] = [[3], [5], [2]]
# 广播比较后的结果:
# mask = [[True, True, True, False, False], # 只有前3个位置有效
# [True, True, True, True, True], # 所有5个位置都有效
# [True, True, False, False, False]] # 只有前2个位置有效X[~mask] = value 会将无效位置(~mask 为 True 的位置)设为 value(默认为0),从而屏蔽填充位置。
MaskedSoftmaxCELoss类是PyTorch中的CrossEntropyLoss类的包裹类。重载的forward()方法接受三个参数:pred是预测值,label是标签(真实值),valid_len是预测值的有效长度。
首先创建了一个形状和label、值为1的权重张量weights,然后通过sequence_mask()将weights中的无效值都设为0。设置self.reduction='none'使交叉熵损失不进行内部聚合,返回每个时间步的逐元素损失张量,以便进行权重计算。
接下来,调用基类CrossEntropyLoss中的forward()方法,输入预测值pred和标签label,得到未计算权重的损失张量unweighted_loss。注意,在输入前交换了pred的第1维和第2维,原因是CrossEntropyLoss 对输入形状有要求。假设预测值pred的形状为(N, C, d1, d2, ...),标签label的形状为(N, d1, d2, ...),其中N是批量大小,C是类型,d1, d2, ...是其他维度。Input中的类别维度 C 必须在第 1 维。
在本例中,pred的形状是(批量大小,时间步数,词表大小),其中词表大小为类型维度。交换后,pred的形状变为(批量大小,词表大小,时间步数),满足了类别维度 C 在第 1 维的要求。
最后,将未计算权重的损失张量unweighted_loss和权重张量weights相乘并求平均数,得到最终的损失weighted_loss。注意,unweighted_loss和weights的形状是一样的。Weights的值由0和1组成,在相乘后,unweighted_loss中的无效值归零,从而实现排除填充词元的目的。
Part.5
训练
训练模型的代码如下:
deftrain_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
"""训练序列到序列模型"""
defxavier_init_weights(m):
iftype(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
iftype(m) == nn.GRU:
for param in m._flat_weights_names:
if"weight"in param:
nn.init.xavier_uniform_(m._parameters[param])
net.apply(xavier_init_weights)
net.to(device)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
loss = MaskedSoftmaxCELoss()
net.train()
animator = Animator(xlabel='epoch', ylabel='loss',
xlim=[10, num_epochs])
for epoch inrange(num_epochs):
print(f'Epoch {epoch + 1}/{num_epochs}')
timer = d2l.Timer()
metric = d2l.Accumulator(2) # 训练损失总和,词元数量
for batch in data_iter:
optimizer.zero_grad()
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],
device=device).reshape(-1, 1)
dec_input = torch.cat([bos, Y[:, :-1]], 1) # 强制教学
Y_hat, _ = net(X, dec_input, X_valid_len)
l = loss(Y_hat, Y, Y_valid_len)
l.sum().backward() # 损失函数的标量进行“反向传播”
rnn.grad_clipping(net, 1)
num_tokens = Y_valid_len.sum()
optimizer.step()
with torch.no_grad():
metric.add(l.sum(), num_tokens)
if (epoch + 1) % 10 == 0:
animator.add(epoch + 1, (metric[0] / metric[1],))
print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
f'tokens/sec on {str(device)}')
return metric[0] / metric[1], metric[1] / timer.stop()以上代码首先定义了权重初始化函数xavier_init_weights(),针对稠密层和循环层使用不同的初始化方式。接下来,依次进行初始化权重、设置优化器和损失函数、设置训练模式等工作。
代码的主体部分包括双重循环,外层循环遍历批量,内层循环遍历特定批量中的特征-标签对,得到输入值X和标签Y,以及相应的有效长度X_valid_len和Y_valid_len。
接下来,通过在Y的头部添加<bos>的词元索引并去掉最后一个词元,获得解码器输入dec_input,用于强制教学。
这样做的目的是:解码器在时间步 t 的输入使用真实序列中时间步 t-1 的词元(首步用 <bos>),用于预测 Y 当前步。将X和dec_input输入深度学习网络,这时候调用的是EncoderDecoder.forward(),X和dec_input对应参数enc_X和dec_X。
返回预测值Y_hat和解码器的最终隐状态,但解码器的最终隐状态此时已经无用,因此被抛弃了。
最后依次计算损失,进行反向传播,调用rnn.grad_clipping()截断梯度(见书中8.5.5节),并通过优化器更新参数,这样就完成了一次循环。
测试代码如下:
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()
train_iter, src_vocab, tgt_vocab = dataset.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)
net = enc_dec.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)输出结果为:
loss 0.019, 11063.6 tokens/sec on cpuPart.6
BLEU
BLEU(Bilingual Evaluation Understudy)用于评估预测的结果,公式为:

公式分为两部分:惩罚因子,即左边的指数公式;精确度,即右边的累乘公式。
在右边的累乘公式中,k是用于匹配的最长的n元语法。原文的解释如下:“用pn表示n元语法的精确度,它是两个数量的比值:第一个是预测序列与标签序列中匹配的n元语法的数量,第二个是预测序列中n元语法的数量的比率。具体地说,给定标签序列A、B、C、D、E、F 和预测序列A、B、B、C、D,我们有p1 = 4/5、p2 = 3/4、p3 = 1/3和p4 = 0。”
由于n元语法越长则匹配难度越大,因此需要为更长的n元语法的精确度分配更大的权重,方法是将这些精确度取1/2n次幂,当n增长时,pn^(1/2n)的值也会增长。(n变大时,1/2n会变小;因为0≤pn≤1,所以pn^(1/2n)会变大。)再将结果进行累乘,得到总的精确度。
在左边的指数公式中,lenlable表示标签序列中的词元数,lenpred表示预测序列中的词元数。原文的解释如下:“由于预测的序列越短获得的pn值越高,因此 (9.7.4)中乘法项之前的系数用于惩罚较短的预测序列。例如,当k = 2时,给定标签序列A、B、C、D、E、F 和预测序列A、B,尽管p1 = p2 = 1,惩罚因子exp(1 − 6/2) ≈ 0.14会降低BLEU。”
BLEU的实现代码如下:
defbleu(pred_seq, label_seq, k):#@save
"""计算BLEU"""
pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
len_pred, len_label = len(pred_tokens), len(label_tokens)
score = math.exp(min(0, 1 - len_label / len_pred))
for n inrange(1, k + 1):
num_matches, label_subs = 0, collections.defaultdict(int)
for i inrange(len_label - n + 1):
label_subs[' '.join(label_tokens[i: i + n])] += 1
for i inrange(len_pred - n + 1):
if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
num_matches += 1
label_subs[' '.join(pred_tokens[i: i + n])] -= 1
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
return score在外层循环中,建立了一个词典label_subs,键是标签中的词元,值是词元出现的次数。先遍历标签中的词元填充label_subs,然后遍历预测值pred中的词元,对于每一个匹配label_subs中某个键的词元,递增匹配计数器num_matches,并从匹配键对应的值中减去1。
这个算法的效果是,匹配计数器num_matches不会超过标签序列中的词元数lenlable,也就是说,如果lenpred大于lenlable,多出来的匹配会被忽略。这样就保证了lenlable/lenpred永远不会小于1。
Part.7
预测
使用模型预测的代码如下(在模型已经训练之后):
defpredict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
device, save_attention_weights=False):
"""序列到序列模型的预测"""
# 在预测时将net设置为评估模式
net.eval()
src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
src_vocab['<eos>']]
enc_valid_len = torch.tensor([len(src_tokens)], device=device)
src_tokens = dataset.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
# 添加批量轴
enc_X = torch.unsqueeze(
torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
enc_outputs = net.encoder(enc_X, enc_valid_len)
dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
# 添加批量轴
dec_X = torch.unsqueeze(torch.tensor(
[tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)
output_seq, attention_weight_seq = [], []
for _ inrange(num_steps):
Y, dec_state = net.decoder(dec_X, dec_state)
# 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
dec_X = Y.argmax(dim=2)
pred = dec_X.squeeze(dim=0).type(torch.int32).item()
# 保存注意力权重(稍后讨论)
if save_attention_weights:
attention_weight_seq.append(net.decoder.attention_weights)
# 一旦序列结束词元被预测,输出序列的生成就完成了
if pred == tgt_vocab['<eos>']:
break
output_seq.append(pred)
return' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seqpredict_seq2seq()接受几个参数:神经网络net(即EncoderDecoder)、源句src_sentence(一个由多个词元组成的句子)、源词表src_vocab、目录词表tgt_vocab和时间步数num_steps(注意力权重超出本文范围)。
首先,将句子拆分成词元src_tokens并加上结束标记<eos>,获取有效长度enc_valid_len,并通过truncate_pad()加入填充词元。truncate_pad()的代码参见原文9.5.4节。此时src_tokens是一个包含填充词元的词元(索引)列表。
接下来,将src_token转换为张量,并添加一个批量轴,得到enc_X。因为是测试代码,只有一个批量,所以只需要在第0维上扩展一个维度就可以了。enc_X的形状是(批量大小,时间步数),其中批量大小为1,时间步数是句子中的词元数量。
将enc_X和有效长度enc_valid_len输入编码器,得到编码器输出enc_outputs。如上文所述,enc_outputs包含预测值output和隐状态state。注意,编码器的工作到此就结束了,以后的代码不会调用EncoderDecoder.forward(),也不会单独调用编码器。编码器的工作仅仅是输出enc_outputs。(更准确地说,由于预测值output会被抛弃,编码器的工作仅仅是输出编码器状态state。)
然后,将enc_outputs和有效长度enc_valid_len用于初始化解码器,得到解码器状态dec_state。
另外,还要创建一个新的张量dec_X作为编码器的输入。它只包含一个值,即开始词元<bos>的索引。同样,也需要给它添加一个批量轴。
随后,在时间步上循环。将dec_X和dec_state输入解码器,得到预测值Y和新的解码器状态dec_state。由于Y的第2维表示预测词元的概率分布,因此可以通过Y.argmax()获得预测最高可能性的词元,并用它更新dec_X,作为解码器在下一个时间步的输入。去掉dec_X第0维上的轴(批量轴),得到预测的词元索引pred,再将pred添加到输出序列output_seq。
最后,按照output_seq中的词元索引获取词元,并将结果连接起来,就得到了预测的文本。
测试代码如下:
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra inzip(engs, fras):
translation, attention_weight_seq = predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, d2l.try_gpu())
print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')输出结果为:
go . => va !, bleu 1.000
i lost . => j'ai perdu <unk> ., bleu 0.658
he's calm . => il est paresseux emporté !, bleu 0.447
i'm home . => je suis chez du c'est c'est c'est c'est c'est c'est, bleu 0.376Part.8
总结
厘清这些细节后,编码器-解码器模型的行为就很清楚了。编码器和解码器分别使用独立的循环神经网络,先将输入的特征信息送入编码器,按时间步循环计算后,返回最终隐状态作为编码器状态。使用编码器状态初始化解码器状态(在本例中就是简单地使用编码器状态作为解码器的初始状态)。
在解码器中,按时间步循环,输入上一个时间步的预测值(首步输入是开始词元<bos>)和解码器状态,输出当前时间步的预测值和更新后的解码器状态,直到预测值为结束词元<eos>或者时间步循环结束。
显然,这种架构很不直观。它使用两个独立的循环神经网络,这两个网络又使用同样的神经网络算法,再加上各种复杂的参数输入/输出,很容易让人迷失。一个自然而然的问题是,为什么要使用这种双网络模型呢?
这种模型显而易见的好处之一,就是可以解决输入/输入序列大小不一致的问题。在语言翻译时,源语言和目标语言的词元数量往往是不一样的,而双网络模型可以轻易处理这一点。
但是,这只是它的附带优势。真正的核心原因是,输入和输出往往位于不同的语义空间(例如中文句子和英文句子),所以模型需要两个阶段:
编码器(Encoder):把输入序列压缩成一个固定维度的语义表示向量,捕捉输入的“意义”;
解码器(Decoder):基于这个语义向量(和之前生成的输出)一步步生成输出序列。这就实现了从 “理解输入” 到 “生成输出” 的解耦,而不是让一个模型直接从输入序列跳到输出序列。
同时,编码器-解码器模型还能提升性能。人们曾经尝试用单个 RNN 直接从输入序列生成输出序列(所谓 RNN transducer)。但是,RNN 在长序列上会遗忘早期输入,且输入序列和输出序列往往不同步,难以对齐。
编码器-解码器模型出现后,编码器的最终隐状态提供了全局上下文,解码器不再依赖输入的时间步对齐,而是自由生成。这大幅提高了机器翻译、语音识别、摘要生成等任务的性能。
另外,它也为后来的注意力(Attention)机制打下了基础。原始 seq2seq 结构(Sutskever et al., 2014)用“最终隐状态”作为上下文向量,信息容易丢失。
后来 Bahdanau 等人(2015)引入注意力机制,解码器在生成每个词时,都可以“动态访问”编码器的所有隐状态。这让 Encoder-Decoder 架构不再受固定长度表示的限制,成为现代 Transformer(BERT、GPT、T5 等)的核心思想基础。
若有兴趣进一步阅读,可以参考以下资源:
● Ilya Sutskever, Oriol Vinyals, Quoc V. Le — Sequence to Sequence Learning with Neural Networks (2014)
● Kyunghyun Cho et al. — Learning Phrase Representations using RNN Encoder–Decoder (2014)
● Dzmitry Bahdanau, Kyunghyun Cho, Yoshua Bengio — Neural Machine Translation by Jointly Learning to Align and Translate (2015)
● Vaswani et al. — Attention Is All You Need (2017)