PyTorch 2.x 深度学习专题【左扬精讲】—— Seq2Seq模型:编码器-解码器架构

PyTorch 2.x 深度学习专题【左扬精讲】—— Seq2Seq模型:编码器-解码器架构详解

先说核心结论

Seq2Seq是现代序列建模的起点。它用 "编码器-解码器" 架构解决了变长序列映射问题:编码器将任意长度的输入压缩为固定向量,解码器从向量生成变长输出。这个看似简单的设计,为注意力机制和Transformer奠定了全部基础。

理解Seq2Seq,才能真正理解三个递进的问题:

  • 为什么需要注意力机制?——因为固定向量是瓶颈
  • 为什么需要Transformer?——因为RNN无法并行
  • 为什么需要GPT和BERT?——因为Seq2Seq的解码和编码可以各自强化

本文系统讲解Seq2Seq模型的核心组件:编码器、解码器、上下文向量、Bahdanau注意力机制,以及在PyTorch中的完整实现(LSTM作为编码器和解码器)。

torch.nn.LSTM              ← 编码器和解码器的基础(RNN变体)
torch.nn.GRU                ← LSTM的简化版,同样可用于Seq2Seq
nn.Embedding                ← 词嵌入层,将词ID映射为向量
torch.nn.Linear             ← 解码器的输出层(vocab投影)
torch.nn.CrossEntropyLoss   ← Seq2Seq训练的损失函数
nn.ModuleList               ← 存储多层解码器(用于堆叠)

PyTorchSeq2SeqEncoder-Decoder注意力机制LSTMGRU机器翻译Bahdanau Attention

学习重点

  • 必须掌握
  • Seq2Seq的核心思想:编码器将变长序列压缩为固定向量,解码器从向量生成变长输出
  • 编码器-解码器架构(Encoder-Decoder Architecture)的标准定义
  • 固定长度上下文向量(Context Vector)的瓶颈问题:信息压缩导致长序列性能下降
  • Bahdanau注意力(加性注意力)的计算流程:对齐分数、权重、上下文向量
  • 自回归解码(Autoregressive Decoding):贪婪搜索(Greedy Search)与束搜索(Beam Search)
  • 理解即可
  • Luong注意力(乘性注意力)与Bahdanau注意力的区别
  • 教师强制(Teacher Forcing)的训练策略
  • 双向LSTM编码器的作用

目录


一、概述:Seq2Seq在深度学习发展史中的位置

What — Seq2Seq是什么?

Seq2Seq(Sequence-to-Sequence,序列到序列)是一种深度学习架构,用于将一个变长序列映射到另一个变长序列。核心应用场景包括机器翻译、文本摘要、对话生成、语音识别等,这些任务的共同特点是输入和输出都是变长序列,且长度不固定。

发展脉络与关键论文

2014年 Sutskever等人《Sequence to Sequence Learning with Neural Networks》
  - 提出经典的编码器-解码器架构
  - 使用两个深层LSTM:编码器将序列压缩为固定向量,解码器从向量生成输出
  - WMT-14英法翻译任务BLEU得分34.8(超过基于短语的SMT系统33.3)

2014年 Cho等人《Learning Phrase Representations using RNN Encoder-Decoder》
  - 提出RNN Encoder-Decoder框架
  - 引入了GRU的门控机制

2015年 Bahdanau等人《Neural Machine Translation by Jointly Learning to Align and Translate》
  - 引入注意力机制,解决了固定向量的信息瓶颈
  - 使用双向RNN作为编码器,每个词对应一个上下文向量
  - 解码器在生成每个词时动态选择相关的输入部分

2017年 Vaswani等人《Attention Is All You Need》
  - Transformer架构:完全基于注意力,摒弃RNN
  - 成为现代NLP的主流基础

本节小结

  • Seq2Seq的核心:解决变长序列到变长序列的映射问题
  • 2014年Sutskever论文:奠定了编码器-解码器架构的基础
  • 2015年Bahdanau注意力:解决了固定向量的瓶颈问题
  • 2017年Transformer:完全基于注意力,成为现代主流

二、Seq2Seq核心:编码器-解码器架构

What — 编码器-解码器架构是什么?

编码器-解码器(Encoder-Decoder)架构是Seq2Seq的核心,由两个子网络组成:编码器负责将输入序列压缩为一个固定维度的向量表示,解码器负责从该向量生成输出序列。两个子网络可以是LSTM、GRU或Transformer的编码器/解码器层。

Why — 为什么需要编码器和解码器分离?

Seq2Seq的核心挑战是处理变长序列的映射。传统神经网络要求输入输出维度固定,无法直接处理变长序列。编码器-解码器架构通过两步解决:

