序列到序列模型 (Seq2Seq):Encoder-Decoder 架构

📂 所属阶段:第二阶段 — 深度学习与序列模型(进阶篇)
🔗 相关章节:长短时记忆网络 LSTM/GRU · 注意力机制

如果你用过翻译软件、跟 AI 聊过天,或者见过图片被自动转换成文字描述,那你大概率已经接触过 序列到序列(Seq2Seq)模型了。它是深度学习中处理 变长输入 → 变长输出 任务的经典架构,也是后来火遍 AI 圈的 Transformer 的 老祖宗之一。啃透它,能帮你搞懂 Attention 机制为什么“必须横空出世”。


1. Seq2Seq 是什么?

1.1 一句话定义 + 主流场景

Seq2Seq 的核心逻辑可以概括成一句话:

输入任意长度的序列 → 模型“消化”成统一载体 → 输出任意长度的目标序列

这个框架几乎覆盖了所有“序列转化”类任务,下面是一些大家耳熟能详的例子:

├── 机器翻译:中文“你好,NLP爱好者!” → 英文“Hello, NLP lovers!”
├── 文本摘要:2000 字科技新闻 → 300 字核心要点
├── 对话系统:用户“今天天气怎么样?” → 机器人自动回复
├── 代码生成:自然语言“写一个 Python 列表去重的函数” → Python 代码块
├── 语音识别:一段 30 秒的中文音频 → 对应文字转录
└── 图像描述(变体):一张猫跳栏杆的图 → “一只橘猫正在跳过白色的小栏杆”

简单来说,只要输入和输出都是序列,而且长度不固定,Seq2Seq 就能派上用场

1.2 经典 Encoder-Decoder 架构拆解

尽管 Seq2Seq 的应用场景五花八门,但核心结构始终是两个循环神经网络(RNN/LSTM/GRU)组成的组合拳

Seq2Seq = 负责“读”的 Encoder + 负责“写”的 Decoder + 连接两者的 Context Vector

我们用中文→英文翻译(“我 爱 NLP”→“I love NLP”)来举一个具体的例子:

  1. Encoder(编码器)
    把输入的中文词序列(“我”“爱”“NLP”)逐个读进去。早期常用双向 LSTM,这样每个词都能同时看到上文和下文。读完整个句子后,模型的最终隐藏状态(以及细胞状态,如果使用 LSTM)会被打包成一个固定长度的上下文向量(Context Vector)

    • 你可以把这个 Context Vector 理解为:模型把整个输入序列的语义“压缩”成了一张摘要卡片。
  2. Decoder(解码器)
    以这张“摘要卡片”作为初始状态,从一个特殊的 <START> 标记开始,一个词一个词地生成英文翻译。每生成一个新词,就把它当作下一步的输入,直到遇到 <END> 标记为止。

这一套“压缩—生成”的过程,就是最经典的 Seq2Seq 流程。


2. PyTorch 极简 Seq2Seq 实现

光说不练假把式!我们用 PyTorch 写一个 基于双向 LSTM 的 Encoder + 简单单向 LSTM 的 Decoder 的小模型,并附上两种最常用的解码方法。

2.1 完整的基础模型代码

import torch
import torch.nn as nn
import random

# --------------------------
# 双向 LSTM Encoder
# --------------------------
class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers=2):
        super().__init__()
        # 词嵌入层:把 token id 转成向量
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        # 双向 LSTM:能同时看到输入词的“上文”和“下文”
        self.lstm = nn.LSTM(
            embed_dim, hidden_dim, num_layers,
            batch_first=True, bidirectional=True,
            dropout=0.2 if num_layers > 1 else 0
        )
        # 把双向最后一层的隐藏/细胞状态拼接成单方向,适配单向 Decoder
        self.hidden_fc = nn.Linear(hidden_dim * 2, hidden_dim)
        self.cell_fc = nn.Linear(hidden_dim * 2, hidden_dim)

    def forward(self, input_ids):
        # input_ids shape: (batch_size, seq_len)
        embedded = self.embedding(input_ids)  # (batch_size, seq_len, embed_dim)

        # outputs: 所有时间步的输出;(hidden, cell): 最后时间步的状态
        outputs, (hidden, cell) = self.lstm(embedded)

        # 拼接双向的最后隐藏层(hidden[-2] 是正向,hidden[-1] 是反向)
        hidden = torch.cat([hidden[-2], hidden[-1]], dim=-1)
        cell = torch.cat([cell[-2], cell[-1]], dim=-1)
        # 压缩成单向 Decoder 需要的维度
        hidden = self.hidden_fc(hidden).unsqueeze(0)  # (1, batch_size, hidden_dim)
        cell = self.cell_fc(cell).unsqueeze(0)

        return outputs, (hidden, cell)


