ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Seq2Seq与注意力机制:从原理到PyTorch实战翻译模型

Seq2Seq与注意力机制:从原理到PyTorch实战翻译模型 1. 从“输入一句话输出另一句话”说起Seq2Seq 到底在解决什么问题第一次接触 Seq2Seq 的人脑子里往往有个疑问我直接用全连接网络不行吗输入一个向量输出一个向量多简单。问题在于现实世界里大量任务的输入和输出都是变长序列——翻译一句话中文 10 个字英文可能 15 个词做文本摘要原文 500 字摘要 80 字语音识别3 秒音频对应 20 个字符。输入长度和输出长度都不固定而且两者之间没有天然的一一对应关系。全连接网络要求固定维度RNN 虽然能吃变长输入但只能输出等长或单步的结果没法直接生成一个结构完整的变长输出序列。Seq2SeqSequence-to-Sequence就是冲着这个痛点来的。它的核心思路非常朴素用一个编码器把输入序列压缩成一个上下文表示再用一个解码器从这个表示里逐步“解码”出目标序列。编码器和解码器通常都是 RNN 家族成员LSTM、GRU但也可以是 Transformer、CNN。整个框架不关心输入输出具体多长只关心“怎么把源序列的信息传递到目标序列的生成过程中”。这个框架能做的事情远超翻译。机器翻译是它的成名作但文本摘要、对话生成、语音识别、代码生成、甚至化学分子式生成都能套进这个范式。适合谁来学如果你已经写过简单的 RNN 分类模型想进一步处理“输入输出都是序列”的任务Seq2Seq 是绕不过去的一站。如果你正在做翻译或摘要项目直接上手 Seq2Seq 加注意力机制基本就是当前工程实践的标准起点。我见过不少新手一上来就冲 Transformer结果连编码器-解码器结构为什么需要两个独立网络都说不清楚。Seq2Seq 是理解这一切的基石把它吃透后面看 Attention、Transformer、BERT 的 encoder-decoder 架构都会顺很多。2. 编码器-解码器架构拆解为什么非要拆成两半2.1 编码器把变长输入“压”成一个向量编码器的工作流程可以用一句话概括逐个读入源序列的每个 token不断更新自己的隐藏状态读完之后把最终隐藏状态交给解码器。假设输入序列是 (x_1, x_2, ..., x_T)编码器在每个时间步 (t) 计算[ h_t f(x_t, h_{t-1}) ]其中 (f) 可以是 LSTM 单元、GRU 单元或者普通 RNN 单元。读完整句话之后我们拿到最终隐藏状态 (h_T)对于 LSTM 还有细胞状态 (c_T)。这个 (h_T) 就是整个源序列的“压缩表示”也叫上下文向量context vector。这里有个关键问题为什么用最终隐藏状态而不是所有隐藏状态的拼接因为解码器需要的是一个固定维度的输入而源序列长度是变化的。把所有隐藏状态拼起来维度会随输入长度变化没法直接喂给解码器。最终隐藏状态维度固定正好充当“源序列摘要”的角色。但这也埋下了一个隐患如果源序列很长比如 50 个词最终隐藏状态真的能记住所有信息吗答案是不能。这就是后面 Attention 机制要解决的核心问题。不过在短序列任务上比如 10 个词以内的翻译纯 Seq2Seq 已经能跑出不错的结果。2.2 解码器一步一步“吐”出目标序列解码器的初始隐藏状态直接复制编码器的最终隐藏状态。然后它开始逐个时间步生成目标 token。每个时间步的输入是上一个时间步生成的 token训练时是真实标签推理时是模型自己生成的输出是当前 token 的概率分布。具体来说解码器在时间步 (t) 的计算是[ s_t g(y_{t-1}, s_{t-1}) ] [ P(y_t | y_{t}, x) \text{softmax}(W_o s_t b_o) ]其中 (y_{t-1}) 是上一个 token 的 embedding(s_{t-1}) 是解码器上一时刻的隐藏状态。训练时我们通常用Teacher Forcing把真实的目标 token 作为下一步输入而不是模型自己预测的 token。这样做的好处是训练更稳定、收敛更快因为模型不用在早期就面对自己犯下的错误。但 Teacher Forcing 有个著名的副作用曝光偏差Exposure Bias。训练时模型见到的都是正确输入推理时却要基于自己生成的、可能有错的 token 继续生成一旦第一步错了错误会累积放大。工程上常见的缓解手段是 scheduled sampling训练前期用 Teacher Forcing后期逐渐替换成模型自己的预测让模型慢慢适应推理时的输入分布。2.3 两个网络为什么要分开有人会问能不能用一个 RNN 同时做编码和解码理论上可以但实践上不推荐。编码器和解码器的职责完全不同编码器要充分理解源序列解码器要流畅生成目标序列。两者的词表可能不同比如中英文需要的隐藏层维度也可能不同。分开设计让每个部分可以独立调参、独立优化。更重要的是分开之后才能方便地插入 Attention 机制让解码器在每一步都能“回头看”编码器的所有隐藏状态而不是只依赖一个固定向量。3. 注意力机制Seq2Seq 从能用变好用的分水岭3.1 固定上下文向量的瓶颈纯 Seq2Seq 最被人诟病的地方就是那个固定长度的上下文向量。想象一下你要把一篇 300 字的文章压缩成一个 256 维的向量然后再从这个向量里恢复出 200 字的摘要。信息瓶颈太严重了。实验上也能明显看到当源序列超过 20 个 token 时BLEU 分数断崖式下跌。解码器在生成后面的词时已经“忘记”了源序列开头的内容。这个问题不是调参能解决的是架构本身的限制。2015 年 Bahdanau 等人提出的注意力机制直接改变了 Seq2Seq 的命运。3.2 注意力到底在算什么注意力机制的核心思想是解码器在每一步生成时不要只盯着一个固定向量而是动态地从编码器的所有隐藏状态中“挑选”最相关的信息。具体计算分三步第一步计算解码器当前隐藏状态 (s_t) 和编码器每个隐藏状态 (h_i) 的相似度[ e_{t,i} \text{score}(s_t, h_i) ]score 函数可以是点积、双线性映射或者一个小型 MLP。Bahdanau 原始论文用的是 MLP 形式。第二步把相似度归一化成概率分布[ \alpha_{t,i} \frac{\exp(e_{t,i})}{\sum_j \exp(e_{t,j})} ]这些 (\alpha) 就是注意力权重表示解码器当前步对编码器每个位置的关注程度。第三步用注意力权重对编码器隐藏状态做加权求和得到当前步的上下文向量[ c_t \sum_i \alpha_{t,i} h_i ]然后解码器用 (c_t) 和 (s_t) 一起预测当前 token。这样一来解码器在生成每个词时都能“看到”源序列的不同部分。生成第一个词时可能关注源序列开头生成最后一个词时可能关注结尾完全动态调整。3.3 一个通用的注意力模块长什么样热词里提到的“a generic attention module for a decoder in seq2seq pytorch”其实就是在说能不能写一个通用的、可插拔的注意力模块方便在不同 Seq2Seq 模型里复用。我自己的做法是定义一个Attention类接收编码器输出encoder_outputs和解码器当前隐藏状态decoder_hidden返回上下文向量和注意力权重。这样不管是 LSTM 还是 GRU 解码器都能直接调用同一个模块。一个典型的 PyTorch 实现大概长这样import torch import torch.nn as nn import torch.nn.functional as F class Attention(nn.Module): def __init__(self, enc_hid_dim, dec_hid_dim): super().__init__() self.attn nn.Linear(enc_hid_dim dec_hid_dim, dec_hid_dim) self.v nn.Linear(dec_hid_dim, 1, biasFalse) def forward(self, decoder_hidden, encoder_outputs): # decoder_hidden: (batch, dec_hid_dim) # encoder_outputs: (batch, src_len, enc_hid_dim) src_len encoder_outputs.shape[1] decoder_hidden decoder_hidden.unsqueeze(1).repeat(1, src_len, 1) energy torch.tanh(self.attn(torch.cat((decoder_hidden, encoder_outputs), dim2))) attention self.v(energy).squeeze(2) # (batch, src_len) return F.softmax(attention, dim1)这个模块的优点是与编码器类型解耦。只要编码器输出是(batch, src_len, enc_hid_dim)的形状不管它是 LSTM、GRU 还是 CNN 产生的注意力模块都能正常工作。工程上这种解耦非常关键因为你可以先跑通一个 LSTM 版本后面想换 Transformer 编码器时注意力模块几乎不用改。注意注意力权重的 softmax 维度一定是src_len那一维不要搞错。我见过有人对 batch 维度做 softmax结果训练完全跑不动loss 一直不降。4. 动手实现一个带注意力的 Seq2Seq 翻译模型4.1 数据准备与词表构建我们以一个小规模的中英翻译任务为例。数据格式是每行一对句子用 tab 分隔。第一步是构建词表。源语言和目标语言各建一个词表特殊 token 至少要有四个pad、sos、eos、unk。pad用于 batch 内对齐sos和eos标记序列开始和结束unk处理未登录词。词表大小建议控制在 10000 到 30000 之间。太小会导致大量unk太大则 embedding 矩阵参数量爆炸。我的经验是如果训练数据少于 10 万句对词表 8000 左右就够了如果数据量在百万级别可以放到 30000 以上。构建词表时按词频排序只保留频率最高的前 N 个词其余全部映射到unk。from collections import Counter def build_vocab(sentences, max_size10000, min_freq2): counter Counter() for sent in sentences: counter.update(sent.split()) vocab {pad: 0, sos: 1, eos: 2, unk: 3} for word, freq in counter.most_common(max_size - 4): if freq min_freq: vocab[word] len(vocab) return vocab4.2 编码器实现细节编码器用单层双向 LSTM。双向的好处是每个位置的隐藏状态都同时包含左侧和右侧的上下文信息。对于翻译任务源语言的理解需要全局信息双向编码器比单向效果好很多。编码器输出两个东西所有时间步的隐藏状态给注意力用和最终隐藏状态给解码器做初始化。class Encoder(nn.Module): def __init__(self, vocab_size, emb_dim, enc_hid_dim, dec_hid_dim, dropout): super().__init__() self.embedding nn.Embedding(vocab_size, emb_dim, padding_idx0) self.rnn nn.LSTM(emb_dim, enc_hid_dim, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(enc_hid_dim * 2, dec_hid_dim) self.dropout nn.Dropout(dropout) def forward(self, src): embedded self.dropout(self.embedding(src)) outputs, (hidden, cell) self.rnn(embedded) # hidden: (2, batch, enc_hid_dim) - (batch, dec_hid_dim) hidden torch.tanh(self.fc(torch.cat((hidden[-2], hidden[-1]), dim1))) return outputs, hidden这里有个细节双向 LSTM 的最终隐藏状态有两个分别来自前向和后向。我们把它们拼接后过一个线性层映射到解码器需要的维度。这样解码器的初始隐藏状态就同时包含了源序列开头和结尾的信息。4.3 解码器与注意力融合解码器每个时间步做四件事读入当前 token 的 embedding、用注意力计算上下文向量、把上下文向量和 embedding 拼接后送入 LSTM、通过输出层预测下一个 token。class Decoder(nn.Module): def __init__(self, vocab_size, emb_dim, enc_hid_dim, dec_hid_dim, dropout, attention): super().__init__() self.attention attention self.embedding nn.Embedding(vocab_size, emb_dim, padding_idx0) self.rnn nn.LSTM(emb_dim enc_hid_dim * 2, dec_hid_dim, batch_firstTrue) self.fc_out nn.Linear(emb_dim dec_hid_dim enc_hid_dim * 2, vocab_size) self.dropout nn.Dropout(dropout) def forward(self, input_token, hidden, encoder_outputs): input_token input_token.unsqueeze(1) # (batch, 1) embedded self.dropout(self.embedding(input_token)) # (batch, 1, emb_dim) attn_weights self.attention(hidden, encoder_outputs) # (batch, src_len) attn_weights attn_weights.unsqueeze(1) # (batch, 1, src_len) context torch.bmm(attn_weights, encoder_outputs) # (batch, 1, enc_hid_dim*2) rnn_input torch.cat((embedded, context), dim2) output, (hidden, cell) self.rnn(rnn_input, (hidden.unsqueeze(0), cell.unsqueeze(0))) prediction self.fc_out(torch.cat((output.squeeze(1), context.squeeze(1), embedded.squeeze(1)), dim1)) return prediction, hidden.squeeze(0), cell.squeeze(0)注意输出层把三样东西拼在一起LSTM 输出、上下文向量、当前 token 的 embedding。这种“三合一”拼接是注意力 Seq2Seq 的常见做法能让模型在预测时同时利用解码器状态、源序列信息和当前输入。4.4 训练技巧与超参数选择训练时用交叉熵损失但必须忽略pad位置的损失。因为 padding 是我们人为补的不是真实目标。忽略方式很简单nn.CrossEntropyLoss(ignore_index0)。优化器选 Adam学习率从 1e-3 开始。如果训练 loss 震荡降到 5e-4 或 3e-4。Batch size 根据显存来8GB 显存跑 64 到 128 没问题。梯度裁剪一定要加torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)防止梯度爆炸。我试过不加梯度裁剪训练到第 3 个 epoch loss 直接变成 nan加了之后稳定很多。Teacher Forcing 的比例可以设成 0.5 到 0.7 之间。完全用 Teacher Forcing 会导致曝光偏差严重完全不用又收敛太慢。折中方案是训练前期用 0.7后期降到 0.3。5. 常见问题与排查技巧实录5.1 训练 loss 不下降怎么办这是最常见的问题。排查顺序如下排查项可能原因解决方法学习率太大导致震荡太小导致停滞尝试 1e-3、5e-4、1e-4梯度裁剪梯度爆炸加 clip_grad_norm_数据预处理词表映射错误、padding 位置不对打印几条样本检查模型初始化全零初始化导致对称性用默认初始化或 Xavier损失函数没忽略 padignore_index0我遇到过一次 loss 死活不降最后发现是词表构建时把pad的索引设成了 1但ignore_index写的是 0。这种低级错误排查起来最费时间建议一开始就把特殊 token 的索引固定下来写个常量类管理。5.2 推理时生成重复词或提前结束解码器在推理时可能陷入循环一直生成同一个词。常见原因有两个一是注意力权重坍缩所有注意力都集中在一个位置二是eos的概率被低估模型不知道什么时候该停。解决方法在推理时加repetition penalty对已经生成过的 token 降低其 logit 值。另外可以设置最大生成长度防止无限循环。如果模型总是提前生成eos检查训练数据里eos的比例是否过高或者损失函数对eos的权重是否过大。5.3 长序列效果差即使加了注意力源序列超过 50 个 token 时效果还是会下降。这时候可以考虑用子词分词BPE、WordPiece代替词级分词缩短序列长度用Transformer替代 RNN自注意力机制对长距离依赖建模更好在注意力里加位置编码让模型感知 token 的相对位置我实测下来把词级分词换成 BPE 之后平均序列长度缩短了 40%BLEU 提升了 3 个点以上。这个投入产出比非常高建议优先尝试。5.4 显存不够用Seq2Seq 训练时显存占用主要来自三块embedding 矩阵、LSTM 隐藏状态、注意力矩阵。如果显存吃紧可以减小 batch size用梯度累积模拟大 batch降低 embedding 维度从 256 降到 128用混合精度训练torch.cuda.amp能省 30% 到 40% 显存把词表大小从 30000 降到 10000提示混合精度训练时softmax 和 loss 计算建议保持在 float32否则容易数值不稳定。6. 从 Seq2Seq 到更远的地方Seq2Seq 加注意力这个组合在 2017 年之前几乎是序列生成任务的标配。后来 Transformer 出来了用自注意力完全替代了 RNN训练并行度更高长序列效果更好。但 Transformer 的 encoder-decoder 架构本质上还是 Seq2Seq 的思路编码器理解输入解码器生成输出中间用交叉注意力传递信息。你把 Seq2Seq 的编码器换成 Transformer encoder解码器换成 Transformer decoder注意力模块换成多头交叉注意力就得到了一个标准的 Transformer 翻译模型。所以我的建议是先把 Seq2Seq 加注意力跑通理解每一步在做什么再去学 Transformer。否则你只是会调库遇到问题根本不知道从哪里下手。我自己带过几个新人直接上 Transformer 的遇到 loss 不降基本束手无策先跑过 Seq2Seq 的至少知道要检查注意力权重、检查 padding mask、检查学习率。这个内容后续还可以这样扩展把注意力权重可视化出来看看模型在生成每个词时到底关注了源句子的哪些位置。对于翻译任务你通常能看到类似“主谓宾对齐”的模式非常直观。另外可以尝试在解码器端加 beam search比贪心解码的 BLEU 能高 1 到 2 个点代价是推理速度变慢。如果要做对话生成还需要处理“通用回复”问题那又是另一个话题了。
返回列表