第一步(编码器):将变长输入序列压缩为固定维度向量
  输入: [w1, w2, w3, ..., wn]  变长序列
  编码: h_enc = Encoder([w1, w2, w3, ..., wn])
  输出: h_enc  固定维度向量(如256维、512维)

第二步(解码器):从固定向量生成变长输出序列
  输入: h_enc(编码器输出的固定向量)
  解码: [v1, v2, v3, ..., vm] = Decoder(h_enc)
  输出: [v1, v2, v3, ..., vm]  变长序列(长度m可以与n不同)

没有编码器-解码器架构会发生什么?

  • 后果1 — 无法处理变长序列:传统DNN的输入输出维度必须固定,无法处理"你好"翻译成"hello"(2词到5词)这样的任务
  • 后果2 — 无法跨序列传递信息:没有编码器,模型无法将整个句子的语义压缩后传递给解码器
  • 后果3 — 序列建模能力受限:直接用一个RNN处理输入和输出,无法处理输入输出长度不同的任务

编码器-解码器架构的意义:将"理解"和"生成"解耦。编码器负责理解输入序列的语义,解码器负责组织输出序列的语言。这种解耦让模型可以处理任意长度的输入输出组合。

标准写法 — 基于LSTM的Seq2Seq模型(Encoder-Decoder)
import torch
import torch.nn as nn

# 编码器:使用LSTM将输入序列编码为固定向量
class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)  # 词嵌入层
        self.lstm = nn.LSTM(embed_dim, hidden_size, batch_first=True)  # LSTM编码器

    def forward(self, src, src_lengths):
        # src: (batch_size, src_len)  输入词ID序列
        # src_lengths: (batch_size,)  每个序列的实际长度
        embedded = self.embedding(src)  # (batch, src_len, embed_dim)  词向量
        packed = nn.utils.rnn.pack_padded_sequence(
            embedded, src_lengths.cpu(), batch_first=True, enforce_sorted=False
        )  # 压缩变长序列
        outputs, (hidden, cell) = self.lstm(packed)  # 编码
        # hidden: (1, batch, hidden_size)  最终隐状态(作为上下文向量)
        # cell: (1, batch, hidden_size)  最终细胞状态
        outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs, batch_first=True)
        return outputs, hidden, cell

# 解码器:使用LSTM从上下文向量生成输出序列
class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, vocab_size)  # 输出层:hidden_size -> vocab_size

    def forward(self, tgt, hidden, cell):
        # tgt: (batch_size, 1)  当前时间步的输入词ID(逐词输入)
        # hidden: (1, batch, hidden_size)  来自编码器的隐状态
        # cell: (1, batch, hidden_size)  来自编码器的细胞状态
        embedded = self.embedding(tgt)  # (batch, 1, embed_dim)
        output, (hidden, cell) = self.lstm(embedded, (hidden, cell))  # 解码一步
        # output: (batch, 1, hidden_size)
        prediction = self.fc(output)  # (batch, 1, vocab_size)  预测下一个词
        return prediction.squeeze(1), hidden, cell  # 预测词ID logits

# Seq2Seq模型:组合编码器和解码器
class Seq2Seq(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size):
        super().__init__()
        self.encoder = Encoder(vocab_size, embed_dim, hidden_size)
        self.decoder = Decoder(vocab_size, embed_dim, hidden_size)

    def forward(self, src, tgt, src_lengths):
        # src: (batch_size, src_len)  源序列
        # tgt: (batch_size, tgt_len)  目标序列(训练时提供)
        # 编码阶段
        _, hidden, cell = self.encoder(src, src_lengths)
        # 解码阶段:教师强制(Teacher Forcing)
        batch_size = tgt.size(0)
        tgt_len = tgt.size(1)
        outputs = []
        for i in range(tgt_len):
            pred, hidden, cell = self.decoder(tgt[:, i:i+1], hidden, cell)
            # 传入真实目标词作为下一步输入(教师强制)
            outputs.append(pred)  # 收集每步的预测
        outputs = torch.stack(outputs, dim=1)  # (batch, tgt_len, vocab_size)
        return outputs

注意事项

  • 编码器隐状态作为上下文向量:编码器的最后一个隐状态(hidden)被直接传给解码器,作为整个输入序列的压缩表示
  • 教师强制(Teacher Forcing):训练时将真实目标词作为解码器的输入,可以加速收敛,但可能导致推理时性能下降(暴露偏差问题)
  • 填充符处理:使用 pack_padded_sequence 忽略填充部分,避免无效计算

本节小结

  • 编码器:将变长输入序列压缩为固定维度隐状态向量
  • 解码器:从固定向量自回归生成变长输出序列
  • 上下文向量:编码器最后一个隐状态携带整个输入序列的信息
  • 教师强制:训练时使用真实目标词作为解码器输入

