前面的文本处理和 Word2Vec 文章主要解决了两个问题:

  • 如何把文本转换成 token id。
  • 如何把 token id 转换成词向量。

但很多自然语言处理任务不是“输入一句话,输出一个类别”,而是“输入一个序列,输出另一个序列”。

例如机器翻译:

输入:我 喜欢 自然语言处理
输出:I like natural language processing

输入和输出的长度可能不同,词序也可能不同。普通 MLP 或单纯的文本分类器很难直接处理这种映射。

Seq2Seq,全称 Sequence to Sequence,就是专门学习“序列到序列”映射的一类模型框架。

一句话概括:

Seq2Seq 先用 Encoder 理解整个输入序列,再让 Decoder 根据编码结果逐步生成输出序列。

Seq2Seq Encoder-Decoder整体结构

1. 什么是序列到序列任务

Seq2Seq 的输入和输出都是有顺序的元素序列:

$$
X=(x_1,x_2,\ldots,x_{T_x})
$$

$$
Y=(y_1,y_2,\ldots,y_{T_y})
$$

其中输入长度 $T_x$ 与输出长度 $T_y$ 不必相同。

常见任务包括:

任务 输入序列 输出序列
机器翻译 中文 token 英文 token
文本摘要 长文章 token 摘要 token
对话生成 用户问题 token 回复 token
语音识别 音频特征帧 文本 token
拼写纠正 含错误的字符序列 正确字符序列
时间序列预测 历史数值序列 未来数值序列

Seq2Seq 不是某一种固定网络层,而是一种结构思想。

Encoder 和 Decoder 可以使用:

  • RNN。
  • LSTM。
  • GRU。
  • CNN。
  • Transformer。

这篇文章先从经典的 RNN/GRU Encoder-Decoder 讲起,因为它最容易看清序列生成的本质。

2. 为什么普通神经网络不够用

假设使用 MLP 做翻译,首先会遇到输入长度不固定的问题:

我 爱 你
我 非常 喜欢 学习 自然语言处理

两句话包含的 token 数不同,而普通全连接层通常需要固定长度输入。

即使通过 padding 把长度补齐,MLP 也不会天然理解:

  • token 的先后顺序。
  • 当前词与前面词之间的依赖。
  • 输出应该在什么时候结束。
  • 输入和输出之间的对齐关系。

循环神经网络会按顺序读取 token,并不断更新隐藏状态:

$$
h_t=f(x_t,h_{t-1})
$$

$h_t$ 同时包含当前输入和之前序列的信息,因此适合处理序列。

3. Seq2Seq 的整体结构

经典 Seq2Seq 由两部分组成。

3.1 Encoder

Encoder 按顺序读取输入 token:

x1 -> x2 -> x3 -> ... -> xTx

每一步都会更新隐藏状态:

$$
h_t^{enc}=f_{enc}(E_x(x_t),h_{t-1}^{enc})
$$

其中:

  • $E_x$ 是源语言 Embedding。
  • $x_t$ 是第 $t$ 个输入 token id。
  • $h_t^{enc}$ 是 Encoder 在第 $t$ 步的隐藏状态。

基础 Seq2Seq 通常把最后隐藏状态作为整个输入序列的表示:

$$
c=h_{T_x}^{enc}
$$

$c$ 常被称为上下文向量或语义向量。

3.2 Decoder

Decoder 使用 Encoder 给出的上下文向量作为初始状态,然后逐步生成输出:

$$
s_t=f_{dec}(E_y(y_{t-1}),s_{t-1})
$$

$$
P(y_t\mid y_{<t},X)=\text{softmax}(Ws_t+b)
$$

其中:

  • $s_t$ 是 Decoder 隐藏状态。
  • $y_{t-1}$ 是上一步 token。
  • $y_{<t}$ 表示已经生成的所有 token。
  • 输出分布表示下一个 token 的概率。

Decoder 一次只生成一个 token,再把这个 token 作为下一步输入。

4. <BOS><EOS><PAD>

Seq2Seq 通常需要几个特殊 token。

