首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >少走 90% 弯路!《动手学深度学习》编码器-解码器模型代码,精准拆解易踩坑点

少走 90% 弯路!《动手学深度学习》编码器-解码器模型代码,精准拆解易踩坑点

作者头像
三掌柜
发布2025-12-24 13:47:51
发布2025-12-24 13:47:51
600
举报

Part.0

前言

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

图片
图片

编码器-解码器模型的代码大致可以分为两部分:抽象基类Encoder/Decoder/EncoderDecoder,以及用于序列到序列(sequence to sequence,seq2seq)学习的派生类Seq2SeqEncoder/Seq2SeqDecoder及相关代码。

Part.1

基类

基类的代码如下:

代码语言:javascript
复制
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的代码如下:

代码语言:javascript
复制
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, state

Seq2SeqEncoder是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()的代码如下:

代码语言:javascript
复制
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的测试代码如下:

代码语言:javascript
复制
    # 测试编码器
    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,它们的形状分别为:

代码语言:javascript
复制
torch.Size([7, 4, 16]) torch.Size([2, 4, 16])

output的形状为(时间步数,批量大小,隐藏单元数),因为已经经过permute()交换了批量轴和时间轴。state的形状为(隐藏层数,批量大小,隐藏单元数)。

Part.3

Seq2SeqDecoder

解码器Seq2SeqDecoder的代码如下:

代码语言:javascript
复制
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, state

Seq2SeqDecoder是Decoder的派生类。

与Seq2SeqEncoder一样,Seq2SeqDecoder.__init__()方法中也创建了一个嵌入层和一个基于门控循环单元的循环层。另外,它还创建了一个稠密层(全连接层),用于将隐藏层的数据转换为词元索引,因此稠密层的形状是(隐藏单元数,词表大小)。

init_state()方法接受编码器输出作为参数。如上文所述,enc_outputs由预测值output和状态state组成。init_state()方法简单地返回第二个值(state)作为解码器的初始状态,output则被放弃了。

同样,在forward()方法中也将输入X转换为特征向量,并交换批量轴和时间轴。

接下来的这一行:

代码语言:javascript
复制
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。

编码器的测试代码如下:

代码语言:javascript
复制
    # 测试解码器
    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。

输出结果:

代码语言:javascript
复制
torch.Size([4, 7, 10]) torch.Size([2, 4, 16])

Part.4

损失函数

本例中使用交叉熵损失函数来计算损失。为了将填充词元的预测值排除在计算之外,实现了MaskedSoftmaxCELoss类,代码如下:

代码语言:javascript
复制
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维的长度(批量大小)相同。

其中这一行:

代码语言:javascript
复制
    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]。

示例:

代码语言:javascript
复制
# 假设 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

训练

训练模型的代码如下:

代码语言:javascript
复制
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节),并通过优化器更新参数,这样就完成了一次循环。

测试代码如下:

代码语言:javascript
复制
    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)

输出结果为:

代码语言:javascript
复制
loss 0.019, 11063.6 tokens/sec on cpu

Part.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的实现代码如下:

代码语言:javascript
复制
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

预测

使用模型预测的代码如下(在模型已经训练之后):

代码语言:javascript
复制
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_seq

predict_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中的词元索引获取词元,并将结果连接起来,就得到了预测的文本。

测试代码如下:

代码语言:javascript
复制
    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}')

输出结果为:

代码语言:javascript
复制
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.376

Part.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)

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-12-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 iOS开发by三掌柜 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档