三、固定长度向量的瓶颈问题

What — 固定长度上下文向量的问题是什么?

Seq2Seq架构的核心瓶颈是将整个输入序列的信息压缩到一个固定维度的向量中。当输入序列很长时,这种压缩不可避免地会导致信息丢失,成为模型性能的主要限制因素。

Why — 为什么固定向量会成为瓶颈?

固定长度向量的问题

假设:hidden_size = 256维,即上下文向量最多携带 256 * 4字节 = 1KB 的信息

短句子(如5词):
  输入: "I love ML" (3词)
  编码器可以将每个词的信息都压缩到向量中
  上下文向量能较好地保留语义

长句子(如50词):
  输入: "In this paper, we propose a novel approach that... [中间省略40词] ...which achieves state-of-the-art results."
  编码器必须将50个词的信息压缩到同一个256维向量中
  上下文向量被"强制"平均化,丢失了细节信息

瓶颈的本质:
  - 维度固定意味着信息容量固定
  - 无论输入多长,都塞进同样大小的向量
  - 信息被"暴力压缩",细节丢失

没有注意力机制会发生什么?

  • 后果1 — 长距离依赖丢失:长句子的开头和结尾无法同时被记住,中间部分的信息被平均化
  • 后果2 — 梯度传播困难:解码器反向传播时,梯度必须通过固定的上下文向量传递,远处词汇的影响被稀释
  • 后果3 — 性能随句子长度下降:BLEU等指标在长句子上显著低于短句子,形成"信息瓶颈"

解决思路:不要只用一个固定向量,而是让解码器在生成每个词时,能够"查看"输入序列的不同部分。这就是注意力机制的核心思想。

瓶颈的可视化:固定向量 vs 注意力机制
固定向量(无注意力):
  输入: "I love machine learning because it is powerful"
  编码: [h1, h2, h3, h4, h5, h6, h7] -> c(1个固定向量)
  解码: c -> "我喜欢机器学习因为它很强大"
  问题: c必须包含所有词的信息,信息被"平均化"

注意力机制:
  输入: "I love machine learning because it is powerful"
  编码: [h1, h2, h3, h4, h5, h6, h7](保留所有隐状态)
  解码第1步: 关注h1 -> "我"
  解码第2步: 关注h1,h2 -> "喜欢"
  解码第3步: 关注h3,h4 -> "机器"
  解码第4步: 关注h3,h4,h5 -> "学习"
  ...(每步动态选择相关输入)
  优势: 可以直接访问任意输入位置,无信息瓶颈

本节小结

  • 固定向量瓶颈:将所有输入信息压缩到固定维度向量中,信息容量受限
  • 长句子问题:信息被"暴力压缩",细节和长距离依赖丢失
  • 解决方向:用注意力机制替代单一固定向量,每步动态关注相关输入
  • 注意力是Seq2Seq的转折点:Bahdanau 2015年的论文解决了这个瓶颈

四、Bahdanau注意力机制:打破信息瓶颈

What — Bahdanau注意力是什么?

Bahdanau注意力(又称加性注意力)由Bahdanau、Cho和Bengio于2015年提出,核心思想是:解码器在生成每个词时,允许"查看"整个输入序列,并自动学习哪些部分与当前输出最相关。这是一种软对齐(Soft Alignment)机制,可以端到端训练。

Why — 为什么注意力能解决问题?

Bahdanau注意力的关键创新是引入了"对齐模型"(Alignment Model),它为输入的每个位置计算一个权重,表示当前解码位置对该输入位置的"关注程度":

Bahdanau注意力的计算流程(加性注意力)

第一步:对齐分数(Alignment Scores)
  e_ti = v^T * tanh(W_a * s_{t-1} + U_a * h_i)
  - s_{t-1}: 解码器上一步的隐状态(Query)
  - h_i: 编码器第i个位置的隐状态(Key)
  - W_a, U_a, v: 可学习的权重矩阵
  - e_ti: 第t步对输入第i个位置的对齐分数

第二步:注意力权重(Attention Weights)
  alpha_ti = exp(e_ti) / sum_j(exp(e_tj))
  - 对所有对齐分数做softmax,得到归一化的注意力权重
  - alpha_ti 表示第t步对第i个输入的关注程度

第三步:上下文向量(Context Vector)
  c_t = sum_i(alpha_ti * h_i)
  - 用注意力权重对所有编码器隐状态加权求和
  - c_t 是第t步的"动态上下文向量"

第四步:解码器更新
  s_t = f(s_{t-1}, y_{t-1}, c_t)
  - 将上下文向量接入解码器,生成新的隐状态
  - 每一步都有针对性的输入信息