token 含义 作用
<BOS> Begin of Sequence 告诉 Decoder 开始生成
<EOS> End of Sequence 告诉 Decoder 序列已经结束
<PAD> Padding 将不同长度序列补成相同长度
<UNK> Unknown 表示词表中没有的 token

假设目标句子是:

I like NLP

训练时通常会处理成:

<BOS> I like NLP <EOS>

Decoder 的输入和预测目标会错开一位:

Decoder 输入:<BOS> I    like NLP
预测目标:      I     like NLP  <EOS>

模型因此学会:

  • 看到 <BOS> 后应该输出第一个词。
  • 看到前面的正确 token 后应该输出下一个词。
  • 句子完成后应该输出 <EOS>

5. 一次翻译是怎样生成的

假设输入是:

我 喜欢 学习

Encoder 读取完整输入后得到上下文向量 $c$。

Decoder 的生成过程可能是:

输入 <BOS> -> 输出 I
输入 I     -> 输出 like
输入 like  -> 输出 studying
输入 studying -> 输出 <EOS>

得到完整结果:

I like studying

这个过程是自回归生成:每一步依赖此前已经生成的结果。

联合概率可以拆成:

$$
P(Y\mid X)
=\prod_{t=1}^{T_y}
P(y_t\mid y_1,\ldots,y_{t-1},X)
$$

训练目标是让正确目标序列的条件概率尽可能大,等价地,也可以最小化每一步 token 的交叉熵损失。

6. 手工理解一次 Encoder 计算

真实 GRU 和 LSTM 内部有多个门控,直接手算较长。为了先理解信息如何逐步积累,可以使用一个简化 RNN:

$$
h_t=\tanh(x_t+h_{t-1})
$$

假设输入三个 token 的一维 Embedding 分别为:

我   -> 0.2
喜欢 -> 0.5
学习 -> 0.8

初始隐藏状态:

$$
h_0=0
$$

读取“我”:

$$
h_1=\tanh(0.2+0)\approx0.197
$$

读取“喜欢”:

$$
h_2=\tanh(0.5+0.197)\approx0.602
$$

读取“学习”:

$$
h_3=\tanh(0.8+0.602)\approx0.886
$$

最后的 $h_3$ 同时受到三个输入的影响,因此可以作为输入序列的压缩表示。

真实模型会使用多维向量和可学习矩阵:

$$
h_t=\tanh(W_xx_t+W_hh_{t-1}+b)
$$

训练过程中,模型会自己学会哪些信息应该保留在隐藏状态中。

7. 为什么常用 LSTM 或 GRU

普通 RNN 在长序列中容易出现梯度消失或梯度爆炸,较早位置的信息很难传到后面。

LSTM 和 GRU 通过门控机制控制信息流动。

以 GRU 为例,它包含:

  • 更新门:决定保留多少旧状态。
  • 重置门:决定计算候选状态时参考多少旧信息。
  • 候选隐藏状态:融合当前输入与历史信息。

简化公式如下:

$$
z_t=\sigma(W_zx_t+U_zh_{t-1})
$$

$$
r_t=\sigma(W_rx_t+U_rh_{t-1})
$$

$$
\tilde{h}t=\tanh(W_hx_t+U_h(r_t\odot h{t-1}))
$$

$$
h_t=(1-z_t)\odot\tilde{h}t+z_t\odot h{t-1}
$$

GRU 比 LSTM 少一个独立的细胞状态,结构相对简洁。在 PyTorch 中,两者都可以直接作为 Encoder 和 Decoder 的核心层。

8. Teacher Forcing 是什么

Decoder 在第 $t$ 步需要一个“上一步 token”作为输入。

训练时有两种选择。

使用模型自己的预测

模型预测 yt-1 -> 作为下一步输入

如果前面预测错了,后面可能连续出错,训练初期尤其困难。

使用真实目标 token

真实标签 yt-1 -> 作为下一步输入

这就是 Teacher Forcing。

Teacher Forcing与自回归输入的区别

代码中常通过概率决定使用哪一种:

