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层数, 批量大小,隐藏层大小) 的张量,包含批次中每个元素的最终隐藏状态。
画图如下:

解码器
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) 是一个全连接层,它只会对 最后一个维度 进行线性变换。
- 输入形状可以是任意的,只要最后一个维度是 in_features。
- 输出会保持前面所有维度不变,仅替换最后一个维度为 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_steps或L)。.repeat(a, b, c)对张量在各维度上进行复制。这里的操作是:- 第一个维度(时间步维度)复制
X.shape[0]次, - 后两个维度保持不变(分别是
batch_size和num_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 个样本,每个词嵌入维度为 128state[-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=3且valid_len = torch.tensor([1, 2]),那么比较的结果将是:[[True, False, False], [True, True, False]]- 第一行表示第一个样本的有效长度为1,因此只有第一个位置为
True,其余为False。 - 第二行表示第二个样本的有效长度为2,因此前两个位置为
True,最后一个位置为False。
- 第一行表示第一个样本的有效长度为1,因此只有第一个位置为
完整示例
假设输入如下:
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
valid_len = torch.tensor([1, 2])
maxlen = X.size(1) # maxlen = 3
执行以下步骤:
torch.arange(maxlen)生成tensor([0., 1., 2.])- 添加维度
[None, :]得到[[0., 1., 2.]] valid_len[:, None]转换为[[1], [2]]- 进行广播比较得到掩码:
[[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 是深度学习中最常用的分类任务损失函数之一,尤其适用于多类别分类问题(如图像识别、文本分类等)。
它结合了两个步骤:
- Softmax 函数:将模型输出的原始 logits 转换为概率分布。
- 负对数似然损失 (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 sizeC: 类别数量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只用于做初始化。

浙公网安备 33010602011771号