没有注意力机制会发生什么?

  • 后果1 — 信息瓶颈依然存在:解码器只能通过固定向量获取输入信息,长距离依赖无法建立
  • 后果2 — 无法处理长句子:BLEU等指标随句子长度增加而急剧下降
  • 后果3 — 缺乏可解释性:无法知道模型在生成某个词时关注了输入的哪些部分

Bahdanau注意力的意义:首次提出可学习的软对齐机制,让模型能够自动发现输入和输出之间的对应关系。这为后续的Transformer自注意力机制奠定了基础。

标准写法 — 带Bahdanau注意力的Seq2Seq解码器
import torch
import torch.nn as nn
import torch.nn.functional as F

# Bahdanau注意力(加性注意力)
class BahdanauAttention(nn.Module):
    def __init__(self, hidden_size):
        super().__init__()
        self.W_a = nn.Linear(hidden_size, hidden_size, bias=False)  # 对解码器隐状态做线性变换
        self.U_a = nn.Linear(hidden_size, hidden_size, bias=False)  # 对编码器隐状态做线性变换
        self.v_a = nn.Linear(hidden_size, 1, bias=False)  # 对齐分数输出层

    def forward(self, decoder_hidden, encoder_outputs):
        # decoder_hidden: (batch, hidden_size)  解码器当前隐状态
        # encoder_outputs: (batch, src_len, hidden_size)  编码器所有隐状态
        batch_size = encoder_outputs.size(0)
        src_len = encoder_outputs.size(1)
        # 扩展decoder_hidden以匹配encoder_outputs的序列长度
        decoder_hidden_expanded = decoder_hidden.unsqueeze(1).repeat(1, src_len, 1)
        # 计算对齐分数:(batch, src_len, hidden_size) -> (batch, src_len, 1)
        scores = self.v_a(torch.tanh(self.W_a(decoder_hidden_expanded) + self.U_a(encoder_outputs)))
        scores = scores.squeeze(-1)  # (batch, src_len)
        # 归一化为注意力权重
        attention_weights = F.softmax(scores, dim=-1)  # (batch, src_len)
        # 加权求和得到上下文向量
        context = torch.bmm(attention_weights.unsqueeze(1), encoder_outputs)  # (batch, 1, hidden_size)
        context = context.squeeze(1)  # (batch, hidden_size)
        return context, attention_weights

# 带注意力的解码器
class AttentionDecoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.attention = BahdanauAttention(hidden_size)  # 注意力模块
        self.lstm = nn.LSTM(embed_dim + hidden_size, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, vocab_size)

    def forward(self, tgt_token, hidden, cell, encoder_outputs):
        # tgt_token: (batch, 1)  当前输入词ID
        # hidden: (1, batch, hidden_size)  解码器隐状态
        # encoder_outputs: (batch, src_len, hidden_size)  编码器所有隐状态
        # 计算上下文向量
        context, attention_weights = self.attention(hidden.squeeze(0), encoder_outputs)
        context = context.unsqueeze(1)  # (batch, 1, hidden_size)
        # 嵌入当前词
        embedded = self.embedding(tgt_token)  # (batch, 1, embed_dim)
        # 将词嵌入和上下文向量拼接
        lstm_input = torch.cat([embedded, context], dim=-1)  # (batch, 1, embed_dim+hidden_size)
        # LSTM更新
        output, (hidden, cell) = self.lstm(lstm_input, (hidden, cell))
        # 预测下一个词
        prediction = self.fc(output.squeeze(1))  # (batch, vocab_size)
        return prediction, hidden, cell, attention_weights

注意事项

  • 加性注意力的计算:先用 tanh 激活,再用线性层输出分数,最后 softmax 归一化
  • 上下文向量维度:与编码器隐状态维度相同,可以直接拼接后输入LSTM
  • 注意力权重可视化:可以打印 attention_weights 查看输入输出的对齐效果
  • 与Luong注意力的区别:Luong使用乘性注意力(score = h_t^T * W * h_s),计算更高效

本节小结

  • Bahdanau注意力:加性注意力,使用 tanh(W * s + U * h) 计算对齐分数
  • 软对齐:每个输出词是所有输入词的加权组合,权重可学习
  • 动态上下文向量:每步生成不同的上下文向量,解决了固定向量的瓶颈
  • 端到端训练:对齐模型作为网络的一部分,与编码器解码器联合优化

五、自回归解码:贪婪搜索与束搜索

What — 自回归解码是什么?

Seq2Seq的解码器是自回归(Autoregressive)的:每一步的输入是上一步的输出,直到生成完整的序列。这种机制让模型能够生成任意长度的输出,但同时也引入了误差累积的问题。

贪婪搜索 vs 束搜索

