Seq2Seq代码解析(包含多层RNN分析,Linear函数等知识补充)

编码器-解码器架构

编码器

在编码器接口中,我们只指定长度可变的序列作为编码器的输入X
任何继承这个Encoder基类的模型将完成代码实现。

from torch import nn


#@save
class Encoder(nn.Module):
    """编码器-解码器架构的基本编码器接口"""
    def __init__(self, **kwargs):
        super(Encoder, self).__init__(**kwargs)

    def forward(self, X, *args):
        raise NotImplementedError

解码器

在下面的解码器接口中,新增了一个init_state函数,用于将编码器的输出(enc_outputs)转换为编码后的状态。

#@save
class Decoder(nn.Module):
    """编码器-解码器架构的基本解码器接口"""
    def __init__(self, **kwargs):
        super(Decoder, self).__init__(**kwargs)

    def init_state(self, enc_outputs, *args):
        raise NotImplementedError

    def forward(self, X, state):
        raise NotImplementedError

合并编码器和解码器

“编码器-解码器”架构包含了一个编码器和一个解码器,并且还拥有可选的额外参数。在前向传播中,编码器的输出用于生成编码状态,这个状态又被解码器作为其输入的一部分。

#@save
class EncoderDecoder(nn.Module):
    """编码器-解码器架构的基类"""
    def __init__(self, encoder, decoder, **kwargs):
        super(EncoderDecoder, self).__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder

    def 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)

序列到序列学习(Seq2Seq)

编码器

#@save
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 = 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)
        # 如果未提及状态,则默认为0
        output, state = self.rnn(X)
        # output的形状:(num_steps,batch_size,num_hiddens)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state

编码器中output, state形状为什么是这样?

在pytorch官方文档中说了,
假设不考虑双向情况
output: 形状为 (时间步, 批量大小, 隐藏层大小) 的张量,该张量包含 RNN 的最后一层在每个时间步 t 上的输出特征
state: 形状为 (RNN层数, 批量大小,隐藏层大小) 的张量,包含批次中每个元素的最终隐藏状态。

画图如下:
img

解码器

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 = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
                          dropout=dropout)
        # linear层只会对最后一个维度进行线性变换
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1]

    def forward(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

解码器中的Linear层

PyTorch 中的 nn.Linear(in_features, out_features) 是一个全连接层,它只会对 最后一个维度 进行线性变换。

  1. 输入形状可以是任意的,只要最后一个维度是 in_features。
  2. 输出会保持前面所有维度不变,仅替换最后一个维度为 out_features。
    例如:
linear = nn.Linear(100, 50)

x = torch.randn(2, 3, 100)   # shape: (A, B, 100)
y = linear(x)                # shape: (A, B, 50)

解码器中的context介绍

当然,下面是对这一行代码的详细解释:

context = state[-1].repeat(X.shape[0], 1, 1)

🔍 作用

这行代码的作用是 将编码器的最终隐藏状态(context)复制(广播)到与输入序列 X 相同的时间步数(sequence length)上,使得在每一个解码时间步都能使用这个上下文信息。

这是典型的 注意力机制之前的常用做法,即把编码器的最终状态作为“上下文向量”传给解码器,并在每个解码步骤中重复使用。


📌 拆解分析

state[-1]
  • state 是从编码器传递过来的隐藏状态。
  • 在 GRU 或 LSTM 中,state 通常是一个张量,形状为:
    • (num_layers, batch_size, num_hiddens)
  • state[-1] 表示取最后一层的隐藏状态(通常是最终的上下文表示),其形状为:
    • (batch_size, num_hiddens)
.repeat(X.shape[0], 1, 1)
  • X.shape[0] 是时间步数(即序列长度 num_stepsL)。
  • .repeat(a, b, c) 对张量在各维度上进行复制。这里的操作是:
    • 第一个维度(时间步维度)复制 X.shape[0] 次,
    • 后两个维度保持不变(分别是 batch_sizenum_hiddens)。

因此,结果 context 的形状变为:

  • (num_steps, batch_size, num_hiddens)

这样它就可以和嵌入后的输入 X(形状也为 (num_steps, batch_size, embed_size))拼接在一起了。


🧠 为什么要这样做?

在传统的 Seq2Seq 架构中:

  • 编码器将整个输入序列压缩成一个固定大小的上下文向量(context vector)。
  • 这个向量被当作“初始状态”或“全局信息”传给解码器。
  • 然而,在每一步解码中,我们希望模型都能访问这个全局信息。
  • 所以最简单的做法就是把这个 context 向量 在时间维度上复制多次,使其能与每个解码时间步结合。

📈 示例说明

假设:

  • X.shape = (5, 32, 128) → 5 个时间步,32 个样本,每个词嵌入维度为 128
  • state[-1].shape = (32, 256) → 来自编码器的最后一层隐藏状态,每个样本 256 维

执行后:

  • context.shape = (5, 32, 256) → 每个时间步都拥有相同的上下文信息

然后你可以将它与输入 X 拼接:

X_and_context = torch.cat((X, context), dim=2)

此时 X_and_context 的形状为 (5, 32, 128+256) = (5, 32, 384),可以送入 GRU 层处理。


创建损失函数掩码

#@save
def sequence_mask(X, valid_len, value=0):
    """在序列中屏蔽不相关的项"""
    maxlen = X.size(1)
    mask = torch.arange((maxlen), dtype=torch.float32,
                        device=X.device)[None, :] < valid_len[:, None]
    X[~mask] = value
    return X

X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2]))