use_teacher = torch.rand(()) < teacher_forcing_ratio

if use_teacher:
    decoder_input = target[:, t]
else:
    decoder_input = logits.argmax(dim=1)

例如:

teacher_forcing_ratio = 0.5

表示每一步有 50% 概率使用真实 token。

Teacher Forcing 可以加快训练,但也会带来训练和推理不一致的问题。

9. 训练与推理为什么不同

训练时,我们拥有完整目标序列,可以使用 Teacher Forcing。

推理时没有标准答案,只能使用模型自己的预测:

<BOS>
  -> 预测 y1
  -> 用 y1 预测 y2
  -> 用 y2 预测 y3
  -> ...
  -> 生成 <EOS>

Seq2Seq训练与推理流程对比

训练时总能看到正确历史,推理时却可能看到自己生成的错误历史。这种差异通常称为 Exposure Bias。

常见缓解方式包括:

  • 逐步降低 Teacher Forcing 比例。
  • Scheduled Sampling。
  • 使用更强的 Decoder 或 Attention。
  • 序列级训练目标。

入门时先理解问题即可,不必一次加入所有技巧。

10. Seq2Seq 的损失函数

假设 Decoder 在每一步输出词表上的 logits:

$$
Z\in\mathbb{R}^{B\times T\times V}
$$

其中:

  • $B$ 是 batch size。
  • $T$ 是目标序列长度。
  • $V$ 是目标词表大小。

目标 token id 的形状是:

$$
Y\in\mathbb{R}^{B\times T}
$$

训练时可以把前两维展平:

loss = loss_fn(
    logits.reshape(-1, vocab_size),
    target.reshape(-1),
)

如果目标序列包含 padding,需要忽略 <PAD>

loss_fn = nn.CrossEntropyLoss(ignore_index=PAD_ID)

CrossEntropyLoss 直接接收 logits,不需要先手动执行 softmax。

11. 基础 Seq2Seq 的固定向量瓶颈

基础模型把整个输入序列压缩进最后一个隐藏状态:

$$
c=h_{T_x}^{enc}
$$

对于短句可能足够,但输入变长后,单个固定长度向量需要记住:

  • 每个输入词的信息。
  • 词序。
  • 长距离依赖。
  • 对生成不同目标词有用的细节。

这会形成信息瓶颈。句子越长,前面信息越容易丢失。

12. Attention 如何改进 Seq2Seq(Attention会专门用一篇文章介绍)

Attention 不再要求 Decoder 只依赖最后一个 Encoder 状态,而是保留所有 Encoder 输出:

$$
H=(h_1^{enc},h_2^{enc},\ldots,h_{T_x}^{enc})
$$

Decoder 在第 $t$ 步计算自己与每个输入位置的相关性:

$$
e_{t,i}=score(s_{t-1},h_i^{enc})
$$

经过 softmax 得到注意力权重:

$$
\alpha_{t,i}
=\frac{\exp(e_{t,i})}
{\sum_j\exp(e_{t,j})}
$$

再对 Encoder 状态加权求和:

$$
c_t=\sum_i\alpha_{t,i}h_i^{enc}
$$

每生成一个目标 token,Decoder 都可以重新选择当前最相关的输入位置。

Attention让Decoder在每一步查看不同输入位置

例如生成英文单词 like 时,模型可能重点关注中文“喜欢”;生成 NLP 时,可能重点关注“自然语言处理”。

Attention 既缓解了固定向量瓶颈,也提供了输入输出之间的软对齐关系。

13. PyTorch 中 GRU 的输入输出形状

下面的实现使用:

nn.GRU(
    input_size=embed_dim,
    hidden_size=hidden_dim,
    batch_first=True,
)

batch_first=True 时:

张量 形状 含义
输入 Embedding [batch, seq_len, embed_dim] 每个 token 的向量
GRU outputs [batch, seq_len, hidden_dim] 每个时间步的输出
GRU hidden [num_layers, batch, hidden_dim] 最后隐藏状态