贪婪搜索(Greedy Search):每步选择概率最高的词
  第1步: P(w|"") -> 选择概率最高的词,如"我"
  第2步: P(w|" 我") -> 选择概率最高的词,如"喜欢"
  第3步: P(w|" 我 喜欢") -> 选择概率最高的词,如"机器"
  ...
  问题: 早期的高概率选择可能导致后续整体概率次优
  例如: "机器"后面跟"学习"比跟"智能"概率更高,但"机器学习"是个常用词组

束搜索(Beam Search):维护k个最可能的候选序列
  k=3(束宽为3):
  第1步: 保留概率最高的3个候选词: ["我", "他", "她"]
  第2步: 对每个候选分别扩展,计算联合概率:
    - "我 喜欢": P(喜欢|我) * 0.8
    - "我 爱": P(爱|我) * 0.7
    - "我 觉得": P(觉得|我) * 0.6
    - "他 觉得": P(觉得|他) * 0.5
    - ...
  第3步: 保留联合概率最高的3个序列
  优势: 考虑全局最优而非局部最优,通常能获得更好的翻译质量

没有束搜索会发生什么?

  • 后果1 — 局部最优陷阱:贪婪搜索只看当前步最优,可能错过全局更优的序列
  • 后果2 — 翻译质量下降:实验表明,束搜索通常能比贪婪搜索提升2-5个BLEU点
  • 后果3 — 缺乏多样性:无法探索多个合理的翻译候选
标准写法 — Seq2Seq推理(贪婪搜索 + 束搜索)
import torch
import torch.nn.functional as F

class Seq2SeqInference:
    def __init__(self, model, tokenizer, device):
        self.model = model
        self.tokenizer = tokenizer
        self.device = device
        self.pad_token_id = tokenizer.pad_token_id
        self.sos_token_id = tokenizer.cls_token_id  # 起始符
        self.eos_token_id = tokenizer.sep_token_id  # 结束符

    def greedy_search(self, src, src_lengths, max_len=50):
        # 贪婪搜索:每步选择概率最高的词
        self.model.eval()
        batch_size = src.size(0)
        with torch.no_grad():
            # 编码
            encoder_outputs, hidden, cell = self.model.encoder(src, src_lengths)
            # 解码
            tgt = torch.full((batch_size, 1), self.sos_token_id, dtype=torch.long, device=self.device)
            finished = torch.zeros(batch_size, dtype=torch.bool, device=self.device)
            outputs = []
            for _ in range(max_len):
                pred, hidden, cell, _ = self.model.decoder(tgt[:, -1:], hidden, cell, encoder_outputs)
                next_token = pred.argmax(dim=-1)  # 选择概率最高的词
                outputs.append(next_token)
                tgt = torch.cat([tgt, next_token], dim=1)
                # 更新完成状态
                finished = finished | (next_token.squeeze(-1) == self.eos_token_id)
                if finished.all():
                    break
            return tgt[:, 1:]  # 去掉起始符

    def beam_search(self, src, src_lengths, max_len=50, beam_size=5):
        # 束搜索:维护k个候选序列
        self.model.eval()
        batch_size = src.size(0)
        assert batch_size == 1, "束搜索仅支持batch_size=1"
        with torch.no_grad():
            encoder_outputs, hidden, cell = self.model.encoder(src, src_lengths)
            # 初始化:每个beam包含 (序列, 分数, 隐状态)
            beams = [(torch.tensor([[self.sos_token_id]], device=self.device), 0.0, hidden, cell)]
            completed = []
            for step in range(max_len):
                candidates = []
                for beam_seq, beam_score, h, c in beams:
                    if beam_seq[0, -1].item() == self.eos_token_id:
                        completed.append((beam_seq, beam_score))
                        continue
                    pred, new_h, new_c, _ = self.model.decoder(
                        beam_seq[:, -1:], h, c, encoder_outputs
                    )
                    log_probs = F.log_softmax(pred, dim=-1)  # 取对数概率
                    topk_log_probs, topk_indices = log_probs.topk(beam_size, dim=-1)
                    for i in range(beam_size):
                        new_seq = torch.cat([beam_seq, topk_indices[:, i:i+1]], dim=1)
                        new_score = beam_score + topk_log_probs[:, i].item()
                        candidates.append((new_seq, new_score, new_h, new_c))
                # 保留分数最高的beam_size个候选
                candidates.sort(key=lambda x: x[1], reverse=True)
                beams = candidates[:beam_size]
                if len(completed) >= beam_size:
                    break
            # 合并已完成和进行中的序列,取分数最高
            all_seqs = completed + [(b[0], b[1]) for b in beams]
            all_seqs.sort(key=lambda x: x[1], reverse=True)
            best_seq = all_seqs[0][0]
            return best_seq[:, 1:]  # 去掉起始符