这段代码用于创建一个掩码(mask),该掩码用来标识哪些位置应该被保留,哪些位置应该被屏蔽(即填充的部分)。为了更好地理解这个过程,我们可以将其拆解为几个步骤来详细解释。

代码拆解

mask = torch.arange((maxlen), dtype=torch.float32, device=X.device)[None, :] < valid_len[:, None]

1. torch.arange(maxlen)

  • 作用: 创建一个从0开始到maxlen-1的序列。
  • 结果: 假设maxlen=3,则生成的张量为 tensor([0., 1., 2.])。注意这里使用了dtype=torch.float32以确保数据类型正确。

2. [None, :]

  • 作用: 在维度0上添加一个新的维度,即将一维张量转换为二维张量。
  • 结果: 将上述张量转换为形状为 (1, maxlen) 的张量。例如,对于maxlen=3,结果是 [[0., 1., 2.]]

3. valid_len[:, None]

  • 作用: 在维度1上添加一个新的维度,即将一维张量转换为二维张量。
  • 结果: 将valid_len张量转换为形状为 (batch_size, 1) 的张量。例如,如果valid_len = torch.tensor([1, 2]),则转换后为 [[1], [2]]

4. 广播机制进行比较 <

  • 作用: 使用广播机制对两个不同形状的张量进行逐元素比较。
  • 具体操作:
    • 左边的张量形状变为 (1, maxlen)
    • 右边的张量形状变为 (batch_size, 1)
    • 通过广播机制,这两个张量会被扩展成相同大小 (batch_size, maxlen),然后进行逐元素比较。

5. 比较的结果

  • 作用: 对于每个批次中的样本,生成一个布尔值矩阵,指示哪些位置属于有效长度内的部分。
  • 结果: 如果maxlen=3valid_len = torch.tensor([1, 2]),那么比较的结果将是:
    [[True, False, False],
     [True, True, False]]
    
    • 第一行表示第一个样本的有效长度为1,因此只有第一个位置为True,其余为False
    • 第二行表示第二个样本的有效长度为2,因此前两个位置为True,最后一个位置为False

完整示例

假设输入如下:

X = torch.tensor([[1, 2, 3], [4, 5, 6]])
valid_len = torch.tensor([1, 2])
maxlen = X.size(1)  # maxlen = 3

执行以下步骤:

  1. torch.arange(maxlen) 生成 tensor([0., 1., 2.])
  2. 添加维度 [None, :] 得到 [[0., 1., 2.]]
  3. valid_len[:, None] 转换为 [[1], [2]]
  4. 进行广播比较得到掩码:
    [[True, False, False],
     [True, True, False]]
    

最终,这个掩码可以用来选择或修改张量X中对应的位置,例如将超出有效长度的部分设置为特定值(如0)。

应用实例

在你的例子中,sequence_mask 函数会使用这个掩码来修改输入张量X,使得超出有效长度的所有值都被替换为指定的value(默认为0)。

