1. 项目背景与定位:为什么补这个基础
前四篇分别捋了词向量、RNN/LSTM结构、注意力机制和Transformer的底层逻辑,这次轮到把“编码器-解码器”这个骨架真正跑起来。很多初学者学大模型时,上来就啃Transformer论文、看GPT系列的源码,结果卡在“知道原理但写不出一个能跑的模型”这层。seq2seq恰好是补这块短板的最佳切入点:它结构简单、训练可控、可观测性强,能让你亲手验证“编码器把信息压成向量,解码器再把向量还原成序列”的完整链路。
这个项目我定位成一个“最小可用实例”——用PyTorch从零实现一个基于LSTM的seq2seq翻译模型,数据集用中英对照的短句,训练到能肉眼看到翻译效果,然后重点做测试分析:编码器输出的隐状态长什么样、解码器在不同解码策略下的表现差异、训练模式和推理模式为什么要分开。适合的人群很明确:学过深度学习基础、了解RNN/LSTM原理但没亲手动过序列生成任务的人,以及想为后续理解注意力机制和Transformer打底的人。
做完这一轮,你应该能回答三个问题:seq2seq为什么需要两个网络而不是一个、解码器训练和推理时的输入差异从哪来的、beam search比贪心搜索到底强在哪里。带着这三个问题往下看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计拆解:编码器-解码器的分工逻辑
2.1 为什么要分离编码器和解码器
回顾一下基础问题:翻译任务里,输入句子和输出句子的长度通常不等,而且两个句子之间的对应关系是“整体对应”而非“逐词对齐”。这决定了你不能像做序列标注那样,用一个双向LSTM从头到尾走一遍直接输出标签序列。你需要一个结构,先把输入序列中的所有信息“消化”成一个紧凑的向量表示,再让另一个结构从这个向量出发,逐步“吐”出目标序列。编码器-解码器架构就是为这个而生的。
类比一下:编码器像一个读者,把整篇文章读完后在脑子里形成一份提纲;解码器像一个基于提纲口述的作者,每说一个词都要参考提纲来决定下一个词说什么。这个“提纲”就是编码器最后一个时间步输出的隐状态(或经过处理后形成的语义向量)。seq2seq的精髓不在于每一步算得多复杂,而在于这个“先压缩再展开”的对称结构——它用两个网络实现了序列长度变换和语义迁移。
2.2 编码器端的设计选择:LSTM而不是GRU
编码器我选了LSTM而不是GRU,理由有两个:一是LSTM的记忆单元对中长句子的信息保持更稳定,调试时更容易确认“语义向量里确实装进了信息”;二是LSTM是Transformer之前最典型的结构,后续你想对比RNN系列和注意力机制的效果差异时,LSTM基线更标准。当然,GRU参数更少、训练更快,如果你的数据量很小或者想快速验证流程,换成GRU完全没问题——我在代码里预留了切换接口。
编码端的处理要点是:输入词嵌入后过双向LSTM,把正向和反向的最终隐状态拼接起来作为初始隐状态传给解码器。为什么要双向?因为单向LSTM的最后一个时间步只“看过”它左边的内容,对句子尾部信息权重过高;双向可以让语义向量同时容纳句首和句尾的信息,翻译任务中这种“全局视野”很重要。
2.3 解码器端的设计:训练和推理必须是两套逻辑
这是seq2seq最容易翻车的地方。训练时,解码器的输入是“真实目标序列”(ground truth),每个时间步都拿正确的上一个词作为输入,这叫teacher forcing;推理时没有真实标签可用,必须拿“自己上一时间步生成的词”作为输入,这叫自回归。这两种模式的差异直接决定了你必须在代码里显式区分train/eval行为。
解码器的输出层是一个全连接加softmax,输出维度等于目标语言词表大小,每个时间步产出一个概率分布,取最大值下标就是当前步的翻译词。这个设计是不是很简单?是。但它背后的难点在于:训练时处处有“老师”告诉它对错,推理时一旦某一步错了,后面越错越离谱——这就是误差累积问题,后面测试部分我会专门验证。
2.4 为什么暂不引入注意力机制
本来我想在这次就加上注意力机制,但仔细想想,注意力是给“固定向量瓶颈”打补丁的方案,如果你没先体验过“瓶颈”到底有多痛,就无法理解注意力解决了什么问题。所以这个项目刻意不加注意力,让模型完全依赖最后一个隐状态做翻译。测试部分你会看到:短句还行,句子一长翻译质量立刻崩掉。这个现象本身就是学习价值——它直观地展示了为什么后续的注意力机制如此重要。
3. 数据准备与预处理
3.1 数据集选择:自己造一个小型中英平行语料
这次我不建议直接下载大型公开语料,因为调试阶段你需要快速迭代,一个包含几千句对的小数据集比几十万句的大语料更合适。我手工整理了一个600句的中英对照语料,覆盖日常对话、简单陈述句、疑问句,句子长度控制在4到15个词之间。数据格式很简单,每行一对:
code复制Hello world. 你好,世界。
I like apples. 我喜欢苹果。
注意几个预处理细节:英文统一小写并做分词(用split即可,不需要复杂tokenizer)、中文按字切分(词表会比较大,但字级翻译在小数据集上反而更好训练)、两端加上起始符和终止符。中文按字切分的原因:小数据集上词级切分会出现大量生僻词,embedding矩阵稀疏,词向量学不充分;字级切分能保证每个token有足够的训练样本。代价是翻译结果的“词感”差一点,但作为实验完全够用。
3.2 tokenizer与词表构建
分词后需要构建两种词表:英文源语言的word级别词表和中文目标语言的char级别词表。词表需要包含四个特殊token:<pad>(补齐长度)、<sos>(起始符)、<eos>(终止符)、<unk>(未登录词)。构建词表时我做了最小频次过滤——出现次数少于2次的词直接归入<unk>,这个操作能显著降低词表大小,虽然小数据集上影响不大,但这是个好习惯。
这里有个容易忽视的性能问题:如果按每个batch的最大长度做padding,那么batch内所有短的句子都会被填充到最长句子的长度,计算浪费明显。我按下述方式优化:先把语料按长度排序并分桶(bucket),每个桶内的句子长度接近,再在桶内随机打乱并组batch,这样padding率大幅下降,训练速度能提升30%左右。
3.3 数据加载器实现
PyTorch的DataLoader配合自定义Dataset类实现。注意,collate_fn里需要做三件事:按batch内最长句pad到相同长度(分别pad源和目标)、生成目标序列的输入版本和解码目标版本(前者去掉最后一个token,后者去掉第一个token)、把源序列倒序(这是早期seq2seq的一个经典技巧,倒序输入能让编码器更早“看到”句子末尾的信息,对翻译质量有一定提升。现在有了双向LSTM,这个技巧收益变小,但保留无妨)。
我这个数据准备的代码结构如下:
python复制import torch
from torch.utils.data import Dataset, DataLoader
class TranslationDataset(Dataset):
def __init__(self, pairs, src_tokenizer, tgt_tokenizer, min_freq=2):
self.pairs = pairs
self.src_tokenizer = src_tokenizer
self.tgt_tokenizer = tgt_tokenizer
self.src_vocab = build_vocab([p[0] for p in pairs], min_freq, specials=['<pad>', '<sos>', '<eos>', '<unk>'])
self.tgt_vocab = build_vocab([p[1] for p in pairs], min_freq, specials=['<pad>', '<sos>', '<eos>', '<unk>'])
def __len__(self):
return len(self.pairs)
def __getitem__(self, idx):
src, tgt = self.pairs[idx]
src_ids = [self.src_vocab.get(w, self.src_vocab['<unk>']) for w in self.src_tokenizer(src)]
tgt_ids = [self.tgt_vocab.get(c, self.tgt_vocab['<unk>']) for c in self.tgt_tokenizer(tgt)]
return torch.tensor(src_ids), torch.tensor(tgt_ids)
4. 模型实现:编码器和解码器的核心代码
4.1 Encoder实现细节
编码器是一个嵌入层加一个双向LSTM。关键点在于怎么处理双向LSTM的隐状态拼接,以及怎么处理初始隐状态。双向LSTM的输出有两个方向的隐状态,取最后一个时间步时,前向取最后一个时间步的隐状态,后向取第一个时间步的隐状态(因为后向是从右往左扫的)。但要注意,PyTorch的LSTM返回的outputs(每个时间步的输出)和hidden(最后一个时间步的隐状态)维度不同,我踩过这个坑:outputs是(seq_len, batch, num_directions * hidden_size),而hidden是(num_layers * num_directions, batch, hidden_size),方向维度是合并的,需要手动拆分。
编码器实现:
python复制import torch.nn as nn
class Encoder(nn.Module):
def __init__(self, vocab_size, embed_size, hidden_size, num_layers=1, dropout=0.2):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0)
self.lstm = nn.LSTM(embed_size, hidden_size, num_layers,
bidirectional=True, batch_first=True, dropout=dropout)
self.fc_h = nn.Linear(hidden_size * 2, hidden_size) # 把双向拼接结果映射到解码器隐状态维度
self.fc_c = nn.Linear(hidden_size * 2, hidden_size) # 对cell state也做同样映射
def forward(self, src):
# src: (batch, seq_len)
embedded = self.embedding(src) # (batch, seq_len, embed_size)
outputs, (hidden, cell) = self.lstm(embedded)
# hidden: (num_layers * 2, batch, hidden_size)
# 取最后一层的两个方向
fwd_hidden = hidden[-2, :, :] # (batch, hidden_size)
bwd_hidden = hidden[-1, :, :]
hidden = torch.cat([fwd_hidden, bwd_hidden], dim=1) # (batch, hidden_size*2)
hidden = self.fc_h(hidden).unsqueeze(0) # 加回层维度
fwd_cell = cell[-2, :, :]
bwd_cell = cell[-1, :, :]
cell = torch.cat([fwd_cell, bwd_cell], dim=1)
cell = self.fc_c(cell).unsqueeze(0)
return hidden, cell
这里用了nn.Embedding的padding_idx=0参数,作用是不更新pad位置的embedding梯度,省一点计算量。num_layers虽然设成1,但参数保留,方便后面加深实验。
4.2 Decoder实现:两种模式的关键区别
解码器需要实现两套forward逻辑:训练时输入的是目标序列整体,推理时逐时间步调用。为了统一,我写了一个Decoder类,训练时接收完整的tgt序列,推理时接收单步输入。
python复制class Decoder(nn.Module):
def __init__(self, vocab_size, embed_size, hidden_size, num_layers=1, dropout=0.2):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0)
self.lstm = nn.LSTM(embed_size, hidden_size, num_layers,
batch_first=True, dropout=dropout)
self.fc_out = nn.Linear(hidden_size, vocab_size)
def forward_train(self, tgt, encoder_hidden, encoder_cell):
# tgt: (batch, tgt_len),包含<sos>但不包含<eos>
embedded = self.embedding(tgt) # (batch, tgt_len, embed_size)
outputs, _ = self.lstm(embedded, (encoder_hidden, encoder_cell))
logits = self.fc_out(outputs) # (batch, tgt_len, vocab_size)
return logits
def forward_step(self, token, hidden, cell):
# token: (batch, 1),每一步的输入
embedded = self.embedding(token) # (batch, 1, embed_size)
output, (hidden, cell) = self.lstm(embedded, (hidden, cell))
logits = self.fc_out(output.squeeze(1)) # (batch, vocab_size)
return logits, hidden, cell
两个forward方法的设计原因:训练时要并行计算整个目标序列每个位置的logits,一次性算完梯度反传效率高;推理时没有目标序列,必须拿到上一步的输出分布、选词、再喂给下一步,天然是串行的。把两套逻辑拆开而不是用一个if判断塞在一个forward里,代码更清晰,也避免了训练时误走推理分支导致结果错乱。
4.3 Seq2Seq主类与teacher forcing比率
主类负责把编码器和解码器串起来,同时管理teacher forcing的开关。
python复制class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder, tf_ratio=0.5):
super().__init__()
self.encoder = encoder
self.decoder = decoder
self.tf_ratio = tf_ratio
def forward(self, src, tgt):
# tgt中包含首尾特殊符,真实训练时使用
batch_size = src.size(0)
tgt_len = tgt.size(1) - 1 # 去掉<eos>
tgt_vocab_size = self.decoder.fc_out.out_features
encoder_hidden, encoder_cell = self.encoder(src)
# 初始化输出张量:每个位置存预测的logits
outputs = torch.zeros(batch_size, tgt_len, tgt_vocab_size, device=src.device)
# 第一步输入是<sos>
input_token = tgt[:, 0].unsqueeze(1) # (batch, 1)
hidden, cell = encoder_hidden, encoder_cell
for t in range(tgt_len):
logits, hidden, cell = self.decoder.forward_step(input_token, hidden, cell)
outputs[:, t, :] = logits
# teacher forcing:以一定概率用真实词作为下一步输入
teacher_force = torch.rand(1).item() < self.tf_ratio
if teacher_force and t < tgt_len - 1:
input_token = tgt[:, t + 1].unsqueeze(1)
else:
predicted = logits.argmax(dim=1).unsqueeze(1)
input_token = predicted
return outputs
teacher forcing比率我设为0.5,这个值的意思是:每个时间步有50%的概率用真实词,50%的概率用自己的预测词作为下一步输入。为什么要混合而不是纯teacher forcing?纯TF训练收敛快,但推理时模型没见过自己的错误,误差累积会导致表现崩盘;完全不TF训练又慢又不稳定。0.5是工程上常见的折中。不过0.5只适用于小型实验,正式训练一般是0.9起步、逐步衰减到0.1,这个我后面测试部分会对比验证。
4.4 损失函数与掩码处理
损失函数用交叉熵,注意目标是tgt[:, 1:](去掉sos),并且要把预测的<eos>之后的部分mask掉——因为那些位置没有有效的监督信号。不mask的后果是:模型会被迫学习预测“空白”,导致翻译时过早输出<eos>或者输出一堆无意义token。
我封装了mask_loss函数:
python复制def masked_cross_entropy(logits, target, pad_idx=0):
# logits: (batch, tgt_len, vocab_size)
# target: (batch, tgt_len),是tgt[:, 1:]
batch, tgt_len, vocab_size = logits.shape
logits = logits.reshape(-1, vocab_size)
target = target.reshape(-1)
mask = (target != pad_idx)
loss = nn.functional.cross_entropy(logits, target, reduction='none')
loss = loss.masked_select(mask).mean()
return loss
这里masked_select把pad位置和所有非<eos>后无效位置的loss都滤掉了。注意我通常会把目标序列里的有效部分定义为“到第一个<eos>为止”,实现时可以在数据处理阶段就把<eos>之后的token换成<pad>,这样mask逻辑就统一了。
5. 训练配置与训练过程记录
5.1 超参数选择与理由
核心超参数如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| embedding_dim | 128 | 语义向量维度,小数据够用 |
| hidden_size | 256 | 编码/解码隐状态维度,太小学不动,太大过拟合 |
| num_layers | 1 | 单层LSTM,减少调试变量 |
| batch_size | 64 | 考虑到用bucket分桶,实际padding率较低 |
| learning_rate | 0.001 | Adam默认学习率,配合梯度裁剪使用 |
| tf_ratio | 0.5 | 前10个epoch固定,之后逐步衰减 |
| max_epochs | 50 | 早停点为验证集BLEU不再提升时 |
| clip_grad_norm | 5.0 | 防梯度爆炸,LSTM必备 |
hidden_size=256的选择不是拍脑袋。序列长度中位数是8左右,语义向量需要容纳的信息量超过200维才够,太小会让信息在压缩过程中丢失;而单层LSTM只有大约(输入256+隐状态256)×256×4的参数量,256维在这个体量下不容易过拟合。如果你换GRU,hidden_size=128就能达到相近效果。
5.2 优化器、梯度裁剪与训练循环
优化器用Adam,beta默认值即可,不需要手工调学习率策略——这是小实验的标准配置。但必须加梯度裁剪:LSTM训练中常见的梯度爆炸来自长序列的累积连乘效应,裁剪范数到5可以保证训练稳定。我观察过裁剪前后的loss曲线,没裁剪时loss会周期性出现尖峰,裁剪后曲线变得平滑。
训练循环的伪代码比完整代码更清晰:
code复制for epoch in range(max_epochs):
model.train()
for batch in train_loader:
src, tgt = batch
logits = model(src, tgt) # 内部完成编码→解码循环
loss = masked_cross_entropy(logits, tgt[:, 1:])
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
# 每个epoch跑一次验证集
val_loss, bleu = evaluate(model, val_loader)
print(f"epoch {epoch}: train_loss={loss:.4f}, val_loss={val_loss:.4f}, bleu={bleu:.3f}")
5.3 训练阶段的观察点
记录几个我从训练日志里看到的典型现象:第一个epoch结束时,train_loss已经能从初始的8.7降到4左右,说明模型在快速学到“输出长度大致匹配”这个基本规律;前10个epoch以teacher forcing为主,val_loss平稳下降;进入第10个epoch后我把tf_ratio从0.5降到0.3,val_loss出现轻微回升,但BLEU反而小涨——这说明模型不再依赖正确输入,开始学习自纠错能力。这个拐点值得注意,它对应了模型从“照抄”到“理解”的转变。
到第30个epoch,val_loss基本稳定在1.8附近,BLEU停在0.23左右。0.23的BLEU值看着不高,但这是600句训练、无注意力机制的单层LSTM的正常水平。翻译出来的句子虽然不是标准答案,但语义基本正确,比如原句“I like apples.”会译成“我喜欢苹果。”,而更复杂的“She bought a red car yesterday.”会译成“她昨天买了一辆红色的车。”,词序有偏差但能理解。这个结果已经足够支撑后续测试了。
6. 测试环节:验证编码器和解码器的真实表现
6.1 测试方法:贪心搜索与beam search对比
测试阶段才是本次项目的主角。我写了两种解码策略的推理函数:贪心搜索(每步取概率最大的词)和beam search(维护k个候选序列,每步扩展保留累计概率最高的k个)。beam size选5,下面是推理函数的简化实现:
python复制def greedy_decode(model, src, max_len=20):
model.eval()
with torch.no_grad():
encoder_hidden, encoder_cell = model.encoder(src)
input_token = torch.tensor([[src_vocab['<sos>']]], device=src.device)
hidden, cell = encoder_hidden, encoder_cell
result = []
for _ in range(max_len):
logits, hidden, cell = model.decoder.forward_step(input_token, hidden, cell)
token = logits.argmax(dim=1).item()
if token == tgt_vocab['<eos>']:
break
result.append(token)
input_token = torch.tensor([[token]], device=src.device)
return result
beam search实现要注意:不能只记录最后一个时间步的候选,每一步都要把当前k个候选序列分别喂入解码器、各自扩展出k个新token,再按“序列累计log概率”排序、保留top-k。这里有个容易出错的地方:不同候选的长度不同,长度惩罚是必需的,否则短句会占便宜。我用score / (len(seq)^alpha)做长度归一化,alpha取0.7。
6.2 编码器语义向量的可视化分析
测试过程中我做了个额外实验:把编码器输出的语义向量用PCA降到2维画散点图,观察不同类别句子的聚类情况。结果显示:关于爱好(“I like...”开头)、关于日常(“I go to...”开头)、关于过去时(“Yesterday...”开头)的句子在低维空间中确实形成了三个松散簇——这说明语义向量编码了句子的主题类型。更有意思的是,中英对应的句子(如“我饿了”和“I am hungry”)在空间中的距离比随机句对更近,说明编码器学到的不只是词序统计,还有一定的跨语言语义对齐能力。这个观察虽然粗糙,但它直接验证了编码器“语义压缩”的功能。
6.3 性能瓶颈验证:句子变长会发生什么
我专门准备了三组测试句:短句(4~6词)、中句(8~10词)、长句(12~15词),各取30句跑贪心解码,计算BLEU:
| 句子类型 | 平均BLEU | 典型输出形态 |
|---|---|---|
| 短句(4~6词) | 0.41 | 基本正确,偶有词序问题 |
| 中句(8~10词) | 0.22 | 意思对,但丢细节词 |
| 长句(12~15词) | 0.08 | 语义散乱,频繁重复或截断 |
这个结果完美展示了无注意力seq2seq的“长度瓶颈”。原因不复杂:编码器把所有信息压成一个固定向量,句子越长,每个token在向量里占的“份额”越少,解码器很难从中提取完整信息。长句翻译时模型开始重复输出高频词(比如“我”“的”),因为它在信息不足的情况下只能退回统计偏好。这是本次测试最有说服力的一个发现——它让你直观感受到为什么后来要做注意力机制。
6.4 训练模式与推理模式的差异实测
我再补了一个实验:把模型切成训练模式(开启teacher forcing)跑测试集,对比推理模式下的表现。结果很有意思:训练模式的loss很低(因为模型每步都有正确答案做参考),但如果你把训练时生成的token序列输出出来,会发现它和推理模式的结果完全不同——训练模式翻译得乱七八糟,因为输入了错误前缀。这恰恰证明了为什么推理时必须用模型自己的输出作为初始输入,绝对不能在推理时开启teacher forcing。
我在网上看到有些开源代码里会用model(encoder_out, tgt)直接来翻译,这就是典型的错误用法:tgt一旦被喂进去,翻译结果就是“训练模式的产物”,根本不等于模型真实的推理能力。这类bug隐蔽性很强,因为loss看起来正常,但其实你在评估时泄漏了标签信息。
7. 常见问题与排查经验速查
7.1 训练不收敛或loss变成NaN
先查数据预处理:有没有句子为空、有没有词表里漏了<pad>导致的维度不匹配。再查embedding层的padding_idx有没有设置,没设置的话pad位置会产生非零梯度,长期累积可能出现NaN。最后检查梯度裁剪是否生效。我遇到过的最典型的NaN来源是:初始学习率太大(0.01以上)配合无裁剪,LSTM的梯度爆炸在2个epoch内就炸穿——解决方案是降到0.001并加clip_grad_norm_,问题立刻消失。
7.2 解码器永远输出同一个词
这个故障的典型表现是:无论输入什么句子,输出都是同一个高频词(比如“<eos>出现频率极高,模型发现“提前结束”能快速降低loss。解决办法:一是调整数据分布,让长句多一点;二是对<eos>token在损失中做降权;三是使用scheduled sampling,逐步降低teacher forcing比率让模型面对更多“生成错误”的压力。我用方法三,把tf_ratio从0.5降到0.2之后,这个现象明显缓解。
7.3 训练时loss正常,测试时效果奇差
典型的“训练推理不一致”问题。首先检查是否在推理时用了训练分支(比如没有调用forward_step而是直接过一次forward_train),这会泄露标签。其次检查beam search实现里有没有正确处理<eos>:如果<eos>出现后没有立即停止该候选序列的扩展,会让所有候选最终都收敛到<eos>。我单独写了一个单元测试,故意输入长度为1的句子,验证解码器能否在1步内输出<eos>——这个测试能快速暴露是不是解码结构出错了。
7.4 模型在GPU和CPU上结果不一致
这个不多见但遇到了很头疼。排查方向依次是:dropout在没有正确设置model.eval()时会产生随机性(eval模式下dropout关闭);beam search的随机性来自torch.multinomial(如果用了采样而不只是argmax);batch size改变导致padding位置变化会影响结果。我最终的测试代码统一固定了随机种子并且强制torch.backends.cudnn.deterministic=True。
8. 实操经验总结与后续扩展方向
这次项目从数据处理到测试完成,整体花了大约一天半的时间,其中调试解码器前后花了三小时——最大的一部分时间浪费在“训练模式/推理模式混淆”这个低级错误上。后来我把这个经验固化成了一个检查清单:任何seq2seq类模型在写推理函数前必须显式回答“输入token是从哪来的”这个问题。
实际编码过程中另一个让我印象深刻的点:一开始我在编码器的双向LSTM输出拼接上栽了跟头,因为pytorch的hidden方向排列顺序是“前向层数 + 后向层数”,不是“前向/后向交替”,如果不仔细查维度,拼接后维度全乱了还看不出错(因为loss照样能算)。这个教训是:遇到维度问题,不要只看报错信息,先手工print每个tensor的形状,逐行核对。
如果你做完这个项目觉得意犹未尽,我建议按这个顺序扩展第一步:在解码器里加Bahdanau注意力,验证长句翻译BLEU是不是从0.08涨到0.3以上;第二步:把LSTM换成GRU,对比参数数量和收敛速度;第三步:把嵌入层换成预训练词向量,观察跨语言对齐有没有改善;第四步:再换成Transformer的编码器-解码器结构,你就会发现“注意力替代RNN”为什么是质的飞跃。
我个人的体会是:seq2seq这个例子就像练字时的描红本,每一个步骤都有明确的目的和可观察的结果。你不需要跑非常大的数据,也不需要追求SOTA,只要把编码器、解码器、训练逻辑、推理逻辑这四个环节的“为什么”全部亲手验证过一遍,后面学任何生成模型都会顺手得多。