注意:即使设置了 batch_first=True,hidden 的维度顺序仍然是:

[num_layers, batch, hidden_dim]

Decoder 每次只处理一个 token,因此它的 Embedding 可以整理成:

[batch, 1, embed_dim]

PyTorch Seq2Seq中的主要张量形状

14. PyTorch 实现 Encoder

import torch
from torch import nn


class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.gru = nn.GRU(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            batch_first=True,
        )

    def forward(self, src):
        # src: [batch, src_len]
        embedded = self.embedding(src)
        # embedded: [batch, src_len, embed_dim]

        outputs, hidden = self.gru(embedded)
        # outputs: [batch, src_len, hidden_dim]
        # hidden:  [1, batch, hidden_dim]

        return outputs, hidden

基础 Seq2Seq 只需要最后的 hidden 初始化 Decoder。

这里仍然返回 outputs,是为了以后扩展 Attention,因为 Attention 需要所有时间步的 Encoder 输出。

15. PyTorch 实现 Decoder

Decoder 每次接收一个 token:

class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.gru = nn.GRU(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            batch_first=True,
        )
        self.output_layer = nn.Linear(hidden_dim, vocab_size)

    def forward(self, token, hidden):
        # token: [batch]
        embedded = self.embedding(token).unsqueeze(1)
        # embedded: [batch, 1, embed_dim]

        output, hidden = self.gru(embedded, hidden)
        # output: [batch, 1, hidden_dim]

        logits = self.output_layer(output.squeeze(1))
        # logits: [batch, vocab_size]

        return logits, hidden

output_layer 把 Decoder 隐藏状态映射为词表上的 logits。

16. 组合成 Seq2Seq 模型

class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder):
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, src, target, teacher_forcing_ratio=0.5):
        # target: [batch, target_len],第一个 token 是 BOS
        _, hidden = self.encoder(src)

        decoder_input = target[:, 0]
        step_logits = []

        for t in range(1, target.size(1)):
            logits, hidden = self.decoder(decoder_input, hidden)
            step_logits.append(logits)

            predicted_token = logits.argmax(dim=1)
            use_teacher = torch.rand(()) < teacher_forcing_ratio

            if use_teacher:
                decoder_input = target[:, t]
            else:
                decoder_input = predicted_token

        # [batch, target_len - 1, vocab_size]
        return torch.stack(step_logits, dim=1)

这里从 t=1 开始,是因为 target[:, 0] 已经作为初始 <BOS> 输入。

模型预测的是:

target[:, 1:]

也就是 <BOS> 后面的所有 token,包括最后的 <EOS>

17. 完整例子:学习反转数字序列

为了把注意力放在 Seq2Seq 本身,不引入分词器和真实翻译语料,下面训练一个序列反转任务。

输入:

3 7 5 9

目标:

<BOS> 9 5 7 3 <EOS>

17.1 定义词表和数据

PAD_ID = 0
BOS_ID = 1
EOS_ID = 2

# 3 到 12 表示十种普通 token
VOCAB_SIZE = 13
SEQ_LEN = 4


def make_batch(batch_size):
    src = torch.randint(
        low=3,
        high=VOCAB_SIZE,
        size=(batch_size, SEQ_LEN),
    )

    reversed_src = torch.flip(src, dims=[1])

    bos = torch.full((batch_size, 1), BOS_ID)
    eos = torch.full((batch_size, 1), EOS_ID)

    target = torch.cat([bos, reversed_src, eos], dim=1)
    return src, target

17.2 创建模型和优化器

torch.manual_seed(42)

encoder = Encoder(
    vocab_size=VOCAB_SIZE,
    embed_dim=32,
    hidden_dim=64,
)

decoder = Decoder(
    vocab_size=VOCAB_SIZE,
    embed_dim=32,
    hidden_dim=64,
)

model = Seq2Seq(encoder, decoder)

loss_fn = nn.CrossEntropyLoss(ignore_index=PAD_ID)
optimizer = torch.optim.Adam(model.parameters(), lr=3e-3)