# --------------------------
# 带简单拼接 Context 的单向 LSTM Decoder
# --------------------------
class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers=1):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        # 输入是当前词嵌入 + 固定 Context Vector
        self.lstm = nn.LSTM(
            embed_dim + hidden_dim, hidden_dim, num_layers,
            batch_first=True,
            dropout=0.2 if num_layers > 1 else 0
        )
        self.fc = nn.Linear(hidden_dim, vocab_size)  # 输出每个词的概率 logits

    def forward(self, input_t, hidden, cell, context):
        # input_t shape: (batch_size, 1) → 当前单个词
        embedded = self.embedding(input_t)  # (batch_size, 1, embed_dim)
        # 拼接上下文向量
        lstm_input = torch.cat([embedded, context.unsqueeze(1)], dim=-1)

        output, (hidden, cell) = self.lstm(lstm_input, (hidden, cell))
        logits = self.fc(output.squeeze(1))  # (batch_size, vocab_size)
        return logits, hidden, cell


# --------------------------
# 完整 Seq2Seq 模型
# --------------------------
class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder):
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, input_ids, target_ids, teacher_forcing_ratio=0.5):
        batch_size = input_ids.size(0)
        target_len = target_ids.size(1)
        target_vocab_size = self.decoder.fc.out_features

        # 预分配输出的 logits 矩阵
        outputs = torch.zeros(batch_size, target_len, target_vocab_size).to(input_ids.device)

        # 先过 Encoder 拿到压缩状态和 Context
        _, (hidden, cell) = self.encoder(input_ids)
        context = hidden[-1]  # 取最后一层的隐藏状态作为固定 Context

        # 解码第一步:用 <START> token
        decoder_input = target_ids[:, 0:1]

        # 逐词生成
        for t in range(target_len):
            logits, hidden, cell = self.decoder(decoder_input, hidden, cell, context)
            outputs[:, t] = logits

            # Teacher Forcing 策略:随机选择用真实标签还是上一步预测结果
            teacher_force = random.random() < teacher_forcing_ratio
            top1_token = logits.argmax(1)
            decoder_input = target_ids[:, t:t+1] if teacher_force else top1_token.unsqueeze(1)

        return outputs
什么是 Teacher Forcing?

在训练 Decoder 时,上面这个模型会以一定概率(例如 50%)使用真实的目标词作为下一步的输入,而不是模型自己预测的词。这就像老师带着学生写句子,每一步都给出正确答案,帮助模型更快收敛。这个方法就叫 Teacher Forcing。

2.2 两种核心解码策略

模型训练好之后,如何根据输出的概率分布生成合理的目标序列? 下面介绍两种最常用的方法。

① 贪婪解码(Greedy Decode)

最直接的策略:每一步只选择概率最高的那个词,直到遇到 <END> 或达到最大长度。

def greedy_decode(model, input_ids, tokenizer, max_len=50,
                  start_token=2, end_token=3):
    model.eval()
    with torch.no_grad():
        # 先过 Encoder
        _, (hidden, cell) = model.encoder(input_ids)
        context = hidden[-1]

        # 初始化:只有 <START>
        decoder_input = torch.tensor([[start_token]]).to(input_ids.device)
        generated_tokens = []

        for _ in range(max_len):
            logits, hidden, cell = model.decoder(
                decoder_input, hidden, cell, context
            )
            # 取概率最高的词
            next_token = logits.argmax(1).item()

            if next_token == end_token:
                break
            generated_tokens.append(next_token)
            decoder_input = torch.tensor([[next_token]]).to(input_ids.device)

        # 用 tokenizer 把 id 转成文字
        return tokenizer.decode(generated_tokens)

