聊到序列到序列模型,圈子里一般直接缩写成Seq2Seq。这节【耿直哥深度学习】10.7,不聊那种“第一步理论、第二步公式、第三步不知所云”的教程,就干一件事:把编码器—解码器这套架构用PyTorch一行行撸出来,从数据准备到模型训练再到推理输出,全流程跑通。你只要会点Python,知道RNN和Embedding大概在干什么,照着下面的代码敲,一个下午就能看到自己的小模型在“翻译句子”。跑通之后,机器翻译、文本摘要、对话生成这些任务的底层逻辑,基本都串起来了。
很多新手学到这儿容易卡住,因为单看框架图觉得简单,无非Encoder把句子压成一个向量,Decoder再展开成另一个句子。但真到代码层面,问题就多了:padding怎么处理?注意力mask怎么加?训练时明明用真实词作为解码输入,到推理时为什么得换成模型自己预测的词?这些坑我不替大家踩一遍,光看书根本记不牢。所以这篇直接把代码、参数、训练技巧摊开聊,能复现一个最小可用的翻译模型,比背十张架构图都管用。
1. 先想明白:Seq2Seq到底在解决什么问题
1.1 为什么普通RNN搞不定“变长到变长”
在Seq2Seq出现之前,大家处理序列任务多用单层RNN或LSTM。这类模型结构上有个死穴:输入一个序列,输出通常只能对应一个标签。比如情感分类,输入一句话,输出“正面/负面”;或者文本生成,输入一个定长窗口,预测下一个词。但机器翻译不是这样,英文“i am a student”翻成法语,长度可能对得上,但“how are you”翻成某种语言可能就变成三个以上词。更麻烦的是,翻译时目标语言的词序和源语言并不一一对应,比如“i love you”里的“you”在句法结构中的位置可能完全不同。
Seq2Seq的核心突破在于放弃“输入输出等长”这个假设,换成两段式结构:第一段把整个源句子读进去,压缩成一个携带语义的向量;第二段从这个向量出发,一个一个词地生成目标句子。关键是第二段生成第几个词、生成多少词,都由模型自己决定,所以输出长度可以和输入完全不同。这个设计思路后来也被广泛用到其他任务,比如文本摘要、图像描述、语音识别,都可以抽象成“一个序列(或特征序列)到另一个序列”的映射。
1.2 从“一个向量扛所有”到“每一步都回头查”
早期的Seq2Seq有个很大的问题:编码器最后一步的隐藏状态要承担整个句子的语义编码。句子短还好,句子一长,前面的信息在RNN传递过程中会被慢慢“冲淡”,最后一步向量根本装不下完整信息,翻译质量急剧下降。这就好比让你把一本三百页的书压缩成一句话,再让另一个人根据这句话把书完整复述出来,信息丢失是必然的。
后来大家加上了注意力机制,思路一下就变了:解码器在生成每个词时,不再只依赖一个固定向量,而是回头看编码器每一步的输出,自己决定“现在应该重点参考源句子的哪个部分”。这个机制这几年已经成为标配,所以下面代码里我直接实现带注意力的版本,省得大家后面还得二次改造。
1.3 这一节代码能干什么
我用一个超小的英法平行语料(就8句),构建一个带Bahdanau注意力机制的GRU翻译模型。模型虽然小,但麻雀虽小五脏俱全:包含词表构建、batch填充、双向编码器、注意力解码器、Teacher forcing训练、贪心解码推理。全部跑完,你能看到模型从完全乱译到慢慢输出像样句子的过程,而且可以把这个代码骨架直接迁移到更大的数据集上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备与词表构建:先把文本变成数字
2.1 最小词表与特殊符号
训练任何文本模型之前,第一步都是把自然语言转成离散ID。我习惯先定义一个Vocab类,把四个特殊符号固定在最前面,分别是:<pad>(填充符)、<sos>(句子开始符)、<eos>(句子结束符)、<unk>(未知词)。
这里有个很多人第一次写时容易忽略的点:<pad>必须放在ID=0的位置,后面用nn.Embedding的时候直接指定padding_idx=0,这样padding位置的向量永远是零向量,不会参与梯度更新,也能省一点计算量。<sos>和<eos>是训练解码器用的锚点:解码器看到<sos>就知道开始生产第一个词,模型生成<eos>就等于告诉外部“句子结束了”。如果没有这两个符号,模型既要学语义又要学“什么时候停”,很容易停不下来或者提前结束。
python复制import torch
import torch.nn as nn
import torch.optim as optim
import random
class Vocab:
def __init__(self):
self.word2idx = {}
self.idx2word = []
# 固定特殊符号
for token in ["<pad>", "<sos>", "<eos>", "<unk>"]:
self.word2idx[token] = len(self.idx2word)
self.idx2word.append(token)
def add_sentence(self, sentence):
for token in sentence.split():
if token not in self.word2idx:
self.word2idx[token] = len(self.idx2word)
self.idx2word.append(token)
def encode(self, sentence, add_eos=False):
ids = [self.word2idx.get(token, self.word2idx["<unk>"])
for token in sentence.split()]
if add_eos:
ids.append(self.word2idx["<eos>"])
return ids
def decode(self, ids):
tokens = []
for idx in ids:
token = self.idx2word[idx]
if token in ("<eos>", "<pad>", "<sos>"):
break
tokens.append(token)
return " ".join(tokens)
2.2 构造一批平行语料并padding
我直接构造一个小数据集作为示例。虽然是玩具数据,但结构上包含了冠词、代词、动词变位等,足够让模型学到点东西:
python复制train_pairs = [
("i am a student", "je suis etudiant"),
("i love you", "je t aime"),
("you are my friend", "tu es mon ami"),
("he is a teacher", "il est professeur"),
("she likes coffee", "elle aime le cafe"),
("we study together", "nous etudions ensemble"),
("they play football", "ils jouent au football"),
("how are you", "comment vas tu"),
]
注意,这里法文部分为了演示故意不用带重音字符,避免词表出现大小写/重音混乱。真实项目中,建议先做分词、小写化、去除多余标点,再进行词表构建。
训练时一个batch里的句子长短不一,必须统一长度才能放进矩阵。做法是先对每个batch找出最长句子,把短句的后面补上<pad>。但这里有个关键细节:解码器端的padding不能和编码器端一样简单处理。解码器的目标序列在计算损失时需要把<pad>位置的loss屏蔽掉,否则模型会被引导去预测一堆填充符。后面训练部分我会用nn.CrossEntropyLoss的ignore_index参数处理。
python复制def make_batch(pairs, src_vocab, tgt_vocab):
src_vocab.add_sentence(pair[0] for pair in pairs) # 实际会先全局构建词表
tgt_vocab.add_sentence(pair[1] for pair in pairs)
实际操作中应该先把所有语料过一遍Vocab,再做batch。我这儿为了展示把两步分开写,正式项目里可以封装成一个Dataset类。
3. 模型架构:双向编码器与注意力解码器
3.1 编码器:用双向GRU把句子读成特征序列
编码器我选择双向GRU。为什么不用单层单向?因为单向RNN每个时刻的隐藏状态只能看到它之前的词,而中文、英文里的词义经常依赖后面的词来消歧。双向GRU则是分别从前往后、从后往前读两遍,再把两个方向的隐藏状态拼起来,这样每个位置的输出都同时包含上下文信息。
我的实现里,nn.GRU设置batch_first=True,输入形状是(batch, seq_len),输出形状是(batch, seq_len, hidden_dim * 2)。这里乘2是因为双向会把两个方向的隐藏状态拼接。后面解码器做注意力时,要拿这个三维的输出做key/value,所以不用只保留最后一步状态。
python复制class Encoder(nn.Module):
def __init__(self, vocab_size, emb_dim, hidden_dim, dropout=0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, emb_dim, padding_idx=0)
self.gru = nn.GRU(emb_dim, hidden_dim, batch_first=True,
bidirectional=True)
self.dropout = nn.Dropout(dropout)
def forward(self, src_ids):
# src_ids: (batch, src_len)
embedded = self.dropout(self.embedding(src_ids)) # (batch, src_len, emb_dim)
outputs, hidden = self.gru(embedded)
# outputs: (batch, src_len, 2*hidden_dim)
# hidden: (2*num_directions, batch, hidden_dim)
return outputs, hidden
这里有个经验:embedding后面加一个Dropout。词嵌入是高维稀疏表示,在训练数据量小时非常容易过拟合,Dropout能明显提升泛化。我当时在模拟项目里试过,去掉这层后验证集损失立刻上涨,加上后稳很多。
3.2 注意力:让解码器学会回头看
Bahdanau注意力(也叫加性注意力)的核心是计算一个分数,表示解码器当前状态和编码器每个位置输出的相关程度。公式不复杂:score = v^T · tanh(W_q · h_dec + W_k · h_enc)。先分别把解码器状态和编码器输出做线性变换,然后相加并通过tanh激活,最后跟一个可学习的向量v做点积,得到每个位置的标量分数。
为什么要加tanh?因为不加非线性变换的话,模型本质上只能学到线性相关性,而翻译时源词和目标词之间的关系不是简单线性就能刻画的。加性注意力相比点积注意力(乘性注意力)计算稍慢,但在hidden_dim不大的时候差别不大,而且对维度的选择更鲁棒。
这里最关键的坑是mask处理。编码器的padding部分在语义上没有意义,如果不把对应位置的注意力分数设成负无穷,解码器就会把注意力均匀分到padding上,导致翻译结果出现重复词或无关词。
python复制class Attention(nn.Module):
def __init__(self, enc_hidden_dim, dec_hidden_dim):
super().__init__()
# 编码器输出是双向拼接后的,所以输入维度是 2*enc_hidden_dim
self.W_e = nn.Linear(enc_hidden_dim * 2, dec_hidden_dim, bias=False)
self.W_d = nn.Linear(dec_hidden_dim, dec_hidden_dim, bias=False)
self.v = nn.Linear(dec_hidden_dim, 1, bias=False)
def forward(self, decoder_hidden, encoder_outputs, src_mask):
# decoder_hidden: (batch, dec_hidden_dim)
# encoder_outputs: (batch, src_len, 2*enc_hidden_dim)
# src_mask: (batch, src_len)
dec_proj = self.W_d(decoder_hidden).unsqueeze(1) # (batch, 1, dec_hidden_dim)
enc_proj = self.W_e(encoder_outputs) # (batch, src_len, dec_hidden_dim)
energy = self.v(torch.tanh(dec_proj + enc_proj)) # (batch, src_len, 1)
energy = energy.squeeze(2) # (batch, src_len)
# mask处理:padding位置替换成非常小的数
energy = energy.masked_fill(src_mask == 0, -1e9)
attn_weights = torch.softmax(energy, dim=1) # (batch, src_len)
return attn_weights
-1e9这个数不是随便选的,它要足够小,使得softmax之后对应位置的权重趋近于零,但又不能太小导致数值溢出。实际工作中我用过-1e9,也用过torch.finfo(torch.float32).min,两者效果差不多。要注意不能用0,因为0进softmax后仍然会得到非零概率,mask就失效了。
3.3 解码器:一个带着注意力生成的GRU
解码器比编码器复杂,因为它每个时刻要接受三个输入:当前输入词(训练时是目标句子真实词,推理时是上一步预测词)、上一个隐藏状态、编码器输出(用于计算注意力)。先通过embedding拿到当前词的向量,再与注意力加权后的“上下文向量”拼接,送入GRU,最后经过输出层映射到词表大小。
这里有一个维度设计的细节要讲清楚:我把dec_hidden_dim设成与编码器单向部分的维度一致,例如都是hidden_size=256。这样的话,编码器双向拼接后是512维,注意力层输出也是256维的上下文向量。解码器GRU的输入维度就是emb_dim + 256。第一次写的时候维度对不上是常态,报错信息里RuntimeError: size mismatch会告诉你哪个维度不匹配,按这个思路逐层排查就行。
python复制class Decoder(nn.Module):
def __init__(self, vocab_size, emb_dim, enc_hidden_dim, dec_hidden_dim, dropout=0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, emb_dim, padding_idx=0)
self.attention = Attention(enc_hidden_dim, dec_hidden_dim)
self.gru = nn.GRU(emb_dim + dec_hidden_dim, dec_hidden_dim, batch_first=True)
self.fc_out = nn.Linear(dec_hidden_dim * 2 + emb_dim, vocab_size)
self.dropout = nn.Dropout(dropout)
def forward(self, decoder_input, decoder_hidden, encoder_outputs, src_mask):
# decoder_input: (batch, 1)
# decoder_hidden: (batch, dec_hidden_dim)
embedded = self.dropout(self.embedding(decoder_input))
attn_weights = self.attention(decoder_hidden, encoder_outputs, src_mask)
context = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs)
# context: (batch, 1, 2*enc_hidden_dim)
rnn_input = torch.cat([embedded, context], dim=-1)
output, decoder_hidden = self.gru(rnn_input, decoder_hidden.unsqueeze(0))
output = output.squeeze(1)
# 最后把输入词向量、RNN输出、上下文向量三者拼接,再映射到词表
logits = self.fc_out(torch.cat([output, context.squeeze(1), embedded.squeeze(1)], dim=-1))
return logits, decoder_hidden.squeeze(0), attn_weights
最后那个fc_out为什么拼接了三部分?这是参考经典论文的做法,把当前词信息、RNN输出、上下文信息都保留下来,让输出预测有更丰富的信号。实际消融实验也验证过,拼接比只用RNN输出效果更稳。
3.4 把编码器和解码器组装起来
Seq2Seq模型整体上就是先编码,再逐时刻解码。为了训练方便,我把Encoder和Decoder封装成一个Seq2Seq类,forward里同时完成encoder前向和decoder的一步步迭代。注意我在每个解码时刻都手动调用decoder(...),而不是用nn.GRU一次处理整个序列,原因在于解码时的输入有两种来源(teacher forcing和self-generated),而且每个时刻都需要重新计算注意力权重,没法向量化一次跑完。
python复制class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder, device):
super().__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device
def forward(self, src_ids, tgt_ids, teacher_forcing_ratio=0.5):
# src_ids: (batch, src_len)
# tgt_ids: (batch, tgt_len),包含 <sos> 和末尾的 <eos>
batch_size = src_ids.shape[0]
tgt_len = tgt_ids.shape[1]
tgt_vocab_size = self.decoder.fc_out.out_features
encoder_outputs, encoder_hidden = self.encoder(src_ids)
src_mask = (src_ids != 0).to(self.device)
outputs = torch.zeros(batch_size, tgt_len - 1, tgt_vocab_size).to(self.device)
# 用编码器最后时刻的隐藏状态作为解码器的初始隐藏状态
decoder_hidden = torch.cat(
[encoder_hidden[-2], encoder_hidden[-1]], dim=1) # (batch, 2*hidden)
# 把初始隐状态压到 dec_hidden_dim
init_fc = nn.Linear(encoder_hidden.shape[0] * encoder_hidden.shape[2],
self.decoder.gru.hidden_size).to(self.device)
decoder_hidden = init_fc(decoder_hidden)
decoder_input = tgt_ids[:, 0].unsqueeze(1) # 第一个输入是 <sos>
for t in range(1, tgt_len):
logits, decoder_hidden, _ = self.decoder(
decoder_input, decoder_hidden, encoder_outputs, src_mask)
outputs[:, t - 1, :] = logits
# 判断是否用teacher forcing
use_teacher = random.random() < teacher_forcing_ratio
if use_teacher:
decoder_input = tgt_ids[:, t].unsqueeze(1)
else:
decoder_input = logits.argmax(dim=-1).unsqueeze(1)
return outputs
这里有一个初学者很容易写错的地方:编码器初始隐藏状态到解码器初始隐藏状态的维度转换。双向GRU的hidden形状是(2, batch, hidden),不能直接拿去初始化单层单间的解码器GRU(需要(1, batch, hidden))。我在代码里用torch.cat把两个方向的最后一层状态拼起来,再经过一个线性层压缩,这是最简单省事的做法。也有人只取其中某一个方向,但效果一般不如拼接后压缩。
4. 训练循环与推理:看着loss往下掉
4.1 训练函数:手写一个足够清楚的版本
很多框架封装了训练循环,但新手我建议手写至少一次,能够彻底理解batch怎么流动、loss怎么计算、梯度怎么更新。这个小模型的训练步骤很简单:前向算出所有时刻的logits,和真实目标序列计算交叉熵,注意ignore_index=0让模型忽略padding位置的loss,然后反向传播、梯度裁剪、更新参数。
python复制def train_one_batch(model, batch_src, batch_tgt, optimizer, criterion,
teacher_forcing_ratio=0.5, clip=1.0):
optimizer.zero_grad()
# 去掉目标序列里的 <sos>,因为解码是从 <sos> 之后的第一个词开始算loss
tgt_input = batch_tgt[:, :-1]
tgt_output = batch_tgt[:, 1:].contiguous().view(-1)
logits = model(batch_src, batch_tgt, teacher_forcing_ratio)
# logits: (batch, tgt_len-1, vocab_size)
loss = criterion(logits.view(-1, logits.shape[-1]), tgt_output)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
optimizer.step()
return loss.item()
注意这里loss的shape匹配。logits是(batch, tgt_len-1, vocab_size),展开成二维的(batch*(tgt_len-1), vocab_size);真实输出tgt_output是去掉<sos>后每个时刻的目标token。因为我们在构造目标句子时统一在末尾加了<eos>,所以去掉开头的<sos>之后长度正好是对齐的。
4.2 梯度裁剪不是可选项
RNN的BPTT计算图很深,梯度很容易在传播过程中指数级增长,出现NaN loss。clip_grad_norm_做的事情很简单:如果所有参数的梯度范数超过阈值,就整体等比例缩放。我一般设在1.0左右,训练这个小模型时即使不用也不会炸,但换到长句子数据集,不裁剪基本必炸。这属于“加了不一定有感觉,不加迟早出事”的防护措施。
4.3 Teacher forcing到底怎么理解
在训练解码器时,每个时刻的输入可以来自两种渠道:一是用真实的目标词,二是用模型上一步预测出来的词。前者就是Teacher forcing,后者属于free running。完全用Teacher forcing有个隐患:模型只学会在“看到正确答案”的前提下预测下一个词,一旦到了推理阶段,输入变成自己的错误预测,错误会累积,生成质量崩得很厉害。
常见的解决办法是在训练时随机选择:比如每个时刻以50%的概率用真实词,50%概率用模型自己的预测。这就是代码里teacher_forcing_ratio=0.5的作用。实际跑下来,这个比例在翻译任务上挺稳。如果任务比较难、句子特别长,可以把这个比例调高到0.7~0.8,训练前期稳定一些;后期再逐步降低,让模型适应自回归推理。
4.4 推理:贪心解码也能看效果
训练完成后,推理就是不带teacher forcing地去逐词生成。我实现一个贪心解码函数:在<sos>开始,每一步取概率最高的词作为下一个动作,遇到<eos>或者到达最大长度就停止。虽然贪心解码不是最优搜索策略(beam search会好很多),但排查模型是否收敛时它最直接。
python复制def translate(model, src_sentence, src_vocab, tgt_vocab, device, max_len=20):
model.eval()
src_ids = torch.tensor([src_vocab.encode(src_sentence)]).to(device)
src_mask = (src_ids != 0).to(device)
with torch.no_grad():
encoder_outputs, encoder_hidden = model.encoder(src_ids)
decoder_hidden = torch.cat([encoder_hidden[-2], encoder_hidden[-1]], dim=1)
init_fc = model.decoder.init_fc
decoder_hidden = init_fc(decoder_hidden)
decoder_input = torch.tensor([[tgt_vocab.word2idx["<sos>"]]]).to(device)
result_ids = []
for _ in range(max_len):
logits, decoder_hidden, _ = model.decoder(
decoder_input, decoder_hidden, encoder_outputs, src_mask)
next_token = logits.argmax(dim=-1).item()
result_ids.append(next_token)
if next_token == tgt_vocab.word2idx["<eos>"]:
break
decoder_input = torch.tensor([[next_token]]).to(device)
return tgt_vocab.decode(result_ids)
如果翻译效果差,第一步应该先确认tokenize和词表有没有对齐,第二步再去检查注意力权重是否集中在真正有意义的词上。把注意力矩阵打印出来可视化,能直观发现模型是不是在看垫片位置。
4.5 完整训练流程
训练时我会把整个数据集循环很多个epoch,因为语料只有8句,所以模型重复看很多遍,但代码逻辑和真实场景一致。
python复制EPOCHS = 300
lr = 0.001
emb_dim = 64
hidden_dim = 128
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
src_vocab = Vocab()
tgt_vocab = Vocab()
for src, tgt in train_pairs:
src_vocab.add_sentence(src)
tgt_vocab.add_sentence(tgt)
encoder = Encoder(len(src_vocab.idx2word), emb_dim, hidden_dim).to(device)
decoder = Decoder(len(tgt_vocab.idx2word), emb_dim, hidden_dim, hidden_dim).to(device)
model = Seq2Seq(encoder, decoder, device).to(device)
optimizer = optim.Adam(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss(ignore_index=tgt_vocab.word2idx["<pad>"])
for epoch in range(EPOCHS):
total_loss = 0.0
for src, tgt in train_pairs:
src_ids = torch.tensor([src_vocab.encode(src)]).to(device)
tgt_ids = torch.tensor(
[tgt_vocab.encode(tgt, add_eos=True)]
).to(device)
total_loss += train_one_batch(model, src_ids, tgt_ids, optimizer, criterion)
if epoch % 20 == 0:
print(f"epoch {epoch}, loss = {total_loss / len(train_pairs):.4f}")
跑上几百个epoch后,随便输入一句训练集里的句子,能看到输出逐渐接近正确的翻译。loss降到0.1以下之后,甚至可以拿没见过的句子去试,模型会输出个“像样但不够准确”的翻译,比如把“i like apples”(如果词表见过相关词)输出成“je aime des pommes”这种结构,你能明显感觉到它学会了“主谓宾”的顺序而不仅仅是背诵样本。
5. 常见问题与排错速查
5.1 注意力没有屏蔽padding导致翻译重复词
症状:输出句子出现同一个词连续重复三四次,或者翻译结果里混入大量无意义token。原因通常是Attention里没做mask,padding位置在softmax前不是负无穷,分到了非零概率。解决方案就是代码里的energy.masked_fill(src_mask == 0, -1e9)。
第一次写的时候,我其实知道该mask,但把mask用反了,结果是有效词被mask掉,padding反而正常,模型学了个寂寞。按我经验,mask的处理请反复检查:mask里的1代表有效、0代表无效。masked_fill是“在mask为True的位置填充value”,所以条件要写成src_mask == 0。
5.2 词典外的词全部变成<unk>
这是所有闭词表模型都有的通病。Vocab.encode里用了get(token, unk_idx),训练语料里没出现过的词,翻译时全变成<unk>。解决办法有三个层次:一是增加训练语料覆盖度;二是用BPE或SentencePiece这样的子词切分,把词表控制在几万以内同时保留词根信息;三是在解码后做简单的unk替换,比如对齐注意力权重找到源句子中对应的词直接抄过来。
对小项目而言,我建议至少掌握第一种方案:保留常用的几千个词,剩下的都标unk,同时不要在测试时输入太多词表外的词,先保证整体流程通。
5.3 Teacher forcing训练后推理效果崩
这是新手问得最多的一个问题。训练时loss下降得很好,一到推理就输出乱码。核心原因是训练和推理的输入分布不一致:训练大部分时间输入是真实词,推理时输入是自己预测的词,一步错步步错。改善思路:降低teacher_forcing_ratio,训练后期逐渐趋向零;或者加入计划采样(scheduled sampling),按epoch线性从1衰减到0.2;或者改用别的架构(比如Transformer)规避这个问题。如果只求快速见效,把ratio从0.5降到0.3,通常能明显改善推理效果。
5.4 Loss变成NaN
优先检查三件事:学习率是否太大、有没有做梯度裁剪、网络层里有没有出现除零或log0。RNN训练中NaN最常见的原因是梯度爆炸,clip_grad_norm_能解决。如果加了梯度裁剪还NaN,检查词表ID是否正确,比如某个token的ID超出了embedding矩阵范围。另外,padding位置如果被送入GRU且participates in loss计算,也可能导致梯度异常,把ignore_index设对就没问题。
5.5 模型怎么也学不动
如果loss从头到尾都不下降,先缩小数据规模,固定一个batch,看看是不是能过拟合。不能过拟合说明模型的表达能力或维度设置有bug,能过拟合说明训练流程没问题,再放大数据量。这个“先过拟合一个batch”的排查思路适用于所有神经网络,我每次写新模型都这么干,比看任何文档都管用。
| 现象 | 最可能原因 | 解决方案 |
|---|---|---|
| 输出重复词 | 注意力mask缺失或写反 | 检查masked_fill条件 |
| 推理比训练差很多 | teacher forcing比例过高 | 降低ratio,或加计划采样 |
| loss NaN | 梯度爆炸 | clip_grad_norm_,降低lr |
| 完全不学 | batch维度错误 | 打印每一层输出的shape逐步核对 |
一直出<eos> |
<sos>/<eos>符号顺序搞反 |
确认训练时解码输入以<sos>开头,目标以<eos>结尾 |
6. 一点实际操作心得
这个小翻译模型,前前后后在好几个模拟项目里被我改造过,最大的体会是:Seq2Seq的代码很短,但每一步的维度匹配和mask处理都很讲究。我见过太多人卡在维度报错上,其实只要把每个张量的shape写在注释里,debug起来会快很多。上面的代码我尽量都写清了(batch, seq_len, hidden)这类信息,你们跑的时候如果报错,先拿注释比对。
再说一个容易踩的坑:我用random库来控制teacher forcing,但如果要在多GPU或分布式训练里跑,random.random()不是deterministic的,复现实验时要记得设全局随机种子,包括Python的random.seed、PyTorch的torch.manual_seed和CUDA的torch.cuda.manual_seed_all。我一开始只设了PyTorch的种子,结果发现每次跑loss曲线都不同,排查了半天才意识到random的种子没设。
最后分享一个实用的扩展方向:把上面的贪心解码换成beam search(束搜索),保持每个时刻的top-k条路径,最后选出整体得分最高的句子。代码量不大,但翻译质量能明显提升一个档次,尤其是句子较长的时候。想要继续深入的话,还可以把GRU替换成Transformer编码器解码器,把位置编码加上去,这就慢慢往现代机器翻译模型上靠了。那部分内容比这节的Seq2Seq实现要庞大不少,但理解清这里的编码器—解码器交互、注意力plus mask、teacher forcing这三大核心,后面学Transformer会省很多力气。