Encoder 和 Decoder 的 hidden_dim 必须一致,这样 Encoder 最后的 hidden 才能直接作为 Decoder 初始 hidden。

17.3 训练模型

for step in range(1500):
    src, target = make_batch(batch_size=128)

    logits = model(
        src,
        target,
        teacher_forcing_ratio=0.5,
    )

    expected = target[:, 1:]

    loss = loss_fn(
        logits.reshape(-1, VOCAB_SIZE),
        expected.reshape(-1),
    )

    optimizer.zero_grad(set_to_none=True)
    loss.backward()

    # 循环网络中常用梯度裁剪缓解梯度爆炸
    nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

    optimizer.step()

    if step % 300 == 0:
        print(f"step={step}, loss={loss.item():.4f}")

这里使用随机生成的数据,所以每一步都会看到新的数字序列。

17.4 编写贪心解码

@torch.no_grad()
def greedy_decode(model, src, max_len):
    model.eval()

    _, hidden = model.encoder(src)

    batch_size = src.size(0)
    decoder_input = torch.full(
        (batch_size,),
        BOS_ID,
        dtype=torch.long,
    )

    generated = []
    finished = torch.zeros(batch_size, dtype=torch.bool)

    for _ in range(max_len):
        logits, hidden = model.decoder(decoder_input, hidden)
        next_token = logits.argmax(dim=1)

        generated.append(next_token)
        finished |= next_token.eq(EOS_ID)

        if finished.all():
            break

        decoder_input = next_token

    return torch.stack(generated, dim=1)

17.5 测试结果

test_src = torch.tensor([
    [3, 7, 5, 9],
    [4, 4, 8, 6],
])

generated = greedy_decode(
    model,
    test_src,
    max_len=SEQ_LEN + 1,
)

print("input:\n", test_src)
print("generated:\n", generated)

理想输出应该接近:

input:
tensor([[3, 7, 5, 9],
        [4, 4, 8, 6]])

generated:
tensor([[9, 5, 7, 3, 2],
        [6, 8, 4, 4, 2]])

其中最后的 2 就是 <EOS>

18. 为什么要进行梯度裁剪

循环网络会在多个时间步上反复应用相同参数。反向传播时,梯度也要沿时间展开的计算图不断传播。

这可能导致梯度爆炸:

梯度越来越大 -> 参数更新过猛 -> loss 变成 NaN

PyTorch 中可以限制整体梯度范数:

nn.utils.clip_grad_norm_(
    model.parameters(),
    max_norm=1.0,
)

梯度裁剪通常放在:

loss.backward() 之后
optimizer.step() 之前

它不能解决所有训练问题,但在 RNN、LSTM、GRU 训练中很常见。

前面的推理代码每一步都选择概率最大的 token:

next_token = logits.argmax(dim=1)

这叫贪心搜索。

它速度快,但当前看起来最好的 token 不一定能组成整体概率最高的序列。

Beam Search 会同时保留多个候选序列:

第 1 步:保留分数最高的 k 个 token
第 2 步:扩展这 k 个候选,再保留总分最高的 k 个
...
直到生成 EOS 或达到最大长度

$k$ 称为 beam size。

Beam Search 通常能得到更好的序列,但计算和内存开销也更大。

20. 变长序列、Padding 与 Mask

真实数据中的句子长度不同,需要 padding:

我 喜欢 NLP <EOS> <PAD>
今天天气 很 好 <EOS>

需要处理两个地方。

损失中忽略 PAD

loss_fn = nn.CrossEntropyLoss(ignore_index=PAD_ID)

Encoder 中避免让 GRU 处理无效 PAD

可以使用:

nn.utils.rnn.pack_padded_sequence

它根据真实长度压缩 batch,让循环网络跳过 padding 位置。

如果加入 Attention,还需要对 padding 位置进行 mask,防止 Decoder 把注意力分配给 <PAD>

21. Seq2Seq 如何评价

不同任务使用不同指标。

任务 常见指标
机器翻译 BLEU、COMET
文本摘要 ROUGE
语音识别 WER、CER
序列反转 Exact Match、token accuracy
时间序列预测 MAE、MSE、RMSE

