现代循环神经网络7-序列到序列学习(seq2seq)

1. 什么是序列到序列学习?

想象你要把一句英文翻译成法文。输入是一个单词序列(如"They are watching"),输出是另一个长度可能不同的序列(如"Ils regardent")。序列到序列学习(seq2seq) 就是专门处理这类输入输出都是序列的任务的模型。它像两个配合的机器人:一个负责"理解"输入句子(编码器),另一个负责"生成"输出句子(解码器)。

核心思想

  • 编码器:将变长的输入序列转换为固定长度的"上下文向量"(好比把一篇文章压缩成摘要)。
  • 解码器:根据上下文向量逐步生成输出序列,每次生成一个词(像根据摘要写读后感)。

2. 编码器:把句子变成"密码"

2.1 工作原理

编码器通常用循环神经网络(RNN)实现。假设输入是"They are watching",每个单词依次进入RNN:

  1. RNN读取"They",更新内部状态。
  2. 读取"are",再次更新状态。
  3. 读取"watching",得到最终状态 <math xmlns="http://www.w3.org/1998/Math/MathML"> h 3 h_3 </math>h3。

这个最终状态 <math xmlns="http://www.w3.org/1998/Math/MathML"> h 3 h_3 </math>h3就是整个句子的"上下文向量"。数学描述为:

<math xmlns="http://www.w3.org/1998/Math/MathML"> h t = f ( x t , h t − 1 ) \boxed{h_t = f(x_t, h_{t-1})} </math>ht=f(xt,ht−1)

<math xmlns="http://www.w3.org/1998/Math/MathML"> c = h T \boxed{c = h_T} </math>c=hT

其中:

  • <math xmlns="http://www.w3.org/1998/Math/MathML"> h t h_t </math>ht是时间步 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t的隐状态
  • <math xmlns="http://www.w3.org/1998/Math/MathML"> f f </math>f是RNN的计算函数(如GRU、LSTM)
  • <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c是上下文向量(最后一步的 <math xmlns="http://www.w3.org/1998/Math/MathML"> h T h_T </math>hT)

2.2 代码示例

现在,让我们实现循环神经网络编码器。注意,我们使用了 嵌入层(embedding layer) 来获得输入序列中每个词元的特征向量。嵌入层的权重是一个矩阵,其行数等于输入词表的大小(vocab_size),其列数等于特征向量的维度(embed_size)。对于任意输入词元的索引i,嵌入层获取权重矩阵的第i行(从0开始)以返回其特征向量。另外,本文选择了一个多层门控循环单元来实现编码器。

python 复制代码
class Seq2SeqEncoder(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

下面,我们实例化上述编码器的实现:

python 复制代码
import torch

import d2l

encoder = d2l.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)  # 输出:torch.Size([7, 4, 16])
print(state.shape)  # 输出:torch.Size([2, 4, 16])

3. 解码器:从"密码"生成句子

3.1 工作流程

解码器同样用RNN实现,但它的初始状态是编码器的上下文向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c。生成过程如下:

  1. 初始输入是<bos>(序列开始符),状态是 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c。
  2. 预测第一个词"Ils",并更新状态。
  3. 以"Ils"作为新输入,预测下一个词"regardent"。
  4. 直到输出<eos>(结束符)停止。

数学表达式:

<math xmlns="http://www.w3.org/1998/Math/MathML"> s t = g ( y t − 1 , s t − 1 , c ) \boxed{s_t = g(y_{t-1}, s_{t-1}, c)} </math>st=g(yt−1,st−1,c)

<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( y t ∣ y < t , c ) = softmax ( W s t ) \boxed{P(y_t | y_{<t}, c) = \text{softmax}(W s_t)} </math>P(yt∣y<t,c)=softmax(Wst)

3.2 代码实现

python 复制代码
class Seq2SeqDecoder(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)

    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), dim=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

下面,我们用与前面提到的编码器中相同的超参数来实例化解码器。

python 复制代码
decoder = d2l.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)  # 输出:torch.Size([4, 7, 10])
print(state.shape)  # 输出:torch.Size([2, 4, 16])

4. 损失函数:屏蔽无效位置

4.1 问题背景

句子长度不同需要填充(如用<pad>补齐)。计算损失时应忽略填充部分。

示例:

  • 真实标签:[Ils, regardent, <eos>, <pad>]
  • 预测标签:[Ils, mangent, <eos>, <pad>]

只需计算前3个位置的损失。

4.2 实现方法

python 复制代码
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


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
  • torch.arange(maxlen, dtype=torch.float32, device=X.device)[None, :]
    • None 在索引操作中等价于 numpy.newaxis,用于在指定维度上增加一个新的维度(即扩展张量的维度)。
python 复制代码
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(d2l.sequence_mask(X, torch.tensor([1, 2])))