优点:速度快,实现简单。
缺点:只看眼前利益,容易错过后面更合理的整体组合(局部最优)。

为了缓解贪婪解码的短视问题,Beam Search 会同时维护 top-k 个“当前最优候选序列”,k 被称作“beam size”。

def beam_search_decode(model, input_ids, tokenizer, max_len=50,
                        beam_size=5, start_token=2, end_token=3):
    model.eval()
    with torch.no_grad():
        # 先过 Encoder
        _, (hidden, cell) = model.encoder(input_ids)
        context = hidden[-1]

        # 初始化 beam 列表:每个 beam 记录 tokens、总得分、当前隐藏/细胞状态
        beams = [
            {"tokens": [start_token], "score": 0.0,
             "hidden": hidden, "cell": cell}
        ]

        for _ in range(max_len):
            all_candidates = []
            # 对每个当前 beam 展开
            for beam in beams:
                # 如果已经遇到 <END>,直接保留这个候选
                if beam["tokens"][-1] == end_token:
                    all_candidates.append(beam)
                    continue

                # 预测下一个词
                decoder_input = torch.tensor(
                    [[beam["tokens"][-1]]]
                ).to(input_ids.device)
                logits, new_hidden, new_cell = model.decoder(
                    decoder_input, beam["hidden"], beam["cell"], context
                )
                # 转成 log 概率(避免连乘下溢,用加法更稳定)
                log_probs = torch.log_softmax(logits, dim=-1)
                # 取 top-k 个候选词
                topk_log_probs, topk_tokens = log_probs.topk(beam_size, dim=-1)

                # 生成 k 个新候选
                for i in range(beam_size):
                    token = topk_tokens[0, i].item()
                    total_score = beam["score"] + topk_log_probs[0, i].item()
                    all_candidates.append({
                        "tokens": beam["tokens"] + [token],
                        "score": total_score,
                        "hidden": new_hidden,
                        "cell": new_cell,
                    })

            # 按总得分降序排序,取 top-k 保留为新的 beam
            all_candidates.sort(key=lambda x: x["score"], reverse=True)
            beams = all_candidates[:beam_size]

        # 最后选得分最高的,去掉开头的 <START> 再转文字
        return tokenizer.decode(beams[0]["tokens"][1:])

Beam Search 的本质:在每一步都多留几条路,最后选出整体分数最高的一条。虽然速度慢了一些,但通常比贪婪解码更通顺、更合理。

:::tip 如何选择 beam size?

  • beam size = 1:其实就退化成了贪婪解码。
  • beam size 太大:计算量明显增加,而且可能生成重复或枯燥的内容。
  • 机器翻译中常用的 beam size 一般在 4~8 之间,具体可以靠实验和验证集来调整。 :::

3. 经典 Seq2Seq 的致命问题 → 引出 Attention

我们上面的实现里,用的是一个固定长度的 Context Vector 来压缩整个输入序列。无论输入句子是 3 个词还是 100 个词,Encoder 都必须把所有信息塞进同一个维度的向量里。

这就是经典 Seq2Seq 最大的 信息瓶颈

  • 对于长句子,后面的信息很容易被前面的“淹没”,模型生成到后半段时已经“忘得差不多了”。
  • 翻译时,模型只能凭借这一个压缩后的记忆,无法动态地参照原文的不同部分。

为了解决这个问题,Attention 机制 被提了出来。它让 Decoder 在生成每一个词时,都能“主动看一眼”输入序列里所有位置的信息,并自己决定哪些位置跟当前生成任务最相关。这样一来,就再也没有那个单一 Context Vector 的容量限制了。

这也正是我们下一篇(注意力机制)要重点讲解的内容。

💡 小总结

  1. 经典 Seq2Seq = 双向 Encoder 压缩 + 单向 Decoder 逐词生成 + Teacher Forcing 训练加速
  2. 解码常用贪婪(快但易局部最优)和 Beam Search(慢但更通顺)
  3. 它是理解 Transformer 的必经之路,但现在纯 Seq2Seq 已经基本被 Transformer 取代

🔗 扩展阅读与论文