只看训练 loss 不够,还需要在验证集上使用与任务目标一致的指标。

对于生成任务,也应人工查看一些输出,因为单个自动指标不一定能完整反映流畅度和语义正确性。

22. 常见错误

22.1 Decoder 输入与目标没有错开

正确关系:

输入:<BOS> y1 y2 y3
目标:y1    y2 y3 <EOS>

如果输入和目标完全相同,模型可能只学会复制当前 token。

22.2 训练时忘记预测 EOS

目标序列必须包含 <EOS>,否则模型不知道何时停止。

22.3 对 logits 先做 softmax 再传给交叉熵

错误:

loss = loss_fn(torch.softmax(logits, dim=-1), target)

正确:

loss = loss_fn(logits, target)

22.4 忘记忽略 PAD

如果 padding 也参与损失,模型会花大量精力预测 <PAD>,指标也会失真。

22.5 Teacher Forcing 比例始终为 1

模型训练时永远只看到正确历史,推理时面对自己的预测可能表现骤降。

可以从较高比例开始,再根据任务逐步降低。

22.6 Encoder 和 Decoder 隐藏维度不一致

如果直接把 Encoder hidden 传给 Decoder,两者的层数和隐藏维度必须兼容。

如果不一致,需要增加线性映射或其他桥接层。

22.7 推理没有最大长度限制

模型可能迟迟不生成 <EOS>。推理循环必须同时设置:

  • 遇到 <EOS> 停止。
  • 达到 max_len 强制停止。

23. Seq2Seq 与 Transformer 的关系

Transformer 也可以采用 Encoder-Decoder 结构,因此它同样属于广义 Seq2Seq 模型。

经典 RNN Seq2Seq:

Encoder RNN/GRU/LSTM
  -> 隐藏状态或 Attention
  -> Decoder RNN/GRU/LSTM

Transformer Seq2Seq:

Transformer Encoder
  -> Cross-Attention
  -> Transformer Decoder

两者的共同点包括:

  • 输入输出都是序列。
  • Decoder 自回归生成。
  • 使用 <BOS><EOS> 和 padding mask。
  • 训练时目标 token 向右错位。
  • 推理时使用贪心或 Beam Search。

主要区别是 Transformer 使用 Attention 并行处理序列,不再依赖逐时间步递归,因此更容易扩展到大规模训练。

24. 总结

Seq2Seq 的核心主线是:

输入 token
  -> Encoder 逐步提取序列信息
  -> 得到上下文表示
  -> Decoder 从 BOS 开始逐步生成
  -> 生成 EOS 后停止

需要重点掌握:

  • Encoder 负责理解输入序列。
  • Decoder 根据上下文和历史输出预测下一个 token。
  • Teacher Forcing 使用真实历史帮助训练。
  • 推理时只能使用模型自己的预测。
  • 基础 Seq2Seq 的固定长度向量存在信息瓶颈。
  • Attention 让 Decoder 每一步查看不同输入位置。
  • PyTorch GRU 的 output 和 hidden 形状不同。
  • 训练时需要处理 padding、梯度裁剪和输入目标错位。

理解经典 Seq2Seq 后,再学习 Attention、Transformer、机器翻译和文本生成时,很多概念都会自然连接起来。

参考资料

  1. Ilya Sutskever, Oriol Vinyals, Quoc V. Le, Sequence to Sequence Learning with Neural Networks: https://arxiv.org/abs/1409.3215
  2. Dzmitry Bahdanau, Kyunghyun Cho, Yoshua Bengio, Neural Machine Translation by Jointly Learning to Align and Translate: https://arxiv.org/abs/1409.0473
  3. PyTorch Documentation, torch.nn.GRU: https://docs.pytorch.org/docs/stable/generated/torch.nn.GRU.html
  4. PyTorch Documentation, pack_padded_sequence: https://docs.pytorch.org/docs/stable/generated/torch.nn.utils.rnn.pack_padded_sequence.html