loss = d2l.MaskedSoftmaxCELoss()
print(loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),
           torch.tensor([4, 2, 0])))

输出:

text 复制代码
tensor([[1, 0, 0],
        [4, 5, 0]])
        
tensor([2.3026, 1.1513, 0.0000])

5. 训练技巧:强制教学与梯度裁剪

5.1 强制教学(Teacher Forcing)

训练时,解码器的输入使用真实标签而非预测结果,避免错误累积。

示例:

  • 输入:<bos>, Ils, regardent
  • 而非:<bos>, 预测词1, 预测词2
python 复制代码
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)
        elif 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 = Animator(xlabel='epoch', ylabel='loss',
                        xlim=[10, num_epochs], figsize=(6.18, 3.82))
    for epoch in range(num_epochs):
        timer = Timer()
        metric = 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()  # 损失函数的标量进行"反向传播"
            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)}')

5.2 梯度裁剪

防止梯度爆炸,设定阈值裁剪梯度。

python 复制代码
grad_clipping(net, 1)

grad_clipping 定义参见 循环神经网络5-从零开始实现循环神经网络

python 复制代码
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 = d2l.Seq2SeqEncoder(vocab_size=len(src_vocab), embed_size=embed_size,
                             num_hiddens=num_hiddens, num_layers=num_layers)
decoder = d2l.Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers)
net = d2l.EncoderDecoder(encoder, decoder)
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

6. 预测:逐步生成输出

预测时解码器每一步的输入是上一步的预测结果:

python 复制代码
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 = 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

7. 评估翻译质量:BLEU分数

BLEU通过比较预测与参考翻译的n-gram相似度打分:

<math xmlns="http://www.w3.org/1998/Math/MathML"> B L E U = exp ⁡ ( min ⁡ ( 0 , 1 − l e n l a b e l l e n p r e d ) ∏ n = 1 k p n 1 / 2 n BLEU = \exp\left(\min(0, 1 - \frac{len_{label}}{len_{pred}}\right) \prod_{n=1}^k p_n^{1/2^n} </math>BLEU=exp(min(0,1−lenpredlenlabel)∏n=1kpn1/2n

其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> p n p_n </math>pn是n-gram精确度。

示例:

  • 参考翻译:Ils regardent .
  • 预测:Ils entendent .
  • 1-gram精确度:1/2("Ils"正确)
  • 2-gram精确度:0/1
  • BLEU得分: <math xmlns="http://www.w3.org/1998/Math/MathML"> exp ⁡ ( 1 − 3 / 2 ) × ( 0. 5 0.5 × 0 0.25 ) = 0 \exp(1 - 3/2) \times (0.5^{0.5} \times 0^{0.25}) = 0 </math>exp(1−3/2)×(0.50.5×00.25)=0
python 复制代码
def bleu(pred_seq, label_seq, k):
    """计算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
python 复制代码
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 = d2l.predict_seq2seq(
        net, eng, src_vocab, tgt_vocab, num_steps, device)
    print(f'{eng} => {translation}, bleu {d2l.bleu(translation, fra, k=2):.3f}')

8. 小结

  • 编码器-解码器结构 是处理序列转换任务的基础框架。
  • 遮蔽损失强制教学 是训练时的关键技巧。
  • BLEU分数 客观评估生成质量。
  • 实际应用时,可以使用注意力机制(后续章节)进一步提升性能。

通过本篇,你已经掌握了机器翻译等seq2seq任务的核心原理。试着用PyTorch实现一个简单的翻译模型吧!

相关推荐
闲人编程1 小时前
图像分类:CIFAR10/ImageNet实战
pytorch·深度学习·图像识别
CoovallyAIHub1 小时前
99.22%准确率!EfficientNet优化算法实现猪肉新鲜度无损快检
深度学习·算法·计算机视觉
王国强20092 小时前
现代循环神经网络8-束搜索:在翻译与生成任务中寻找最优解的智慧策略
深度学习
AI Echoes2 小时前
llama-factory微调
人工智能·深度学习·机器学习·deepseek
紫雾凌寒4 小时前
自然语言处理 | 文本清洗的20种核心策略:从数据噪声到信息价值
人工智能·深度学习·自然语言处理·文本清洗·数据噪声·词干提取·高级文本规范化
一头大学牲5 小时前
深度学习:分类和回归的区别
深度学习·分类·回归
蔗理苦5 小时前
2025-03-15 Python&深度学习2——Numpy库
pytorch·python·深度学习·numpy
HABuo6 小时前
【YOLOv8】YOLOv8改进系列(6)----替换主干网络之VanillaNet
人工智能·深度学习·yolo·目标检测·计算机视觉
youngerwang7 小时前
【嵌入式硬件测试之道连载之开篇语+第一章】
网络·功能测试·嵌入式硬件·深度学习·硬件架构·硬件工程·测试覆盖率