《动手学深度学习》读书笔记—9.7序列到序列学习
本文记录了自己在阅读《动手学深度学习》时的一些思考,仅用来作为作者本人的学习笔记,不存在商业用途。
正如我们在9.5机器翻译
中看到的, 机器翻译中的输入序列和输出序列都是长度可变的。 为了解决这类问题,我们在 9.6"编码器-解码器"架构
中 设计了一个通用的”编码器-解码器“架构。 本节,我们将使用两个循环神经网络的编码器和解码器, 并将其应用于序列到序列(sequence to sequence,seq2seq)类的学习任务。
遵循编码器-解码器架构的设计原则, 循环神经网络编码器使用长度可变的序列作为输入, 将其转换为固定形状的隐状态。 换言之,输入序列的信息被编码到循环神经网络编码器的隐状态中。 为了连续生成输出序列的词元, 独立的循环神经网络解码器是基于输入序列的编码信息 和输出序列已经看见的或者生成的词元来预测下一个词元。 图9.7.1演示了 如何在机器翻译中使用两个循环神经网络进行序列到序列学习。
🏷图9.7.1 使用循环神经网络编码器和循环神经网络解码器的序列到序列学习
在图9.7.1
中,特定的“<eos>”表示序列结束词元。一旦输出序列生成此词元,模型就会停止预测。在循环神经网络解码器的初始化时间步,有两个特定的设计决定:首先,特定的“<bos>”表示序列开始词元,它是解码器的输入序列的第一个词元。其次,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。在图9.7.1
所示的结构中,编码器最终的隐状态在每一个时间步都作为解码器的输入序列的一部分。类似于8.3语言模型和数据集
中语言模型的训练,可以允许标签成为原始的输出序列,从源序列词元“<bos>”“Ils”“regardent”“.”到新序列词元“Ils”“regardent”“.”“<eos>”来移动预测的位置。
下面,我们动手构建图9.7.1
的设计,基于9.5机器翻译
中介绍的“英-法”数据集来训练这个机器翻译模型。
# 导入各种库
import collections
import math
import torch
from torch import nn
from d2l import torch as d2l
9.7.1 编码器
从技术上讲,编码器就是将任意长度的文本转换为一个固定长度的向量ccc(见下图)
🏷图片来源于鱼书
考虑由一个序列组成的样本(批量大小是111)。假设输入序列是x1,…,xTx_1, \ldots, x_Tx1,…,xT,其中xtx_txt是输入文本序列中的第ttt个词元(即时间步ttt处的输入是xtx_txt)。在时间步ttt,循环神经网络将词元xtx_txt的输入特征向量xt\mathbf{x}_txt(8.4循环神经网络
中将每个词元xtx_txt在词表中对应的索引iii变成独热编码作为词元的特征向量xt\mathbf{x}_txt)和ht−1\mathbf{h} _{t-1}ht−1(上一时间步的隐状态)转换为ht\mathbf{h}_tht(当前步的隐状态)。使用一个函数fff来描述循环神经网络的循环层所做的变换:
ht=f(xt,ht−1).\mathbf{h}_t = f(\mathbf{x}_t, \mathbf{h}_{t-1}). ht=f(xt,ht−1).
编码器通过选定的函数qqq,将所有时间步的隐状态转换为上下文变量:
c=q(h1,…,hT).\mathbf{c} = q(\mathbf{h}_1, \ldots, \mathbf{h}_T).c=q(h1,…,hT).
比如,当选择q(h1,…,hT)=hTq(\mathbf{h}_1, \ldots, \mathbf{h}_T) = \mathbf{h}_Tq(h1,…,hT)=hT时,上下文变量仅仅是输入序列在最后时间步的隐状态hT\mathbf{h}_ThT,如图9.7.1
中的结构所示。到目前为止,我们使用的是一个单向循环神经网络来设计编码器,其中隐状态只依赖于输入子序列,这个子序列是由输入序列的开始位置到隐状态所在的时间步的位置(包括隐状态所在的时间步)组成。我们也可以使用双向循环神经网络构造编码器,其中隐状态依赖于两个输入子序列,两个子序列是由隐状态所在的时间步的位置之前的序列和之后的序列(包括隐状态所在的时间步),因此隐状态对整个序列的信息都进行了编码。
现在,让我们实现循环神经网络编码器。注意,我们使用了嵌入层(embedding layer)来获得输入序列中每个词元的特征向量。嵌入层的权重是一个矩阵,其行数等于输入词表的大小(vocab_size
),其列数等于特征向量的维度(embed_size
)。对于任意输入词元的索引iii,嵌入层获取权重矩阵的第iii行(从000开始)以返回其特征向量。另外,本文选择了一个多层门控循环单元来实现编码器。
🏷nn.Embedding的具体解释
https://pytorch.ac.cn/docs/stable/generated/torch.nn.Embedding.html
>>> # an Embedding module containing 10 tensors of size 3创建一个包含10个3维的tensor
>>> embedding = nn.Embedding(10, 3)
>>> # a batch of 2 samples of 4 indices each每个批量中包含4个索引值# 注意这里的索引值不能超过nn.Embedding的第一个参数)
>>> input = torch.LongTensor([[1, 2, 4, 5], [4, 3, 2, 9]])
>>> embedding(input)
tensor([[[-0.0251, -1.6902, 0.7172], # 索引1被编码成3维的向量[-0.6431, 0.0748, 0.6969], # 索引2被编码成3维的向量[ 1.4970, 1.3448, -0.9685], # 索引4被编码成3维的向量[-0.3677, -2.7265, -0.1685]], # 索引5被编码成3维的向量[[ 1.4970, 1.3448, -0.9685], # 索引4被编码成3维的向量[ 0.4362, -0.4004, 0.9400], # 索引3被编码成3维的向量[-0.6431, 0.0748, 0.6969], # 索引2被编码成3维的向量[ 0.9124, -2.3616, 1.1151]]]) # 索引9被编码成3维的向量
🏷Seq2SeqEncoder代码实现
#@save
class Seq2SeqEncoder(d2l.Encoder):"""用于序列到序列学习的循环神经网络编码器"""def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,dropout=0, **kwargs):super(Seq2SeqEncoder, self).__init__(**kwargs)# nn.Embedding: 词表大小, 嵌入维数(每个词元转成向量的维数)self.embedding = nn.Embedding(vocab_size, embed_size)# nn.GRU: 输入特征维数, 隐藏层维数, GRU的层数, 是否使用dropoutself.rnn = nn.GRU(embed_size, num_hiddens, num_layers,dropout=dropout)def forward(self, X, *args):# 输出'X'的形状:(batch_size,num_steps,embed_size)X = self.embedding(X)# 在循环神经网络模型中,输入尺寸是时间长度 * 批量大小 * 词元对应的特征向量维数(这里是嵌入维数) X = X.permute(1, 0, 2)# 如果未提及状态,则默认为0output, state = self.rnn(X)# output是所有时刻的隐状态, 形状:(num_steps,batch_size,num_hiddens)# state是最后一个时刻的隐状态, 形状:(num_layers,batch_size,num_hiddens)return output, state
下面,我们实例化上述编码器的实现:我们使用一个两层门控循环单元编码器,其隐藏单元数为161616。给定一小批量的输入序列X
(批量大小为444,时间步为777)。在完成所有时间步后,最后一层的隐状态的输出是一个张量(output
由编码器的循环层返回),其形状为(时间步数,批量大小,隐藏单元数)。
>>> 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)
>>> output.shape
torch.Size([7, 4, 16])
由于这里使用的是门控循环单元, 所以在最后一个时间步的多层隐状态的形状是 (隐藏层的数量,批量大小,隐藏单元的数量)。 如果使用长短期记忆网络(将self.rnn = nn.GRU换成nn.LSTM),state中还将包含记忆单元信息。
>>> state.shape
torch.Size([2, 4, 16])
9.7.2 解码器
编码器输出的上下文变量c\mathbf{c}c对整个输入序列x1,…,xTx_1, \ldots, x_Tx1,…,xT进行编码。来自训练数据集的输出序列y1,y2,…,yT′y_1, y_2, \ldots, y_{T'}y1,y2,…,yT′,对于每个时间步t′t't′(与输入序列或编码器的时间步ttt不同),解码器输出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)。
为了在序列上模型化这种条件概率,我们可以使用另一个循环神经网络作为解码器。在输出序列上的任意时间步t′t^\primet′,循环神经网络将来自上一时间步的输出yt′−1y_{t^\prime-1}yt′−1和上下文变量c\mathbf{c}c作为其输入,然后在当前时间步将它们和上一隐状态st′−1\mathbf{s}_{t^\prime-1}st′−1转换为隐状态st′\mathbf{s}_{t^\prime}st′。因此,可以使用函数ggg来表示解码器的隐藏层的变换:
st′=g(yt′−1,c,st′−1).\mathbf{s}_{t^\prime} = g(y_{t^\prime-1}, \mathbf{c}, \mathbf{s}_{t^\prime-1}).st′=g(yt′−1,c,st′−1).
在获得解码器的隐状态之后,我们可以使用输出层和softmax操作来计算在时间步t′t^\primet′时输出yt′y_{t^\prime}yt′的条件概率分布P(yt′∣y1,…,yt′−1,c)P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \mathbf{c})P(yt′∣y1,…,yt′−1,c)。
根据图9.7.1
,当实现解码器时,我们直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。为了进一步包含经过编码的输入序列的信息,上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。为了预测输出词元的概率分布,
在循环神经网络解码器的最后一层使用全连接层来变换隐状态。
🏷Seq2SeqDecoder代码实现
class Seq2SeqDecoder(d2l.Decoder):"""用于序列到序列学习的循环神经网络解码器"""def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,dropout=0, **kwargs):super(Seq2SeqDecoder, self).__init__(**kwargs)# nn.Embedding: 词表大小, 嵌入维数(每个词元转成向量的维数)self.embedding = nn.Embedding(vocab_size, embed_size)# nn.GRU: 输入特征维数(拼接后是嵌入维数+隐藏层维数), 隐藏层维数, GRU的层数, 是否使用dropoutself.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,dropout=dropout)# nn.Linear: 隐藏层维数, 词表大小self.dense = nn.Linear(num_hiddens, vocab_size)def init_state(self, enc_outputs, *args):# 编码器输出返回一个元组enc_outputs(包含两个元素)# enc_outputs[0]: 所有时间步的隐状态output, enc_outputs[1]:最后一个时间步的隐状态statereturn enc_outputs[1]def forward(self, X, state):# 输入X的形状: (批量大小, 时间长度)# self.embedding(X): 对输入X进行编码, X的形状: (批量大小, 时间长度, 嵌入维数)# permute(1, 0, 2): X的形状: (时间长度, 批量大小, 嵌入维数)X = self.embedding(X).permute(1, 0, 2)# state[-1]: 按照图9.7.1所示取编码器最后一个时间步的隐状态作为上下文context# repeat(X.shape[0], 1, 1): 在dim1上复制X.shape[0]次context, 其余维度保持不变# 即context的形状: (时间长度, 批量大小, 隐藏层维数)context = state[-1].repeat(X.shape[0], 1, 1)# 沿着dim2拼接, 拼接后的形状: (时间长度, 批量大小, embed_size + num_hiddens)X_and_context = torch.cat((X, context), 2)# output的形状: (时间长度, 批量大小, 词表大小)output, state = self.rnn(X_and_context, state)# self.dense(output)计算批量中每个词元的输出值# permute(1, 0, 2): output的形状变为: (批量大小, 时间长度, 词表大小)output = self.dense(output).permute(1, 0, 2)# output的形状: (批量大小, 时间长度, 词表大小)# state的形状:(层数, 批量大小, 隐藏层维数)return 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)
>>> output.shape, state.shape
(torch.Size([4, 7, 10]), torch.Size([2, 4, 16]))
🏷图9.7.2 循环神经网络"编码器—解码器"模型结构图
9.7.3 损失函数
在每个时间步,解码器预测了输出词元的概率分布。 类似于语言模型,可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化。 回想一下 9.5节中, 特定的填充词元被添加到序列的末尾, 因此不同长度的序列可以以相同形状的小批量加载。 但是,我们应该将填充词元的预测排除在损失函数的计算之外。
为此,我们可以使用下面的sequence_mask函数 通过零值化屏蔽不相关的项, 以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。 例如,如果两个序列的有效长度(不包括填充词元)分别为111和222, 则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。
🏷sequence_mask代码实现
#@save
def sequence_mask(X, valid_len, value=0):"""在序列中屏蔽不相关的项"""# 沿着dim=1, 即列方向获得序列的长度maxlen = X.size(1)# torch.arange(maxlen): 生成长度为maxlen的张量(相当于创建位置索引), 比如[0, 1, 2]# device=X.device: 确保与输入张量在同一设备# [None, :]: 增加一个维度, 将形状变成(1, maxlen), 比如[[0, 1, 2]]# valid_len[: None]: 增加一个维度, 相当于tensor([1, 2])变成tensor([[1], [2]])# 对于第一个序列, [0<1, 1<1, 2<1]得到[True, False, False]# 对于第二个序列, [0<2, 1<2, 2<2]得到[True, True, False]# 即mask中所有为True的位置是要保留的mask = torch.arange((maxlen), dtype=torch.float32,device=X.device)[None, :] < valid_len[:, None]# ~mask将布尔值取反, 取反后True变False, False变True# True表示要掩蔽的位置, 将这些位置设为0X[~mask] = valuereturn X
>>> X = torch.tensor([[1, 2, 3], [4, 5, 6]])
>>> sequence_mask(X, torch.tensor([1, 2]))
# X的两个序列最大长度是3, 生成索引列表[[0, 1, 2]], valid_len[:None]后变成[[1], [2]]
# [[0, 1, 2]]形状是(1,3), [[1], [2]]形状是(2, 1)
# 广播机制会将(1, 3)变成(2, 3), 将(2, 1)变成(2, 3)
# [[0, 1, 2] [[1, 1, 1]
# [0, 1, 2]] [2, 2, 2]],
# 对于第一个序列, [0<1, 1<1, 2<1]得到[True, False, False]
# 对于第二个序列, [0<2, 1<2, 2<2]得到[True, True, False]
# 取~后的X如下
# [[False, True, True]
# [False, False, True]]
# True的位置赋值为value, 于是得到下面的结果
tensor([[1, 0, 0],[4, 5, 0]])
我们还可以使用此函数屏蔽最后几个轴上的所有项。如果愿意,也可以使用指定的非零值来替换这些项。
>>> X = torch.ones(2, 3, 4)
>>> sequence_mask(X, torch.tensor([1, 2]), value=-1)
# X的两个序列最大长度是3, 生成索引列表[0, 1, 2], valid_len[:None]后变成[[1], [2]]
# 广播机制会将(1, 3)变成(2, 3), 将(2, 1)变成(2, 3)
# [[0, 1, 2] [[1, 1, 1]
# [0, 1, 2]] [2, 2, 2]],
# 对于第一个序列, [0<1, 1<1, 2<1]得到[True, False, False]
# 对于第二个序列, [0<2, 1<2, 2<2]得到[True, True, False]
# 取~后的X如下
# [[False, True, True]
# [False, False, True]]
# X本身是三维的, mask是二维的, 所以会再次触发广播机制
# 广播后的mask如下
# [[[False, False, False, False],
# [True, True, True, True ],
# [True, True, True, True ],
# [False, False, False, False],
# [False, False, False, False],
# [True, True, True, True ]]]
# True的位置赋值为value, 于是得到下面的结果
tensor([[[ 1., 1., 1., 1.],[-1., -1., -1., -1.],[-1., -1., -1., -1.]],[[ 1., 1., 1., 1.],[ 1., 1., 1., 1.],[-1., -1., -1., -1.]]])
现在,我们可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。 最初,所有预测词元的掩码都设置为1。 一旦给定了有效长度,与填充词元对应的掩码将被设置为0。 最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。
🏷MaskedSoftmaxCELoss实现
#@save
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):"""带遮蔽的softmax交叉熵损失函数"""# pred的形状:(batch_size,num_steps,vocab_size)# label的形状:(batch_size,num_steps)# valid_len的形状:(batch_size,)def forward(self, pred, label, valid_len):# 初始化预测词元的掩码为1, 形状与label一样weights = torch.ones_like(label)# 根据解码器输出的语句的真实长度, 将填充词元的掩码设置为0(过滤填充词元)weights = sequence_mask(weights, valid_len)# 设置nn.CrossEntropyLoss状态, none表示返回每个样本的交叉熵损失self.reduction='none'# nn.CrossEntropyLoss的输入形状是(批量大小, 类别数)# 对应到词元预测就是(批量大小, 词表大小(相当于有多少种词元))# pred.permute(0,2,1)改变形状为(批量大小, 词表大小, 时间长度)# unweighted_loss的形状是(批量大小, 时间长度)unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(pred.permute(0, 2, 1), label)# unweighted_loss * weights保留每个预测位置的损失, 过滤填充位置的损失# mean(dim=1)沿着列维度计算损失, 相当于计算批量内每条语句预测词元的平均损失# 即每条语句都计算 预测次元总损失/预测词元数# weighted_loss的形状是(批量大小, )weighted_loss = (unweighted_loss * weights).mean(dim=1)return weighted_loss
我们可以创建三个相同的序列来进行代码健全性检查, 然后分别指定这些序列的有效长度为444、222和101010。结果就是,第一个序列的损失应为第二个序列的两倍,而第三个序列的损失应为零。
>>> loss = MaskedSoftmaxCELoss()
>>> loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),torch.tensor([4, 2, 0]))
tensor([2.3026, 1.1513, 0.0000])
9.7.4 训练
在下面的循环训练过程中,如图9.7.1
所示,特定的序列开始词元(“<bos>”)和 原始的输出序列(不包括序列结束词元“<eos>”) 拼接在一起作为解码器的输入。这被称为强制教学(teacher forcing),因为原始的输出序列(词元的标签)被送入解码器。或者,将来自上一个时间步的预测得到的词元作为解码器的当前输入。
#@save
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):"""训练序列到序列模型"""# 使用xavier初始化权重def xavier_init_weights(m):# 如果m是nn.Linear, 则使用xavier_uniform初始化m的权重if type(m) == nn.Linear:nn.init.xavier_uniform_(m.weight)# 如果是nn.GRU, 则遍历m中的每个参数, _flat_weight_names按顺序存储了nn.GRU中的参数# 比如输入权重weight_ih_l0, 偏置bias_ih_l0, 隐藏层权重weight_hh_l0, 偏置bias_hh_l0if type(m) == nn.GRU:for param in m._flat_weights_names:# 如果是权重参数(即参数名包含weight), 则使用xavier_uniform初始化权重if "weight" in param:nn.init.xavier_uniform_(m._parameters[param])# 对模型中的每层应用xavier初始化net.apply(xavier_init_weights)# 将模型移动到指定设备net.to(device)# 使用Adam优化器, 能够自适应学习率optimizer = torch.optim.Adam(net.parameters(), lr=lr)# 损失函数使用带有掩码的交叉熵损失loss = MaskedSoftmaxCELoss()# 设置模型为训练模式net.train()animator = d2l.Animator(xlabel='epoch', ylabel='loss',xlim=[10, num_epochs])# 遍历每轮训练for epoch in range(num_epochs):# 设置计时器timer = d2l.Timer()metric = d2l.Accumulator(2) # 训练损失总和,词元数量# 从数据迭代器中取出当前批量# 数据迭代器data_iter中包含很多批量batch, 每个batch包含X, X_valid_len, Y, Y_valid_lenfor batch in data_iter:# 清空梯度optimizer.zero_grad()# X: 源序列, X_valid_len: 源序列有效长度# Y: 目标序列, Y_valid_len: 目标序列有效长度X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]# tgt_vocab['<bos>']: 在目标词表中查找'<bos>'的索引# Y.shape[0]: 目标的批量大小, 即将'<bos>'的索引值复制batch_size次# reshape(-1,1): 形状转换成(batch_size, 1)# 假设tgt_vocab['<bos>']=3, Y.shape[0] = 2, 则bos大致如下# tensor([[3],# [3]], device='cuda:0')bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],device=device).reshape(-1, 1)# 特定的序列开始词元“<bos>”和目标输出序列(不包括序列结束词元“<eos>”拼接在一起作为解码器的输入# Y是目标输出序列, Y的最后一列是序列结束词元“<eos>”# Y[:, :-1]: 取Y的所有行, 第1个词元到倒数第2个词元# torch.cat([bos, Y[:, :-1]], 1): 沿着列的方向拼接bos和目标输出序列的每列dec_input = torch.cat([bos, Y[:, :-1]], 1) # 输入源序列X, 解码器输入dec_input和源序列有效长度X_valid_len, 输出预测序列Y_hatY_hat, _ = net(X, dec_input, X_valid_len)# 根据目标序列Y, 目标序列长度Y_valid_len和预测序列Y_hat计算当前批次的损失l = loss(Y_hat, Y, Y_valid_len)# 将批次内所有序列的损失求和反向传播l.sum().backward() # 防止梯度爆炸进行梯度裁剪d2l.grad_clipping(net, 1) # 计算批次中有效词元数num_tokens = Y_valid_len.sum()# 使用优化器更新模型参数optimizer.step()# 临时禁用梯度计算with torch.no_grad():# 向加法器中添加当前批次的总损失和有效词元数metric.add(l.sum(), num_tokens)# 每10个epoch更新损失曲线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)}')
现在,在机器翻译数据集上,我们可以 创建和训练一个循环神经网络“编码器-解码器”模型用于序列到序列的学习。
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
# train_iter里面有很多个批量, 每个批量包含两个序列(两个源序列和其对应的目标序列)
# 每个batch = [X, X_valid_len, Y, Y_valid_len]
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
# 根据源序列X创建编码器
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,dropout)
# 根据目标序列Y创建解码器
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,dropout)
# 创建模型
net = d2l.EncoderDecoder(encoder, decoder)
# 训练模型
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
🏷训练结果图
9.7.5 预测
为了采用一个接着一个词元的方式预测输出序列,每个解码器当前时间步的输入都将来自于前一时间步的预测词元。与训练类似,序列开始词元(“<bos>”)在初始时间步被输入到解码器中。该预测过程如图9.7.3
所示,当输出序列的预测遇到序列结束词元(“<eos>”)时,预测就结束了。
🏷图9.7.3 预测时的结构图
#@save
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,device, save_attention_weights=False):"""序列到序列模型的预测"""# 在预测时将net设置为评估模式net.eval()# src_sentence.lower().split(' '): 将源序列转换成小写并按空格拆分成词元# src_vocab[src_sentence.lower().split(' ')]: 查找源序列中包含的词元在源词表中的索引# + [src_vocab['<eos>']]: 添加序列结束标记src_tokens = src_vocab[src_sentence.lower().split(' ')] + [src_vocab['<eos>']]# 根据源序列长度src_tokens获得编码器输入序列的有效长度enc_valid_len = torch.tensor([len(src_tokens)], device=device)# 将当前源序列, 时间长度, 待填充词元'<pad>'在源词表中的索引送入truncate_pad函数中# 该函数将实现对当前序列截断或填充词元'<pad>'(若序列长度 < 时间长度则填充, 反之则截断)# src_tokens的形状: 时间长度src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])# 添加批量轴# torch.unsqueeze(dim=0)添加批量维度, enc_X的形状为(1, 时间长度)enc_X = torch.unsqueeze(torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)# 向编码器中输入源序列enc_X和源序列有效长度enc_valid_len得到编码器输出# 编码器输出enc_outputs是包含输出和状态两个元素的元组# enc_outputs[0]是输出, 形状为(时间长度, 批量大小, 隐藏层维数)# enc_outputs[1]是状态, 形状为(隐藏层数量, 批量大小, 隐藏层维数)enc_outputs = net.encoder(enc_X, enc_valid_len)# 利用编码器输出和源序列有效长度初始化解码器状态# dec_state的形状为(隐藏层数量, 批量大小, 隐藏层维数)dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)# 添加批量轴# tgt_vocab['<bos>']获得目标词表中起始词元'bos'的索引# torch.unsqueeze(dim=0)添加批量维度, dec_X的形状为(1, 1)dec_X = torch.unsqueeze(torch.tensor([tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)# output_seq: 存储生成的词元索引, attention_weight_seq: 注意力权重output_seq, attention_weight_seq = [], []# 遍历每个时间步for _ in range(num_steps):# 解码器返回两个变量, 当前时间步的输出Y和当前时间步的状态dec_state# Y的形状: (批量大小, 时间长度, 目标词表大小), dim2的值可视为预测词元属于目标词表中各个词元的概率# 由于现在遍历的是每个时间步, 所以时间长度是1, 即Y的形状(批量大小, 1, 目标词表大小)# dec_state的形状: (网络层数, 批量大小, 隐藏层维数)Y, dec_state = net.decoder(dec_X, dec_state)# Y.argmax(dim=2): 获得预测最高可能性的词元在词表中的索引# 由于Y是三维张量, 所以返回的是二维张量, 类似[[12]] # dec_X = Y.argmax(dim=2): 作为解码器在下一时间步的输入dec_X = Y.argmax(dim=2)# dec_X.squeeze(dim=0): 去掉dim0, 相当于[[12]]变成[12]# dec_X.item(): 提取张量中的标量值, 即获得12 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)# tgt_vocab.to_tokens(output_seq): 根据输出序列的词元索引获得对应的词元# ' '.join将词元拼接成字符串# 返回预测序列和注意力权重return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq
9.7.6 预测序列的评估
BLEU(bilingual evaluation understudy)最先是用于评估机器翻译的结果,但现在它已经被广泛用于测量许多应用的输出序列的质量。原则上说,对于预测序列中的任意nnn元语法(n-grams),BLEU的评估都是这个nnn元语法是否出现在标签序列中。我们将BLEU定义为:
exp(min(0,1−lenlabellenpred))∏n=1kpn1/2n, \exp\left(\min\left(0, 1 - \frac{\mathrm{len}_{\text{label}}}{\mathrm{len}_{\text{pred}}}\right)\right) \prod_{n=1}^k p_n^{1/2^n},exp(min(0,1−lenpredlenlabel))n=1∏kpn1/2n,
其中lenlabel\mathrm{len}_{\text{label}}lenlabel表示标签序列中的词元数和lenpred\mathrm{len}_{\text{pred}}lenpred表示预测序列中的词元数,kkk是用于匹配的最长的nnn元语法。另外,用pnp_npn表示nnn元语法的精确度,它是两个数量的比值:第一个是预测序列与标签序列中匹配的nnn元语法的数量,第二个是预测序列中nnn元语法的数量的比率。具体地说,给定标签序列AAA、BBB、CCC、DDD、EEE、FFF和预测序列AAA、BBB、BBB、CCC、DDD,我们有p1=4/5p_1 = 4/5p1=4/5、p2=3/4p_2 = 3/4p2=3/4、p3=1/3p_3 = 1/3p3=1/3和p4=0p_4 = 0p4=0。
🏷BLEU的计算过程
根据BLEU的定义,当预测序列与标签序列完全相同时,BLEU为111。此外,由于nnn元语法越长则匹配难度越大,所以BLEU为更长的nnn元语法的精确度分配更大的权重。具体来说,当pnp_npn固定时,pn1/2np_n^{1/2^n}pn1/2n会随着nnn的增长而增加(原始论文使用pn1/np_n^{1/n}pn1/n)。而且,由于预测的序列越短获得的pnp_npn值越高,所以eq_bleu
中乘法项之前的系数用于惩罚较短的预测序列。例如,当k=2k=2k=2时,给定标签序列AAA、BBB、CCC、DDD、EEE、FFF和预测序列AAA、BBB,尽管p1=p2=1p_1 = p_2 = 1p1=p2=1,惩罚因子exp(1−6/2)≈0.14\exp(1-6/2) \approx 0.14exp(1−6/2)≈0.14会降低BLEU。
🏷BLEU代码实现
def bleu(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))# 遍历一元词到K元词for n in range(1, k + 1):# 初始化匹配词元数num_matches=0, 设置计数器label_subs# collections.defaultdict(int)当存在时返回int, 不存在时返回Nonenum_matches, label_subs = 0, collections.defaultdict(int)# 遍历标签序列中的n元词for i in range(len_label - n + 1):# label_tokens[i: i + n]: 从标签序列中取出n元词(最后那个词的起始位置是len-n+1)# ' '.join: 将n元词的词元组装成一个词# 统计该词在标签序列中出现的次数label_subs[' '.join(label_tokens[i: i + n])] += 1# 遍历预测序列中的n元词for i in range(len_pred - n + 1):# pred_tokens[i: i + n]: 从标签序列中取出n元词(最后那个词的起始位置是len-n+1)# ' '.join: 将n元词的词元组装成一个词# 如果标签序列中存在预测序列中的这个n元词if label_subs[' '.join(pred_tokens[i: i + n])] > 0:# 匹配词元数加1num_matches += 1# 避免重复匹配, 将标签中该词元的出现次数-1label_subs[' '.join(pred_tokens[i: i + n])] -= 1# 完成公式计算score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))return score
最后,利用训练好的循环神经网络“编码器-解码器”模型,将几个英语句子翻译成法语,并计算BLEU的最终结果。
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
# 遍历engs和fras中的每个元素
for eng, fra in zip(engs, fras):# 相当于每个批量只使用一条源序列和目标序列translation, attention_weight_seq = predict_seq2seq(net, eng, src_vocab, tgt_vocab, num_steps, device)print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')
9.7.7 小结
- 根据“编码器-解码器”架构的设计,我们可以使用两个循环神经网络来设计一个序列到序列学习的模型。
- 在实现编码器和解码器时,我们可以使用多层循环神经网络。
- 我们可以使用遮蔽来过滤不相关的计算,例如在计算损失时。
- 在“编码器-解码器”训练中,强制教学方法将原始输出序列(而非预测结果)输入解码器。
- BLEU是一种常用的评估方法,它通过测量预测序列和标签序列之间的nnn元语法的匹配度来评估预测。