注意事项

  • 贪婪搜索的局限性:只看当前步最优,无法回溯。适用于实时系统或对延迟敏感的场景
  • 束搜索的权衡:束宽越大搜索质量越高,但计算量也越大。实际常用束宽3-10
  • 长度惩罚:可以加入长度惩罚(length penalty)防止模型倾向于生成短序列
  • 重复问题:束搜索可能导致重复生成相同的词或短语,可以加入n-gram惩罚或去重

本节小结

  • 自回归解码:每步输入是上一步的输出,直到生成结束符
  • 贪婪搜索:每步选择概率最高的词,计算快但可能陷入局部最优
  • 束搜索:维护多个候选序列,通常能获得2-5个BLEU点的提升
  • 实际应用:大多数生产系统使用束搜索,贪婪搜索用于快速原型

六、PyTorch实现:Seq2Seq标准写法

What — torch.nn模块如何支持Seq2Seq?

PyTorch没有内置的端到端Seq2Seq模块,但提供了构建Seq2Seq所需的所有基础模块:nn.LSTMnn.GRUnn.Embeddingnn.Linear等。理解这些模块的组合方式,是实现Seq2Seq的基础。

关键PyTorch模块组合

PyTorch Seq2Seq所需模块

nn.Embedding(vocab_size, embed_dim, padding_idx=0)
  - 将词ID映射为密集向量
  - padding_idx用于标记填充位置(训练时被忽略)

nn.LSTM(input_size, hidden_size, num_layers, batch_first, bidirectional)
  - 编码器和解码器的基础
  - batch_first=True: 输入形状 (batch, seq, features)
  - bidirectional=True: 编码器可使用双向LSTM

nn.utils.rnn.pack_padded_sequence(input, lengths, batch_first, enforce_sorted)
  - 将填充序列压缩为紧凑格式
  - 跳过填充部分,减少无效计算

nn.utils.rnn.pad_packed_sequence(input, batch_first, total_length)
  - 将压缩序列还原为填充序列

nn.CrossEntropyLoss(ignore_index=pad_token_id)
  - Seq2Seq的标准损失函数
  - ignore_index忽略填充位置的损失

nn.Module
  - 自定义Seq2Seq模型需要继承此基类
完整示例 — 机器翻译Seq2Seq模型(带注意力)
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from transformers import BertTokenizer

# 完整的Seq2Seq模型(带注意力 + 束搜索推理)
class TranslationSeq2Seq(nn.Module):
    def __init__(self, src_vocab_size, tgt_vocab_size, embed_dim, hidden_size):
        super().__init__()
        self.src_embedding = nn.Embedding(src_vocab_size, embed_dim, padding_idx=0)
        self.tgt_embedding = nn.Embedding(tgt_vocab_size, embed_dim, padding_idx=0)
        # 编码器:双向LSTM,捕获前后文信息
        self.encoder = nn.LSTM(embed_dim, hidden_size, batch_first=True, bidirectional=True)
        # 注意力层:解码器隐状态和编码器输出的交互
        self.attention = nn.Linear(hidden_size * 2 + embed_dim, hidden_size)
        self.attn_combine = nn.Linear(hidden_size * 2 + embed_dim, hidden_size)
        # 解码器:单向LSTM
        self.decoder = nn.LSTM(hidden_size, hidden_size * 2, batch_first=True)
        self.out = nn.Linear(hidden_size * 2, tgt_vocab_size)

    def forward(self, src, tgt, src_lengths, teacher_forcing_ratio=0.5):
        # src: (batch, src_len), tgt: (batch, tgt_len)
        batch_size = tgt.size(0)
        tgt_len = tgt.size(1)
        # 编码
        embedded = self.src_embedding(src)
        packed = nn.utils.rnn.pack_padded_sequence(embedded, src_lengths.cpu(),
                                                    batch_first=True, enforce_sorted=False)
        encoder_outputs, (hidden, cell) = self.encoder(packed)
        encoder_outputs, _ = nn.utils.rnn.pad_packed_sequence(encoder_outputs, batch_first=True)
        # 合并双向隐状态
        hidden = torch.cat([hidden[-2], hidden[-1]], dim=-1).unsqueeze(0)  # (1, batch, hidden*2)
        cell = torch.cat([cell[-2], cell[-1]], dim=-1).unsqueeze(0)
        # 解码
        decoder_input = tgt[:, 0:1]  # 起始符
        outputs = []
        for i in range(tgt_len - 1):
            # 嵌入
            embedded = self.tgt_embedding(decoder_input)  # (batch, 1, embed)
            # 注意力
            attn_weights = F.softmax(
                self.attention(torch.cat([embedded, hidden.transpose(0, 1), encoder_outputs], dim=-1)),
                dim=1
            )  # (batch, 1, src_len)
            context = torch.bmm(attn_weights, encoder_outputs)  # (batch, 1, hidden*2)
            # 合并嵌入和上下文
            decoder_input_combined = self.attn_combine(torch.cat([embedded, context], dim=-1))
            # LSTM更新
            output, (hidden, cell) = self.decoder(decoder_input_combined, (hidden, cell))
            # 预测
            prediction = self.out(output)  # (batch, 1, vocab)
            outputs.append(prediction)
            # 教师强制
            decoder_input = tgt[:, i+1:i+2] if torch.rand(1).item() < teacher_forcing_ratio else prediction.argmax(dim=-1)
        return torch.cat(outputs, dim=1)

