初学NLP的人多少都会碰到一个绕不开的入门项目用Pytorch手写一个Seq2Seq模型把中文翻译成英文并且加上Attention机制。这个项目几乎每家高校的NLP课程都会布置一遍覆盖面也确实广——从词表构建、词嵌入到RNN编码、注意力计算、解码生成再到BLEU评测一环扣一环。你把它彻底跑通一遍等于把序列生成任务的主干全摸了一遍。这篇文章就按我实际实现这个项目的思路把核心代码逻辑、训练细节和踩过的坑一次性讲透适合刚学完RNN基础、准备动手写第一个翻译模型的读者。1. 为什么经典Seq2Seq必须加Attention1.1 简单说清楚Attention到底解决什么问题最早的Seq2Seq模型是Encoder把所有输入信息压缩成最后一个隐状态然后交给Decoder慢慢展开。这就像让你先把一整篇英文课文背下来然后合上书用中文默写出来——句子短还好句子一长开头的内容早就被遗忘得差不多了。这就是RNN编码器最尴尬的地方隐状态是信息的唯一载体而这个载体有严重的容量瓶颈。Attention机制的思路特别朴素Decoder每一步解码的时候不要只盯着那一个句尾状态而是回头去看Encoder每一步输出的所有隐状态按这一步我对哪个位置更感兴趣分配权重再把这些状态加权求和成一个上下文向量。说白了就是边看原文边翻译而不是背完全文再默写。初学时最容易误解的一点是Attention不是让模型想起某个位置而是直接计算一个概率分布告诉Decoder当前步应该重点关注源句的哪几个词。这个分布一般是通过当前Decoder隐状态与所有Encoder隐状态做相似度打分再softmax得到的。有了这个分布再把所有Encoder隐状态加权求和就得到了携带当下最需要信息的上下文向量。1.2 翻译场景下的直觉理解拿我爱你翻译成I love you来举例。不带Attention时Encoder读完整个我爱你之后把你的信息也塞进最后一个状态里Decoder生成I的时候这个状态里既混着爱和你的信息又不能直接定位出i应该重点参考哪个源词汇。带Attention之后Decoder在生成I之前会对我爱你三个位置算出一个权重分布比如 [0.8, 0.1, 0.1]说明当前步主要参考的是我生成love时权重变为 [0.1, 0.9, 0.0]生成you时参考你。这样每个输出词都能从源序列里找到最该对齐的部分长句翻译的效果提升非常明显。所以这个项目里Attention不是可选的锦上添花而是让Seq2Seq真正能用的关键之一。你后面看Transformer会发现它本质上就是彻底抛弃RNN、只用Attention的结构注意力机制在序列建模里的地位可见一斑。2. 数据准备分词、词表与特殊标记一步都不能少2.1 语料选择与清洗机器翻译项目的数据准备没有你想的那么免费。公开的中英平行语料动辄几百万句对但都有大量的长句、噪声句、重复句直接丢进模型训练效果很拉胯。我当时用的是从开源社区整理的一份约5万句对的清洗后中英平行语料规模不大单卡训练几十分钟就能看到明显效果适合反复调参和跑实验。语料清洗这一步很容易被跳过却是最影响效果的隐形因素。清洗时我做了这几件事去掉长度过长或过短的句子对比如中文字数少于2或多于80英文字数少于1或多于100的直接扔掉去掉含有大量乱码、HTML标签或异常符号的句对去掉重复句对统一英文标点和中文标点避免你好。与你好被当成完全不同的句子这里有个容易被忽略的细节很多中英平行语料的英文侧用的是全角标点或混用了半角标点导致同一个英文句子因为标点不同出现两个不完整版本的词表。清洗时统一转成半角标点能让词表干净不少训练loss也能低一点。2.2 词表构建与超参设定分词策略上中文我用开源分词工具比如jieba切分英文比较简单按空格切分并做小写化就可以了。当然英文也可以直接调用spaCy但对这个任务规模来说有点杀鸡用牛刀规则切分足够。词表构建不是简单地把所有词收集起来而是要设置词频阈值和最大词表大小。我的做法是统计所有词频按频率从高到低排序截断到最大词表大小我这里设置英文词表3万、中文词表2万出现次数低于2次的词直接替换成UNK标记给词表加入4个特殊标记PAD、SOS、EOS、UNK词表大小直接决定Embedding层的参数规模。很多新手会忽视一个细节如果词表截断得太狠生僻词和专有名词全变成UNK训练loss看似还行实际翻译时大量出现UNK几乎不可用。我后来把词表扩到了4万/3万UNK比例明显下降。另外要特别强调一个词表构建顺序的坑PAD标记必须放在词表索引为0的位置而且之后创建Embedding层时一定要设置padding_idx0。否则PAD位置也会参与梯度更新模型会在空白处学出不该有的含义整个训练都变得不稳定。这是我项目初期损失一直降不下去的元凶之一后面会细说。数据侧的超参我也列一下方便你直接抄参数取值说明最大源句长度50超过截断最大目标句长度50超过截断英文最大词表30000按词频截断中文最大词表20000按词频截断最小词频2低于则替换为UNKPAD_ID0必须SOS_ID1解码起始符EOS_ID2解码终止符UNK_ID3未登录词3. 模型结构逐一拆解Encoder、Attention与Decoder3.1 Encoder双向GRU更稳Encoder我选择的是双向GRU而不是单向LSTM。LSTM当然也能用但GRU参数量更少训练速度更快在这个中规模翻译任务上效果和LSTM基本持平。另外双向结构非常重要——对于翻译任务我爱她中的爱到底对应英文的love还是loving有时候需要结合后文的她才能判断。单向RNN只看左侧上下文天然缺失这个信息双向结构等于把前后文都看了Encoder输出的每个位置隐状态包含的信息更完整。Encoder的输入形状为(batch_size, src_len)经过Embedding层变成(batch_size, src_len, embed_size)然后喂给双向GRU。Pytorch中设置batch_firstTrue输出有两个部分所有时间步的隐状态outputs以及最后一个时间步的隐藏状态hidden。其中outputs就是我们后面Attention要用的源序列状态集合形状为(batch_size, src_len, hidden_size * 2)。因为bidirectionalTruehidden_size要乘2。核心代码class Encoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idxPAD_ID) self.gru nn.GRU(embed_size, hidden_size, num_layers1, batch_firstTrue, bidirectionalTrue) def forward(self, src): embedded self.embedding(src) # (batch, src_len, embed_size) outputs, hidden self.gru(embedded) # outputs: (batch, src_len, hidden*2) return outputs, hidden这里有个工程上的选择我没有用pack_padded_sequence来处理变长序列。原因有两点一是这个模型规模不算大padding带来的额外计算量在可控范围内二是packed sequence与Attention机制配合时需要手动处理对齐新手很容易在pad_packed_sequence之后搞错状态维度的恢复顺序。为了代码可读性和低出错率我用Attention掩码来屏蔽PAD位置用少量算力换来了极高的实现清晰度。3.2 Attention的计算方式注意力机制实现上我采用的是全局注意力Global Attention的general形式也就是用点积后经过一个可学习的权重矩阵来计算相似度。整体流程分三步。第一步把Decoder当前步的隐状态query和Encoder所有位置的隐状态keys做相似度计算得到一个未经softmax的注意力分数。第二步对注意力分数做mask把源句子中PAD位置的分数替换成一个非常小的负数再softmax得到归一化的注意力权重。第三步用这个权重对所有Encoder隐状态做加权求和得到上下文向量context。这里要特别注意维度的处理。假设Decoder隐状态是(batch_size, hidden_size * 2)因为要和双向Encoder拼接Decoder的隐藏维度也设为Encoder的2倍Encoder输出是(batch_size, src_len, hidden_size * 2)。Attention打分时需要对query做unsqueeze变成(batch_size, 1, hidden_size * 2)然后利用广播机制与Encoder逐位置计算能量分数得到(batch_size, src_len, 1)最后squeeze成(batch_size, src_len)。这块非常容易因为维度不匹配报错我在第6节会专门展开。核心代码class Attention(nn.Module): def __init__(self, hidden_size): super().__init__() self.attn nn.Linear(hidden_size * 2, hidden_size * 2, biasFalse) def forward(self, query, encoder_outputs, src_mask): # query: (batch, hidden*2) # encoder_outputs: (batch, src_len, hidden*2) # src_mask: (batch, src_len) query query.unsqueeze(1) # (batch, 1, hidden*2) energy torch.tanh(self.attn(query)) # (batch, 1, hidden*2) scores torch.bmm(energy, encoder_outputs.transpose(1, 2)) scores scores.squeeze(1) # (batch, src_len) scores scores.masked_fill(src_mask 0, -1e9) weights torch.softmax(scores, dim-1) # (batch, src_len) context torch.bmm(weights.unsqueeze(1), encoder_outputs) context context.squeeze(1) # (batch, hidden*2) return context, weights有的教程会直接用torch.bmm(encoder_outputs, query)这种简单的点积注意力效果其实也可以。我这里多了一个self.attn线性层是让模型先学会把当前解码状态映射到源序列的表示空间再计算相似度这是Luong attention中general形式的核心比纯点积多了一点灵活度。3.3 Decoder把看到的信息解码成单词Decoder的结构比Encoder稍微复杂一点。每一步的输入有三个上一步的目标词Embedding、当前的隐状态、Encoder的源序列状态。流程是这样的先用上一个隐状态作为query通过Attention模块拿到上下文向量然后把上下文向量与当前输入词的Embedding拼接起来经过一个线性层和激活函数再输入到单向GRU中得到新的隐状态最后通过一个输出映射层把新的隐状态映射到目标词表大小的logits。之所以要把上下文向量和Embedding拼接后再进GRU是因为Attention选出来的信息本身就是Decoder决定下一步要生成什么词的最关键线索。你可以把它理解为读到了原文的某个位置现在要决定这个词怎么翻译。核心代码class Decoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idxPAD_ID) self.attention Attention(hidden_size) self.gru nn.GRUCell(embed_size hidden_size * 2, hidden_size * 2) self.fc nn.Linear(hidden_size * 2, vocab_size) def forward(self, x, prev_hidden, encoder_outputs, src_mask): embedded self.embedding(x) # (batch, embed_size) context, weights self.attention(prev_hidden, encoder_outputs, src_mask) rnn_input torch.cat([embedded, context], dim-1) hidden self.gru(rnn_input, prev_hidden) # (batch, hidden*2) logits self.fc(hidden) return logits, hidden, weights这里用GRUCell而不是nn.GRU是因为在训练阶段我们需要手动控制每一步的输入方便实施Teacher Forcing。如果你用nn.GRU虽然也能做但步长需要循环传入代码会绕很多。初始化Decoder第一个隐状态时官方论文的做法是把Encoder最后一个隐状态双向GRU会返回两个方向的状态需要拼接当作Decoder的初始状态。实现上我直接把Encoder返回的hidden形状为(2, batch, hidden_size)用torch.cat([hidden[0], hidden[1]], dim-1)拼成(batch, hidden_size * 2)作为Decoder的初始隐状态。这个细节也经常有人搞错——如果你没有拼接而是只取一个方向的状态Decoder初始状态的信息就只有一半长短句翻译效果都会受影响。4. 训练策略Teacher Forcing、损失掩码与梯度裁剪4.1 Teacher Forcing到底采样还是全用真实词训练Decoder的时候最常见的选择是Teacher Forcing也就是每一步都用真实的目标词作为输入而不是用模型上一步预测出来的词。用真实词训练收敛非常快因为模型不需要承担上一步错了连锁反应的代价每一步都在学习正确的条件概率分布。但这带来一个经典问题训练时用的都是真实词推理时只能用自己的预测词两个阶段的输入分布不一致。这被称为Exposure Bias。解决的办法很多最简单实用的就是Scheduled Sampling训练初期以80%的概率用真实词后期逐步降低到50%让模型有概率看到自己的错误预测增强推理时的鲁棒性。我当时为了收敛稳定性在代码里设了一个简单的策略teacher_forcing_ratio max(0.5, 1.0 - epoch / total_epochs) use_teacher_forcing random.random() teacher_forcing_ratio训练循环的写法大致是把目标句子去掉最后一个EOS作为Decoder输入序列去掉第一个SOS作为预测的目标输出序列。每个时间步都调用一次Decoder先用真实词更新再用上一步的logits取argmax作为下一步输入如果决定不teacher forcing的话。把每个时间步的logits收集起来拼成一个大的预测张量用于计算与目标输出序列的交叉熵。这里我建议一个工程习惯不要在每个时间步里单独算loss再累加而是把所有步的logits拼成一个(batch_size * tgt_len, vocab_size)的张量target直接展平成(batch_size * tgt_len)一次算完。这样Pytorch在反向传播时对计算图的处理也更高效。4.2 损失函数与PAD掩码损失函数直接用nn.CrossEntropyLoss(ignore_indexPAD_ID)。这个ignore_index参数至关重要。目标序列里有很多PAD填充位如果这些位置也参与loss计算模型会花费大量梯度去预测PAD严重干扰对实际有效词的学习。设置ignore_index之后PAD位置直接被跳过梯度只作用于有效token。不过这个损失函数有个容易踩的隐形坑如果整个batch的目标序列全部都是PAD在极端情况下当某个batch样本过短时可能出现计算出的平均loss会变成NaN。虽然概率不大但一旦出现就会导致整个训练挂掉。稳妥的做法是在计算loss之前做一个判断如果target的有效长度为零就跳过这个batch。训练时的核心代码criterion nn.CrossEntropyLoss(ignore_indexPAD_ID) for batch_idx, batch in enumerate(train_loader): src batch[src] # (batch, src_len) tgt_in batch[tgt_in] # (batch, tgt_len) tgt_out batch[tgt_out] # (batch, tgt_len) src_mask batch[src_mask] # (batch, src_len) logits model(src, tgt_in, src_mask) # (batch, tgt_len, vocab_size) loss criterion(logits.reshape(-1, vocab_size), tgt_out.reshape(-1)) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step()4.3 优化细节优化器我选了Adam初始学习率0.001然后用ReduceLROnPlateau调度器当验证集loss连续3个epoch不下降时学习率乘以0.5。这个策略比固定学习率的鲁棒性好太多尤其是后期loss经常会卡在一个平台期如果不降学习率模型训练很久都跳不出去。梯度裁剪是又一个必须加的保险。RNN在长序列反传时梯度容易爆炸max_norm5.0的裁剪能让训练稳定非常多。我见过有些人一开始不加裁剪训练到某个epoch时loss突然变成NaN然后从头再来很浪费时间。还有一个经验batch size不要选太小。翻译任务里batch size太小会导致每个batch内PAD比例偏高浪费算力且训练震荡大。我在这里用64显存2G左右就能跑。如果你的显存允许128会更好。5. 推理与评估从贪心解码到BLEU5.1 贪心解码流程训练好之后推理阶段相对简单Encoder一次性把源句子编码成所有隐状态Decoder从SOS开始每一步取logits的argmax作为当前预测词贪心策略把这个词作为下一步输入循环直到遇到EOS或者达到最大长度上限。贪心解码的优点是实现简单缺点是每一步只取概率最大的词一旦生成某个词出现偏差后面没有纠正的机会相当于一条路走到黑。高级一点的Beam Search会保留多个候选序列最终挑选整体概率最高的一个效果通常比贪心好不少。但贪心已经足够作为入门项目的主线Beam Search作为扩展点留着。推理代码里有个小细节值得注意Decoder每一步都传入同一个encoder_outputs和src_mask但prev_hidden和x是动态更新的。把这个逻辑写清楚推理部分其实就那么几十行。def translate_sentence(model, tokenizer_src, tokenizer_tgt, sentence, max_len50): model.eval() tokens tokenizer_src.encode(sentence) src torch.tensor([tokens]).to(device) # (1, src_len) src_mask (src ! PAD_ID).to(device) with torch.no_grad(): encoder_outputs, hidden model.encoder(src) hidden torch.cat([hidden[0], hidden[1]], dim-1) # (1, hidden*2) x torch.tensor([SOS_ID]).to(device) result [] for _ in range(max_len): logits, hidden, _ model.decoder(x, hidden, encoder_outputs, src_mask) next_token logits.argmax(dim-1).item() if next_token EOS_ID: break result.append(tokenizer_tgt.decode([next_token])) x torch.tensor([next_token]).to(device) return .join(result)5.2 BLEU评估手工看几个例句当然很有成就感但要衡量模型整体效果还得用BLEU分数。BLEU的核心思想是计算候选译文与参考译文之间n-gram的匹配情况并施加一个长度惩罚因子防止输出的句子太短反而得分高。可以直接用nltk里的sentence_bleu。我建议在计算时加一个平滑函数否则当参考句子较短、n-gram匹配较少的时候BLEU得分会莫名其妙变成0看起来非常难看。实际测试中加了SmoothingFunction之后分数的区分度会更好。from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction def compute_bleu(reference, candidate): ref [reference.split()] cand candidate.split() smooth SmoothingFunction().method1 return sentence_bleu(ref, cand, smoothing_functionsmooth)我当时的经验是在5万句对的小语料上模型在测试集上的BLEU能达到25到35之间不同随机种子波动较大。这个分数不要跟大型预训练模型动辄50、60比因为它本身就是在极小规模下从零训练的。你可以用BLEU分数在报告中证明Attention带来的提升把带Attention和不带Attention的模型分别训练一次对比BLEU通常会有5到10分的差距这个对比实验是报告里最有说服力的一张表格。值得注意的是BLEU不是万能的。它衡量的是n-gram重叠有时模型翻译得很通顺但用词和参考译文不同BLEU依然很低。所以评估时一定要人工看几十个例句把机器实际翻译效果与BLEU分数参照来看才能得出可靠结论。6. 踩坑实录维度错误、注意力看空气与EOS乱停6.1 注意力分数的维度不匹配这个坑几乎每个写Attention的人都会踩一遍我见过至少有三种报错频次最高scores的形状是(batch, src_len, 1)直接跟(batch, src_len)的mask做masked_fill时报错context的形状是(batch, 1, hidden*2)跟Decoder隐状态拼接时维度对不上weights忘记加unsqueeze(1)导致bmm运算报维度错误这些问题归根到底都是因为没有想清楚每一步张量形状的变化。我的建议是写Attention之前先在注释里把每个中间变量的形状写清楚然后每写一行都对照注释核对。比如# query: (batch, hidden*2) - unsqueeze - (batch, 1, hidden*2) # scores: (batch, 1, src_len) - squeeze - (batch, src_len) # mask: (batch, src_len) - 广播 # weights: (batch, src_len) - unsqueeze - (batch, 1, src_len) # encoder_outputs: (batch, src_len, hidden*2) # context: (batch, 1, hidden*2) - squeeze - (batch, hidden*2)Debug阶段可以在关键位置用print(x.shape)查看。不要想当然直接让程序告诉你形状是什么。这个习惯能帮你把调试时间缩短一半。6.2 注意力没有maskPAD位置抢权重训练时Encoder的输入序列是经过padding的Attention计算时如果不把PAD位置的分数mask掉softmax会对所有位置包括一堆PAD分配概率权重。这样有个直接后果模型会看到空白位置并从中提取无意义的梯度信息。我实测下来不加mask的模型BELLU会低2到4分而且注意力可视化时经常出现权重分散到尾部PAD位置的诡异图案。解决办法就是在softmax之前把源码中有PAD的位置分数替换为一个很大的负数比如-1e9。这样softmax之后这些位置的权重会趋近于0相当于强制模型忽略padding位置。另外有一点要提醒mask的构建要提前做不要在训练循环里临时拼。我一般把src_mask (src ! PAD_ID).float()放在DataLoader返回的batch里模型内部直接用。6.3 训练好但翻译差的Exposure Bias问题如果你发现训练集loss已经降得很低但推理时翻译出来的句子乱七八糟尤其是开头一两个词稍微错一点、后面的所有词都跟着崩那大概率是Exposure Bias在作怪。训练时Teacher Forcing每次都用对了词模型没见过自己的错误推理时第一步输入稍不准后面的误差就像滚雪球一样放大。解决思路有两个一是Scheduled Sampling前面已经提到二是训练时每个epoch随机选择一部分样本让Decoder全程用预测词来自动回归也就是free running让模型习惯错误传播。我最初比较激进地把free running的比例设得偏高结果loss震荡剧烈后来改为scheduled sampling训练和推理的gap明显缩小。还有一个小细节推理阶段的Decoder输入词表中如果出现了UNK不要直接丢掉。很多时候生成UNK是因为词表截断但强行丢掉会导致句子断掉。折中做法是保留UNK符号句子至少是通顺的。真正治本的方法还是扩大词表、增加语料或者在注意力机制里加copy机制不过这个就属于后话了。7. 注意力可视化与结果分析7.1 对齐矩阵热力图怎么看模型训练好之后最直观的驗证方式就是把注意力权重画出来看看模型在翻译每个英文词时到底在看中文句子的哪个位置。抽取Attention权重的方式很简单在翻译函数的循环里把Decoder返回的weights收集起来拼成一个(tgt_len, src_len)的矩阵然后用matplotlib的imshow画热力图。当时我拿我喜欢编程这句做测试生成英文是I like programming。热力图上能非常清楚地看到I 这一行的权重集中在我的位置like 集中在喜欢programming 集中在编程看起来就像一张对角线附近有粗壮色块的矩阵非常美观。项目报告里贴一张这种图比任何文字都更能证明Attention机制的有效性。7.2 长句中常见的错位现象再拿一个更长的句子测试今天天气很好我想去公园散步。 翻译结果一般是 the weather is nice today, i want to go for a walk in the park. 注意力图上你会看到一个有意思的现象英文的today对应中文的今天但它的注意力权重可能同时分散在今天和天气两个位置上。这是因为RNN的隐状态本身就把今天和天气的语义融合到了一起Attention在这种情况下会把权重合理地分布在语义相关的周边词上。这不算错误反而体现了软对齐的灵活性。但如果句子特别长比如超过50个字注意力分布往往会变得混乱出现某个英文词对应了中文句子里三个不相关位置的情况。这也印证了RNN的长期依赖问题是真实存在的。报告中可以专门列一个表展示在不同源句长度区间比如10字以内、10到20字、20字以上的BLEU分数你会发现长句的BLEU显著下降。这个实验结果很有说服力也是理解Attention局限性的好素材。8. 下一步扩展思路与个人体会把整个项目跑通之后可以做几个很有意思的扩展。第一个是把贪心解码换成Beam Search我试过beam size5之后BLEU大概能再涨1到3分代价是推理时间变长几倍。第二个是把双向GRU换成Transformer的Encoder部分只用Transformer Encoder Attention Decoder效果会进一步提升也为后面学Transformer做个铺垫。第三个是加入copy mechanism专门解决OOV问题让模型可以直接从原文复制生词这在翻译专有名词时非常有用。最后说点实际操作中的个人体会。这个项目我在本地反反复复跑了至少有五六遍每次重新实现都能发现之前没注意的问题。最深的感受是做这类NLP实战项目千万不要一次性把代码写完再跑而是先搭一个极小的语料比如1万句以下把整个链路跑通确认代码没有维度问题和逻辑问题再换全量数据训练。否则一旦全量数据训到一半才发现Attention维度写错了损失的时间比一开始慢跑的代价大得多。还有训练过程千万别急着看效果先盯着训练loss曲线。如果loss在最初几个epoch就能从5左右降到2左右说明代码逻辑基本正确如果loss一直不降或者忽上忽下优先排查三个方向数据是否有大量噪声、学习率是否过大、PAD是否没被ignore。这三个点排查完之后绝大多数问题都能解决。这个项目做完你对Seq2Seq、注意力机制、端到端生成任务的整个脉络都会清晰很多。后面无论接着学Transformer、BERT还是其他预训练模型底层的解码生成逻辑都是相通的。把RNNAttention吃透相当于给后面的学习打了一个非常扎实的地基。