Seq2Seq:从Encoder-Decoder到Attention与PyTorch实现
前面的文本处理和 Word2Vec 文章主要解决了两个问题:
- 如何把文本转换成 token id。
- 如何把 token id 转换成词向量。
但很多自然语言处理任务不是“输入一句话,输出一个类别”,而是“输入一个序列,输出另一个序列”。
例如机器翻译:
输入:我 喜欢 自然语言处理
输出:I like natural language processing
输入和输出的长度可能不同,词序也可能不同。普通 MLP 或单纯的文本分类器很难直接处理这种映射。
Seq2Seq,全称 Sequence to Sequence,就是专门学习“序列到序列”映射的一类模型框架。
一句话概括:
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。
代码中常通过概率决定使用哪一种:
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>
训练时总能看到正确历史,推理时却可能看到自己生成的错误历史。这种差异通常称为 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 都可以重新选择当前最相关的输入位置。
例如生成英文单词 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]
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 训练中很常见。
19. 贪心搜索与 Beam Search
前面的推理代码每一步都选择概率最大的 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、机器翻译和文本生成时,很多概念都会自然连接起来。
参考资料
- Ilya Sutskever, Oriol Vinyals, Quoc V. Le, Sequence to Sequence Learning with Neural Networks: https://arxiv.org/abs/1409.3215
- Dzmitry Bahdanau, Kyunghyun Cho, Yoshua Bengio, Neural Machine Translation by Jointly Learning to Align and Translate: https://arxiv.org/abs/1409.0473
- PyTorch Documentation,
torch.nn.GRU: https://docs.pytorch.org/docs/stable/generated/torch.nn.GRU.html - PyTorch Documentation,
pack_padded_sequence: https://docs.pytorch.org/docs/stable/generated/torch.nn.utils.rnn.pack_padded_sequence.html