# 训练函数
def train_epoch(model, dataloader, optimizer, criterion, device):
    model.train()
    total_loss = 0
    for src, tgt, src_lengths in dataloader:
        src, tgt = src.to(device), tgt.to(device)
        optimizer.zero_grad()
        outputs = model(src, tgt, src_lengths)  # (batch, tgt_len-1, vocab)
        # 移位:预测第t+1步时输入第t步
        loss = criterion(outputs.reshape(-1, outputs.size(-1)), tgt[:, 1:].reshape(-1))
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)  # 梯度裁剪
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(dataloader)

本节小结

  • 双向LSTM编码器:同时考虑前向和后向上下文,隐状态维度翻倍
  • 注意力层:解码器每步动态关注编码器输出的不同部分
  • 教师强制:训练时以一定概率使用真实目标词,加速收敛
  • 梯度裁剪:防止梯度爆炸,保持训练稳定

七、FAQ(20组)

关于Seq2Seq和注意力机制的20个高频问题

Q1. Seq2Seq和编码器-解码器是一回事吗?

是的,Seq2Seq和编码器-解码器是同一架构的两种叫法。Seq2Seq强调"序列到序列"的输入输出映射能力,编码器-解码器强调其内部结构(编码器压缩序列,解码器生成序列)。两者描述的是同一个东西。

Q2. 为什么Seq2Seq需要两个LSTM(编码器和解码器)?用一个不行吗?

用一个LSTM理论上可行,但效果会差很多。编码器需要"理解"整个输入序列(双向处理),解码器需要"生成"输出序列(单向、自回归)。两者的任务方向相反,共享参数会导致冲突。分开设计让每个网络专注于自己的任务。

Q3. 编码器的最后一个隐状态为什么能代表整个输入序列?

因为LSTM的隐状态是整个序列信息的递归压缩。最后一个时间步的隐状态包含了前面所有时间步的信息累积(通过门机制选择性保留/遗忘)。虽然信息被压缩会有损失,但在短句子上通常足够。

Q4. 为什么编码器要使用双向LSTM?单向不行吗?

双向LSTM能让每个词同时利用前向和后向上下文。对于"我不喜欢你"这句话,单向LSTM处理"不"时只能看到"我",看不到后面的"喜欢你"。双向LSTM在处理每个词时都能看到完整的句子上下文,对于理解语义很重要。

Q5. 教师强制(Teacher Forcing)是什么?有什么优缺点?

教师强制是在训练时,将真实的上一时刻目标词作为解码器输入,而非使用模型自己的预测。优点是加速收敛、训练稳定。缺点是形成"暴露偏差"(exposure bias):训练时输入真实词,推理时输入模型预测词,分布不一致导致错误累积。

Q6. 如何解决教师强制带来的暴露偏差问题?

常用方法有:计划采样(Scheduled Sampling)、增大batch size、教授forcing。计划采样以一定概率在真实词和预测词之间切换,训练时逐步增加使用预测词的比例,让模型逐渐适应自己的错误。

Q7. Bahdanau注意力和Luong注意力的核心区别是什么?

Bahdanau使用加性注意力(tanh激活),Luong使用乘性注意力(直接矩阵乘法)。数学上:Bahdanau的score = v^T * tanh(W*[s;h]),Luong的score = s^T * W * h。Luong通常计算更快,但两者在实践中性能相近。

Q8. 注意力权重可以解释为什么?

注意力权重揭示了输入和输出之间的软对齐关系。在机器翻译中,如果注意力权重在某个位置很高,说明模型在生成对应输出词时主要参考了该输入词。可以可视化注意力矩阵来检查翻译质量。

Q9. 贪婪搜索为什么会陷入局部最优?

因为每步只考虑当前步的最优,不考虑对后续的影响。例如翻译"I think that machine learning is important"时,第一步选了"我认为"而不是"我觉得",虽然"我认为"概率略高,但"我觉得"后面接"机器学习"比"我认为"更自然,导致后续整体翻译质量下降。

