资讯动态

手写Seq2Seq机器翻译:从Encoder-Decoder到Teacher Forcing的完整实践

发布时间:2026/10/21 18:01:11 来源:尧图企业网站定制
写的什么内容都有了。开工前先说明这篇东西主要面向想把Seq2Seq机器翻译搞清楚的人尤其是那些啃论文啃得头大、总在理论上懂了动手就废边缘试探的同学。我会把自己踩过的坑、调试时的观察、以及一些教科书上不会明说的细节一并写出来代码示例基于PyTorch但你理解思路之后换成别的框架也完全没问题。1. 整体设计与思路拆解为什么偏偏是 Encoder-Decoder1.1 机器翻译到底在解一个什么问题机器翻译本质上是个序列到序列Sequence-to-Sequence的任务输入是一串源语言单词输出是一串目标语言单词两串长度通常不一样语序也经常对不上。比如英文 I love you 翻成中文是我爱你三个词对三个词运气好但 The cat sat on the mat 变成猫坐在垫子上英文六个词中文五个词长度就不一样了。这个长度可变到长度可变的映射是普通分类模型、甚至普通RNN做不了的。分类模型输出是固定维度向量RNN虽然能处理变长输入但输出的每一步只能依赖当前输入和之前的隐状态没法等到看完整个句子再决定怎么翻译。这就需要一个能先读完全文、提炼语义、再逐个生成目标词的结构。Encoder-Decoder 就是冲着这个需求来的Encoder 负责把不定长的源语言句子压缩成一个固定长度的语义向量或者一组向量Decoder 负责从这个向量出发逐词生成目标语言句子。整个过程有点像你先通读一篇英文文章在脑子里形成大意然后根据这个大意用中文把它复述出来——只不过机器的方式更机械、更数学化。1.2 RNN 选型的底层逻辑现在Transformer大行其道但聊Seq2Seq还是绕不开RNNLSTM/GRU。原因很简单Seq2Seq的概念和训练技巧尤其是Teacher Forcing都是在RNN时代被彻底研究透的而Transformer只是把条件从RNN的隐状态换成了注意力加权后的上下文向量整体的Encoder-Decoder框架和Teacher Forcing训练逻辑完全一脉相承。RNN家族处理序列的方式是逐步递推每读一个词就把这个词的信息和之前的记忆融合更新隐状态。这个逐步递推的特性恰好对应了人类阅读和说话的过程——你不可能跳过中间直接知道结尾。PyTorch里一个简单的GRU单元输入形状是(seq_len, batch, input_size)输出的是每个时间步的隐状态用起来非常直觉。至于为什么用GRU/LSTM而不是原始RNN原始RNN在反向传播时存在梯度消失/爆炸问题长句子的早期信息传不到最后。GRU和LSTM通过门控机制给信息传递修了一条高速公路让模型在长句子上也能把早期信息送到需要它的地方。实操中我倾向于用GRU因为参数更少、训练更快效果在中小规模数据集上和LSTM没什么明显差距。1.3 为什么这个架构能端到端训练传统机器翻译是走管线的分词、词性标注、句法分析、规则转换、生成每一环都要单独维护而且误差会逐级累积。Encoder-Decoder最大的优势是把整个翻译过程变成一个大函数输入句子进去、翻译结果出来中间的特征工程完全交给模型自己学。端到端的含义是只要你能定义损失函数预测词和真实词的交叉熵梯度就能从输出层一路传回Encoder的输入层。这意味着你不需要告诉模型主语在英语里通常在句首这类语言学知识模型自己从大量平行语料里统计出这些规律。这个设计思路的巨大好处是泛化同样的结构换个数据集就能做文本摘要、对话生成、代码补全。坏处是需要海量数据和算力小数据集上容易过拟合或者训练不稳定。所以我一直建议自己动手做实验时先拿小规模数据集比如几万句对的规模把流程跑通再考虑上大规模语料。2. 核心细节解析编码器如何把句子吃进去2.1 词嵌入从离散符号到稠密向量模型不认识单词它只认识数字。但你不能直接把单词编号喂给网络——cat是5342dog是977这两个数字的差没有任何语义意义。词嵌入层做的事情就是把每个离散的单词ID映射成一个稠密向量比如300维或512维让语义相近的词在向量空间里靠得近。嵌入层在PyTorch里就是一个查表操作import torch.nn as nn embedding nn.Embedding(vocab_size10000, embedding_dim256) # 输入形状 (seq_len, batch) 的LongTensor值范围 [0, 9999] # 输出形状 (seq_len, batch, 256) 的FloatTensor嵌入层是可以被训练的。刚开始所有向量随机初始化训练过程中模型会不断调整这些向量猫和狗的向量会逐渐靠拢good和bad的向量会逐渐远离。这背后是交叉熵损失在反向传播时对嵌入矩阵产生了梯度——所以嵌入层不是数据预处理而是模型的一部分。实操中有一个重要的细节嵌入维度不是越大越好。词表小的时候比如1万词以内256维足够词表几十万级别可以考虑512维。维度过大容易过拟合而且显存开销会明显增加——嵌入矩阵大小是vocab_size × embedding_dim词表10万、维度512就意味着5120万个参数光这一层就占掉约20MB显存。2.2 隐状态传递信息的压缩与融合RNN在每一步做的事情可以通俗理解成读入新词结合已有记忆更新记忆。这里的记忆就是隐状态向量h_t。以GRU为例它内部有更新门和重置门决定多少旧信息要保留和多少新信息要被写入。整个Encoder的过程是import torch # 假设输入句子是 I love youword_ids [i, love, you] 对应的索引 # 形状(seq_len3, batch1) input_tensor torch.tensor([[123, 456, 789]]) # 举例 # 初始化隐状态为全零向量 hidden torch.zeros(1, 1, hidden_size) # 逐词送入GRU单元 for t in range(3): # 每一步输入一个词形状 (1, 1, embedding_dim) word_embed embedding(input_tensor[:, t]) # 取第t个词 _, hidden gru_cell(word_embed, hidden) # 更新隐状态 # 循环结束后hidden 就是整个句子的语义压缩这个过程的本质是模型按顺序读完每个词每读一个词就对这段话大概在说什么进行一次修正。最后一个时间步的隐状态理论上蕴含了整个句子的信息——但理论上三个字划重点后面我会在长句子信息丢失的部分展开讲。2.3 一个实操细节为什么Encoder的初始隐状态是零向量很多初学者会问Encoder刚开始读第一个词的时候隐状态从哪里来答案是零向量。这是最公平的起点——模型还没看到任何词记忆就是空白。这个初始隐状态是超参数决定的它是一个(num_layers, batch, hidden_size)的全零张量。但要注意如果你用多层GRU每一层都需要单独的初始隐状态。num_layers2时初始隐状态的形状应该是(2, batch, hidden_size)。这个细节经常导致维度不匹配的报错我见过不少人在这里卡了半天。另外一个小技巧如果你在处理非常长的序列可以把初始隐状态设成可学习的参数让模型自己学一个好的起点。但实践中收益微乎其微反而增加过拟合风险所以常规做法就是全零初始化。3. Decoder 的生成逻辑从语义向量到逐词输出3.1 Decoder 的输入与输出设计Encoder 输出的是整个源句子的语义压缩向量最后一个时间步的隐状态。Decoder 要做的是从这个向量出发逐词生成目标语言句子。Decoder 本质上也是一个RNN但它的工作方式和Encoder有个关键区别Encoder 是一次性把所有词都读进来Decoder 是预测一个词把这个词当作下一步输入再预测下一个词。这个自回归autoregressive特性就像你写文章先写第一个字然后根据这个字想第二个字再根据前两个字想第三个字以此类推。Decoder 每一步做的事情接收上一步生成的词嵌入接收上一步的隐状态初始隐状态来自Encoder的最终隐状态通过GRU单元更新隐状态把隐状态通过一个线性层投射到目标词表大小过softmax得到每个目标词的概率分布代码output_projection nn.Linear(hidden_size, target_vocab_size) # 第一个输入SOS句子开始标记的嵌入 decoder_input sos_token_embed # 初始隐状态Encoder的最终隐状态 hidden encoder_final_hidden for t in range(max_target_len): embedded embedding(decoder_input) # (1, batch, embed_dim) output, hidden gru_cell(embedded, hidden) # 更新隐状态 logits output_projection(output) # (1, batch, target_vocab_size) probs softmax(logits, dim-1) # 概率分布 next_word_id argmax(probs, dim-1) # 贪心选择 decoder_input embedding(next_word_id) # 作为下一步输入 if next_word_id EOS_TOKEN: # 遇到句子结束标记就停 break关键点在于Decoder 在推理时每走一步输入永远是上一步生成的那个词而不是真实的词。这个设计在训练时有个大问题——我们下面详细说。3.2 SOS、EOS 和 Padding 的作用机器翻译必须处理句子边界因为模型需要知道何时开始生成和何时停止生成。SOSStart Of Sentence标记告诉Decoder你要开始输出了EOSEnd Of Sentence标记告诉解码器句子到此结束。为什么不能像文本分类那样直接输入空因为RNN必须有输入才能递推SOS就是一个空白启动信号。为什么需要EOS因为模型需要学会自己判断这句话已经翻译完了否则它会对齐源句和目标句的长度遇到长句就不会灵活停止。Padding则是另外一个维度的问题一个batch里的句子长度不同为了拼成张量需要把短句子补到最长句子的长度。但Padding的位置不能参与模型计算否则模型会学到输出没意义的填充符号。PyTorch的pack_padded_sequence就是干这个的——它让RNN只处理真实词跳过padding部分。这个细节对训练速度影响很大不用它的话短句子会白白计算一大堆padding步骤。3.3 推理时的贪心搜索是够用但不够好推理阶段最常见、最省事的做法是贪心搜索每一步直接选softmax概率最大的那个词。这个方法的优点是简单缺点是局部最优不等于全局最优——可能在第一个词选了次优解导致后面整个句子都歪了。举个例子I saw the man with the telescope如果第一步贪心选了saw当作看到后面很可能生成看到那男人带着望远镜而不是地道的我用望远镜看到了那个男人。为了缓解这个问题工程上一般用Beam Search每一步保留概率最高的K个候选序列比如K4或K5而不是只留1个。这就像你在岔路口把每条可能的路都走几步最后选一条整体分数最高的路。代价是推理时间变成K倍所以上线系统里K也不会设太大。4. Teacher Forcing让训练更稳定的一剂猛药4.1 训练和推理的裂缝现在我们把训练和推理摆在一起看就会发现一个尴尬的矛盾推理时每一步的输入是上一步生成的词训练时如果想要真实地模拟推理过程每一步的输入也应该是上一步生成的词但问题在于训练刚开始时模型是个傻子它生成的词基本都是乱猜。如果拿乱猜的词喂给下一步错误就会像滚雪球一样累积——第一步错了第二步在错误的基础上更错第三步直接跑偏到外太空。这个现象叫曝光偏差exposure bias。Teacher Forcing 的思路非常简单粗暴训练时不去管模型上一步生成了什么直接把上一步的真实目标词ground truth喂给Decoder。就像教小孩游泳时教练每划一下就在旁边扶着不让小孩真的呛水——虽然小孩还没掌握但至少不会因为呛水而崩溃。4.2 Teacher Forcing 的训练流程Teacher Forcing 的训练方式和推理有本质区别。推理是迭代式生成一个词、喂一个词但训练时我们手上有完整的目标句子可以一次性把所有词喂进去然后计算每一步的预测和真实词的交叉熵损失。具体做法是把目标句子整体右移一位作为Decoder的输入序列。假设目标句子是[SOS, I, love, you, EOS]那么Decoder的输入是[SOS, I, love, you]去掉最后的EOS我们要预测的目标是[I, love, you, EOS]去掉开头的SOS。这样一顿操作之后Decoder在时间步t的预测目标就是目标句的第t个词完美对齐。PyTorch代码长这样class Seq2Seq(nn.Module): def __init__(self, encoder, decoder): super().__init__() self.encoder encoder self.decoder decoder def forward(self, src, trg, teacher_forcing_ratio0.5): # src: 源句子词ID序列 # trg: 目标句子词ID序列 # 1. Encoder 前向得到最终隐状态 encoder_outputs, hidden self.encoder(src) # 2. 准备Decoder输入序列去掉trg最后一个词加上SOS在开头 # 此时 trg[:-1] [I, love, you, EOS]在前面拼上SOS decoder_input torch.cat([ sos_token_repeated, # 形状 (1, batch) trg[:-1] # 目标句去掉最后一个词 ], dim0) # 形状 (seq_len, batch) # 3. 一次性前向得到所有时间步的预测logits # decoder_outputs 形状: (trg_len, batch, target_vocab_size) decoder_outputs, hidden self.decoder(decoder_input, hidden) return decoder_outputs训练时的做法是全量one-shot forward和推理时的逐词迭代完全不同。这也是很多初学者容易混淆的地方训练用Teacher Forcing时就失去了自回归的递推结构推理时又要改回自回归。理解这个差异是整个Seq2Seq实现中最关键的一步。4.3 折中方案Scheduled SamplingTeacher Forcing 虽然让训练稳定但它有个副作用模型学会依赖真实词作为输入一旦推理时遇到自己的错误输出表现会打折扣——因为训练时永远没见过错误输入长什么样。Scheduled Sampling 是对Teacher Forcing的改进训练时以一定概率使用模型自己生成的词剩下的概率用真实词。这个概率随着训练进行从1完全Teacher Forcing逐渐降到0完全Free Running。就像教骑自行车一开始扶得死死的后面逐渐松手最终完全放手让学员自己骑。实际代码实现时我们不用完全逐词迭代而是可以在batch内部做混合。但要注意Scheduled Sampling会让训练变得不稳定尤其是概率衰减过快的时候。我的建议是在小数据集上先用纯Teacher Forcing跑稳再考虑Scheduled Sampling做微调。如果追求稳定Scheduled Sampling在翻译任务上的收益其实有限反而在生成任务如对话、故事生成上收益更明显。4.4 为什么Teacher Forcing没有破坏端到端可微性一个自然的问题既然Teacher Forcing在某些时间步使用了模型自己生成的词作为输入那这些词是从概率分布里采样出来的采样是离散操作怎么反向传播答案是纯Teacher Forcing情况下根本不存在这个问题因为输入是真实的词ID梯度直接从损失流向网络。而Scheduled Sampling里如果采样出模型自己生成的词这个路径的梯度会被截断因为离散采样不可导只有使用真实词的部分才有梯度。所以Scheduled Sampling本质上是稳定性和可微性的折中多给模型看错误输入但代价是梯度信息变少。另外要注意一个常见误解Decoder在训练时虽然一次性输入了整个目标句但每个时间步的预测只依赖当前隐状态而当前隐状态依赖于之前的输入词。所以信息流仍然是从前到后的并没有泄漏未来信息——这是因果性的体现。5. 完整实操从零手写一个机器翻译模型5.1 数据准备与预处理我先说数据这一环因为数据的好坏直接决定训练效果。机器翻译需要平行语料库也就是同一句话的双语版本。公开数据集有很多但自己学习时用一个小规模的数据集比如维基百科的简单句子对足够了。数据预处理的完整流程清洗去HTML标签、去多余空格、统一标点分词英文按空格分中文可以用分词工具但简单实验里也可以按字切分构建词表统计词频保留前N个高频词如8000-10000其余映射为UNK未知词序列化把每个句子变成词ID序列加上SOS和EOS构建DataLoader按batch打包padding到相同长度代码示意from torch.utils.data import Dataset, DataLoader class TranslationDataset(Dataset): def __init__(self, src_sentences, trg_sentences, src_vocab, trg_vocab): self.src [src_vocab.encode(s) for s in src_sentences] self.trg [trg_vocab.encode(s) for s in trg_sentences] def __len__(self): return len(self.src) def __getitem__(self, idx): return self.src[idx], self.trg[idx] # vocab.encode 做的事情 # 1. 分词 # 2. 加上 sos 和 eos # 3. 转成词ID列表Padding 这一步有个坑一定要用pack_padded_sequence把有效长度告诉RNN否则RNN会对padding位置也进行前向计算浪费算力事小关键是模型会把padding当作有效内容学习导致输出中包含无意义的重复词。5.2 模型定义Encoder 与 Decoder把上面的概念落成代码。这里给出一个简单但完整的Seq2Seq实现用GRU作为基础单元。import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers1, dropout0.1): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim) self.gru nn.GRU(embedding_dim, hidden_size, num_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0) def forward(self, src): # src: (batch, src_len) embedded self.embedding(src) # (batch, src_len, embedding_dim) outputs, hidden self.gru(embedded) # outputs: (batch, src_len, hidden_size) return outputs, hidden class Decoder(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers1, dropout0.1): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim) self.gru nn.GRU(embedding_dim, hidden_size, num_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0) self.fc_out nn.Linear(hidden_size, vocab_size) def forward(self, trg, hidden, encoder_outputsNone): # trg: (batch, trg_len) embedded self.embedding(trg) # (batch, trg_len, embedding_dim) outputs, hidden self.gru(embedded, hidden) # (batch, trg_len, hidden_size) logits self.fc_out(outputs) # (batch, trg_len, vocab_size) return logits, hidden注意Decoder的forward里我预留了encoder_outputs参数——这是为了后面加Attention时不用改动接口。现在先不用它但建议一开始就把接口留好避免后续折腾。Encoder和Decoder的hidden_size必须一致否则Decoder拿不到Encoder的隐状态。这是个简单但容易犯的错。5.3 训练循环损失计算与梯度更新训练循环是Seq2Seq和普通分类模型一个明显的不同点loss需要一次性计算整个序列的交叉熵而且要对padding部分做mask。def train_one_epoch(model, iterator, optimizer, criterion, clip): model.train() epoch_loss 0 for src, trg in iterator: # src: (batch, src_len), trg: (batch, trg_len) optimizer.zero_grad() # 前向Teacher Forcing ratio 可以每次随机 output model(src, trg) # (batch, trg_len, vocab_size) # 计算loss将output和trg都改成 (batch * trg_len, vocab_size) 和 (batch * trg_len) output_dim output.shape[-1] output output.reshape(-1, output_dim) trg trg.reshape(-1) loss criterion(output, trg) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() epoch_loss loss.item() return epoch_loss / len(iterator)这里有几个细节值得展开说。第一criterion用的是nn.CrossEntropyLoss(ignore_indexpadding_idx)。ignore_index参数是专门用来处理padding位置的这样模型在padding位置上的预测不会贡献loss。第二梯度裁剪gradient clipping在RNN训练里几乎是必需品。因为GRU虽然缓解了梯度消失但在长序列上仍然可能出现梯度爆炸——尤其是序列长度波动大的时候。我习惯把clip设为1.0或0.5这是经验值你可以试到0.1到5.0之间。第三Teacher Forcing ratio 我给的是一个随机值。实践中常见做法是固定teacher_forcing_ratio0.5也就是了一半真实词一半模型词。还有一种做法是从1.0开始线性衰减到0.0。我建议新手先用0.5试模型更容易收敛等模型稳定了再调低。5.4 推理Beam Search 实现推理阶段我们要把模型切回逐词生成模式。这里给出贪心搜索和Beam Search两种实现。贪心搜索def greedy_decode(model, src, max_len50, sos_id1, eos_id2): model.eval() with torch.no_grad(): encoder_outputs, hidden model.encoder(src) trg_indexes [sos_id] for _ in range(max_len): trg_tensor torch.tensor([trg_indexes[-1]]).unsqueeze(0) # (1, 1) logits, hidden model.decoder(trg_tensor, hidden, encoder_outputs) pred_token logits.argmax(2).item() trg_indexes.append(pred_token) if pred_token eos_id: break return trg_indexesBeam Search会稍微复杂一些核心思路是维护一个beam宽度为K的候选序列池def beam_decode(model, src, beam_width4, max_len50, sos_id1, eos_id2): model.eval() with torch.no_grad(): encoder_outputs, hidden model.encoder(src) # 每个候选包含序列、累计log概率、隐状态 candidates [([sos_id], 0.0, hidden)] for _ in range(max_len): all_candidates [] for seq, score, hid in candidates: trg_tensor torch.tensor([seq[-1]]).unsqueeze(0) logits, new_hid model.decoder(trg_tensor, hid, encoder_outputs) log_probs F.log_softmax(logits[0, 0], dim-1) # 取top-K个词 topk_probs, topk_idx log_probs.topk(beam_width) for i in range(beam_width): new_seq seq [topk_idx[i].item()] new_score score topk_probs[i].item() all_candidates.append((new_seq, new_score, new_hid)) # 按得分排序保留前K个 all_candidates.sort(keylambda x: x[1], reverseTrue) candidates all_candidates[:beam_width] # 如果所有的候选都生成了EOS提前结束 if all(seq[-1] eos_id for seq, _, _ in candidates): break # 选得分最高的序列排除EOS之后的部分 best_seq, _, _ candidates[0] return best_seqBeam Search里一个容易被忽略的细节是为了让beam有多样性我们在扩展每个候选时都取top-K所以总的候选数量是beam_width × beam_width然后从中挑top-K。这个操作保证了每一步结束后候选数仍然是K不会指数膨胀。另外Beam Search有一个长度惩罚的参数。因为模型倾向于生成短句子log概率是负的连乘之后越长的句子得分越低。实践中可以使用Google的length penaltyscore score / ((5 len_seq) / 6) ** alphaalpha一般取0.6到1.0。这个细节对长句子的翻译质量影响很大。6. 常见问题与排查技巧实录6.1 模型输出全是UNK怎么办这是新手最容易遇到的问题模型预测出来一堆unk翻译结果毫无意义。原因通常有两个第一词表太小。如果你的词表只有5000词很多生僻词、专有名词都会被映射到UNK。解决方法是扩充词表或者在预处理时做BPE/子词切分subword tokenization。子词切分是目前工业界的标准方案——把unbelievable切成un、believ、able既减小词表又保留语义。第二UNK在词表里的位置靠前模型训练初期倾向输出UNK。这时候你需要检查word2idx的构建逻辑UNK应该是索引0还是其他编号nn.CrossEntropyLoss的ignore_index不能设置成UNK的索引否则模型会完全无视UNK这个输出。我的建议是在小规模实验中直接增大词表到2万以上同时使用子词切分。等到模型能输出正常单词了再回来调其他参数。6.2 训练loss不下降怎么排查Loss不下降这几乎是每个训练Seq2Seq的人都经历过的噩梦。我的排查顺序是数据是否有问题打印几个batch看看src和trg的序列化是否正确SOS/EOS位置对不对。模型是否初始化了把输出logits打印出来看看是不是接近均匀分布log(vocab_size)的量级。学习率是否合适Seq2Seq的默认学习率可以试试1e-3到3e-3用Adam优化器通常没问题。但如果你用了较大的词表可能需要调低到1e-4。是不是存在NaN如果loss变成了NaN检查学习率是否过大或者嵌入层的权重是否炸掉。如果有梯度裁剪排查clip值是否太小导致学不动。如果以上都没问题还有一个可能你的数据量太少。几万句对对于Seq2Seq来说确实捉襟见肘。这时候可以适当调小模型hidden_size降到128或者用预训练的词向量如GloVe初始化嵌入层会明显加快收敛。6.3 长句子的翻译质量特别差这是Encoder-Decoder架构的天然瓶颈固定长度的隐状态向量信息容量有限。就算GRU比RNN强句子超过30个词之后早期信息也难免被挤出隐状态。解决思路有两个方向第一用注意力机制Attention。这是目前最有效的方案——Decoder在每一步都能看到Encoder所有时间步的输出而不是只依赖最后一个隐状态。本质上是给Decoder开了一扇回看原句的窗口让它自己决定翻译这个词的时候应该重点看源句的哪个部分。第二倒序输入源句子。在Attention出现之前有人发现把源句子倒过来输入Encoder能显著提高BLEU分数。原因是缓解了短期依赖的问题——源句开头和翻译开头只隔了一步模型更容易对齐。虽然Attention出来之后这个技巧用得少了但在某些任务上仍然有效值得一试。6.4 训练比预想的慢很多怎么优化训练速度慢最常见的原因是你没有用padding的优化技巧。具体来说别忘了pack_padded_sequence和pad_packed_sequence。这两个函数让RNN跳过padding位置的计算短句子的batch能省一半以上的时间。用batch_firstTrue参数让输入形状是(batch, seq_len, ...)减少permute操作的开销。别用 Python 的 for 循环逐词跑训练。PyTorch的RNN单元是C实现的用nn.GRU一次性前向比手写for t in range(seq_len)逐个调用 GRUCell 快一个数量级。另外Seq2Seq的batch_size和大模型不一样。RNN是时间步串行、batch内并行的batch_size设太大反而容易OOM。我一般在翻译任务上用32到128根据输入句子的平均长度和显存调整。6.5 训练集loss很低测试集却完全不行这就是过拟合的典型症状。Seq2Seq模型参数多小数据集上很容易把所有训练句子背下来而不是学到翻译规则。解决过拟合的手段Dropout在Encoder和Decoder的GRU层输出后加dropout。要注意dropout只用于层间比如多层GRU之间不能加在时间步上——时间步之间需要连续的信息传递。减小hidden_size128或256在小数据集上足够。Early stopping监控验证集loss连续几个epoch不降就停。数据增强替换同义词、随机打乱词序、对源句子做back-translation。这些在低资源机器翻译里都有明显效果。我的经验是如果训练集不到5万句对hidden_size用256就够了模型大到1024反而极容易过拟合。7. 一些经验之谈自己手写一遍Seq2Seq和直接用现成的库完全不是一回事。我最初的时候直接用现成封装好的翻译接口对自己的体系毫无帮助。后来痛下决心从数据预处理到beam search全部手写才算是把Encoder-Decoder、Teacher Forcing这些概念真正变成了长在自己身上的知识。有几个印象很深的体验第一Teacher Forcing 真的是训练质量和生成质量之间的一条分界线。刚毕业的时候我用纯Teacher Forcing训练训练loss降到很低感觉一切完美结果一到推理阶段输出就是乱码把我急得不行。后来才明白模型从来没见过自己生成的东西推理时输入的分布和训练时完全不同自然就崩了。试着把ratio降到0.5甚至0.3之后情况立刻好转。这个教训我记了特别久。第二不要一开始就追求Transformer。Transformer更强大但细节更多定位假阳性的难度也更大。RNN时代的Seq2Seq知识尤其是Teacher Forcing和Attention的思想能帮你打好底子。等把RNN版的每一个细节都吃透了再迁移到Transformer你对为什么Transformer要设计成那个样子的理解会瞬间深刻很多。第三模型调试时一定要给自己写一个debug模式只取100个样本、只训练1个epoch、打印每个batch的loss和输出样例。这一步看起来多余但实际上能节省大量排查问题的时间。很多bug是训练了3个小时才发现数据预处理错了。先用100个样本把整个流程快速走一遍确认每个环节都正常了再开完整训练。第四如果你计划在这个方向深耕我建议在RNN版跑通之后继续沿着Attention - Transformer - 预训练这条路走下去。注意这里的核心不是堆积更多技术名词而是理解同样的学一点预测一点逻辑在不同架构里是怎么演化的。走了这条路线你对NLP领域的理解会有一个质的飞跃。代码量不大但把每一个细节都弄明白收获会比泛泛地刷很多模型要大得多。希望大家能自己动手跑一跑遇到那些奇奇怪怪的bug也不要气馁——每一个bug都是帮你把模型内部机制吃透的最好老师。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