def sequence_mask(X, valid_len, value=0):
    """在序列中屏蔽不相关的项"""
    maxlen = X.size(1)
    mask = torch.arange((maxlen), dtype=torch.float32,
                        device=X.device)[None, :] < valid_len[:, None]
    X[~mask] = value  # 将不在有效长度范围内的位置设置为value
    return X

这样,你就可以有效地处理变长序列的问题了。

完整损失函数实现

#@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):
        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

你贴出的内容是 PyTorch 中 nn.CrossEntropyLoss 的官方文档说明,非常详细地描述了这个损失函数的使用方式、参数含义、输入输出形状以及数学定义。下面我将用通俗易懂的语言为你系统性地解释 CrossEntropyLoss 是什么、怎么用、为什么重要。


损失函数中的 CrossEntropyLoss

CrossEntropyLoss 是深度学习中最常用的分类任务损失函数之一,尤其适用于多类别分类问题(如图像识别、文本分类等)。

它结合了两个步骤:

  1. Softmax 函数:将模型输出的原始 logits 转换为概率分布。
  2. 负对数似然损失 (Negative Log Likelihood Loss, NLLLoss):衡量预测概率与真实标签之间的差异。

所以你可以简单理解为:

CrossEntropyLoss = LogSoftmax + NLLLoss


🔧 使用方法

import torch
import torch.nn as nn

loss_fn = nn.CrossEntropyLoss()

# 假设我们有3个样本,5个类别
input = torch.randn(3, 5)         # 模型输出 logits,形状: [batch_size, num_classes]
target = torch.tensor([1, 0, 3])  # 真实类别索引,范围 [0, 4],形状: [batch_size]

loss = loss_fn(input, target)

📐 输入和目标的形状要求

类型 形状
input (logits) (N, C)(C)(N, C, d1, d2, ..., dK)
target (类别索引) (N)()(N, d1, d2, ..., dK)
  • N: batch size
  • C: 类别数量
  • d1, d2,...: 可选的空间维度(例如用于图像像素级分类)

🎯 参数详解

参数名 默认值 含义
weight None 给每个类别分配权重,处理类别不平衡问题
ignore_index -100 忽略某个特定类别的损失计算
reduction 'mean' 对损失的归约方式:'none'/'mean'/'sum'
label_smoothing 0.0 标签平滑,缓解过拟合(论文中常用)

✅ 示例代码

import torch
import torch.nn as nn

loss_fn = nn.CrossEntropyLoss()
input = torch.randn(3, 5)            # batch_size=3, num_classes=5
target = torch.tensor([1, 0, 3])     # 每个样本的真实类别
loss = loss_fn(input, target)
print(loss.item())

训练

#@save
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
    """训练序列到序列模型"""
    def xavier_init_weights(m):
        if type(m) == nn.Linear:
            nn.init.xavier_uniform_(m.weight)
        if type(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 = d2l.Animator(xlabel='epoch', ylabel='loss',
                     xlim=[10, num_epochs])
    for epoch in range(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]
      #      Y: tensor([[ 6,  7, 40,  4,  3,  1,  1,  1],
     #   [ 0,  5,  3,  1,  1,  1,  1,  1]], dtype=torch.int32)
            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()	# 损失函数的标量进行“反向传播”
            d2l.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)}')


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 = 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)
net = d2l.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

预测

#@save
def predict_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 = d2l.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 _ in range(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

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))
    for n in range(1, k + 1):
        num_matches, label_subs = 0, collections.defaultdict(int)
        for i in range(len_label - n + 1):
            label_subs[' '.join(label_tokens[i: i + n])] += 1
        for i in range(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

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 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}')

预测部分代码的缺陷

Seq2SeqDecoder设计中state既和X拼接当做输入,又当做rnn初始的隐状态。这在训练的时候是没问题的,encoder最后的隐状态 encoder_state一次性拼接了所有时间步的X,也当做decoder的起始状态。
但在预测的时候,因为是用for循环一步一步更新的,每步都调用一次Seq2SeqDecoder,过程中改变了state, state变成了上一时刻的隐状态,输入rnn没有问题,但因为不再是encoder_state,和X拼接后就和训练不一致了。
比较简单的改法是修改Decoder,更新state的同时,保留encoder_state用于拼接。或者按照另一种设计方式,删除拼接步骤,encoder_state只用于做初始化。

posted @ 2025-05-20 18:01  玉米面手雷王  阅读(50)  评论(0)    收藏  举报