Q10. 束搜索的束宽(beam size)设多少合适?

通常设3-10,根据延迟和质量的trade-off调整。束宽3通常能获得接近最优的结果;束宽10能进一步提升但计算量增加3倍;超过10收益递减明显。实时系统可用束宽1-3,离线批处理可用束宽5-10。

Q11. Seq2Seq和Transformer在架构上有什么区别?

Seq2Seq使用RNN(LSTM/GRU)作为编码器和解码器,Transformer用自注意力机制替代。核心区别:RNN是串行计算(时间步依赖),自注意力是并行计算(所有位置同时交互)。Transformer训练更快,但推理时如果不用KV Cache会变慢。

Q12. 为什么Transformer能完全替代Seq2Seq的RNN部分?

因为自注意力机制解决了RNN的所有核心问题:并行计算、长距离依赖、梯度传播。自注意力允许任意位置直接交互,无需通过时间步传递;所有位置可以并行计算;梯度可以直达任意位置。计算复杂度从O(n)变成O(n^2),但换来了建模能力的质变。

Q13. Seq2Seq的训练损失和推理损失为什么不一致?

因为训练时使用教师强制(输入真实词),推理时使用自回归(输入预测词)。训练阶段,解码器每步接收的是ground truth;推理阶段,解码器接收的是上一步的预测。这种差异导致训练损失不能完全反映推理质量。

Q14. 为什么解码时需要定义起始符和结束符?

起始符(<s>)告诉解码器开始生成,结束符(</s>)告诉解码器停止生成。没有起始符,解码器不知道从哪里开始;没有结束符,解码器不知道何时停止,会一直生成到最大长度。结束符是生成序列长度不固定的关键。

Q15. 如何处理Seq2Seq中的未登录词(OOV)问题?

常用方法有:词根化、子词分割(Byte Pair Encoding)、复制机制。词根化将词还原为词根;BPE将词分解为子词单元;复制机制让模型直接复制输入中的罕见词。GPT等模型使用BPE tokenizer,从根本上减少了OOV问题。

Q16. 束搜索和贪婪搜索的时间复杂度分别是多少?

贪婪搜索:O(T * V),T是序列长度,V是词表大小。束搜索:O(k * T * V),k是束宽。贪婪搜索每步只需一次前向传播;束搜索每步需要k次前向传播来扩展每个beam。但束搜索的k通常很小(3-10),整体仍远低于暴力穷举(V^T)。

Q17. 为什么Seq2Seq在长句子上的性能会下降?

因为固定向量容量有限,长句子的信息被过度压缩。解码器只能通过一个固定向量获取输入信息,长句子中的细节和长距离依赖在压缩过程中丢失。注意力机制通过允许直接访问输入的每个位置来解决这个问题。

Q18. Seq2Seq能用于文本摘要吗?和机器翻译有什么区别?

可以,但通常需要额外技巧:指针网络、覆盖机制。文本摘要是"抽取或压缩"式生成,机器翻译是"等义转换"。摘要可能需要复制原文中的专有名词(指针网络),避免重复(覆盖机制)。核心架构相同,但训练目标和技巧有差异。

Q19. 为什么Sutskever 2014年的论文要反转输入句子?

反转输入句子能显著缩短源语和目标语之间的"距离",让优化更容易。原文发现:虽然语义对应关系没有改变,但反转后的句子让早期词的梯度和后期词的梯度"距离"更近,减少了学习长距离依赖的难度。

Q20. Seq2Seq和GPT/BERT是什么关系?

Seq2Seq是GPT和BERT的架构基础,Transformer是它们的实现载体。GPT是单向语言模型(类似Seq2Seq解码器),BERT是双向编码器。两者都用Transformer实现,但思想上延续了Seq2Seq的编码-解码分离理念。GPT用于生成任务,BERT用于理解任务。

全篇总纲

  • Seq2Seq是现代NLP的起点:编码器-解码器架构奠定了序列建模的基础
  • 固定向量瓶颈推动了注意力机制的诞生,Bahdanau 2015年解决了这个问题
  • Transformer用自注意力完全替代RNN,成为现代主流,但Seq2Seq的核心理念依然适用
  • 理解Seq2Seq是理解GPT、BERT等大模型的必经之路

八、Roadmap预告

后续内容预告

  • Attention机制详解:从Bahdanau到Luong,从加性到乘性
  • Transformer原理解析:从自注意力到完整架构
  • 位置编码:为什么需要,如何设计,旋转位置编码(RoPE)
  • GPT系列:自回归语言模型的训练与推理
  • BERT系列:掩码语言模型的预训练与微调
posted @ 2026-07-23 18:41  左扬  阅读(28)  评论(0)    收藏  举报