1. 什么是序列到序列学习?
想象你要把一句英文翻译成法文。输入是一个单词序列(如"They are watching"),输出是另一个长度可能不同的序列(如"Ils regardent")。序列到序列学习(seq2seq) 就是专门处理这类输入输出都是序列的任务的模型。它像两个配合的机器人:一个负责"理解"输入句子(编码器),另一个负责"生成"输出句子(解码器)。
核心思想
- 编码器:将变长的输入序列转换为固定长度的"上下文向量"(好比把一篇文章压缩成摘要)。
- 解码器:根据上下文向量逐步生成输出序列,每次生成一个词(像根据摘要写读后感)。
2. 编码器:把句子变成"密码"
2.1 工作原理
编码器通常用循环神经网络(RNN)实现。假设输入是"They are watching",每个单词依次进入RNN:
- RNN读取"They",更新内部状态。
- 读取"are",再次更新状态。
- 读取"watching",得到最终状态 <math xmlns="http://www.w3.org/1998/Math/MathML"> h 3 h_3 </math>h3。
这个最终状态 <math xmlns="http://www.w3.org/1998/Math/MathML"> h 3 h_3 </math>h3就是整个句子的"上下文向量"。数学描述为:
<math xmlns="http://www.w3.org/1998/Math/MathML"> h t = f ( x t , h t − 1 ) \boxed{h_t = f(x_t, h_{t-1})} </math>ht=f(xt,ht−1)
<math xmlns="http://www.w3.org/1998/Math/MathML"> c = h T \boxed{c = h_T} </math>c=hT
其中:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> h t h_t </math>ht是时间步 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t的隐状态
- <math xmlns="http://www.w3.org/1998/Math/MathML"> f f </math>f是RNN的计算函数(如GRU、LSTM)
- <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c是上下文向量(最后一步的 <math xmlns="http://www.w3.org/1998/Math/MathML"> h T h_T </math>hT)
2.2 代码示例
现在,让我们实现循环神经网络编码器。注意,我们使用了 嵌入层(embedding layer) 来获得输入序列中每个词元的特征向量。嵌入层的权重是一个矩阵,其行数等于输入词表的大小(vocab_size),其列数等于特征向量的维度(embed_size)。对于任意输入词元的索引i
,嵌入层获取权重矩阵的第i
行(从0
开始)以返回其特征向量。另外,本文选择了一个多层门控循环单元来实现编码器。
python
class Seq2SeqEncoder(Encoder):
"""用于序列到序列学习的循环神经网络编码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqEncoder, self).__init__(**kwargs)
# 嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=dropout)
def forward(self, X, *args):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X)
# 在循环神经网络模型中,第一个轴对应于时间步
X = X.permute(1, 0, 2)
# 如果未提及状态,则默认为0
output, state = self.rnn(X)
# output的形状:(num_steps,batch_size,num_hiddens)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, state
下面,我们实例化上述编码器的实现:
python
import torch
import d2l
encoder = d2l.Seq2SeqEncoder(vocab_size=10, embed_size=8,
num_hiddens=16, num_layers=2)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
print(output.shape) # 输出:torch.Size([7, 4, 16])
print(state.shape) # 输出:torch.Size([2, 4, 16])
3. 解码器:从"密码"生成句子
3.1 工作流程
解码器同样用RNN实现,但它的初始状态是编码器的上下文向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c。生成过程如下:
- 初始输入是
<bos>
(序列开始符),状态是 <math xmlns="http://www.w3.org/1998/Math/MathML"> c c </math>c。 - 预测第一个词"Ils",并更新状态。
- 以"Ils"作为新输入,预测下一个词"regardent"。
- 直到输出
<eos>
(结束符)停止。
数学表达式:
<math xmlns="http://www.w3.org/1998/Math/MathML"> s t = g ( y t − 1 , s t − 1 , c ) \boxed{s_t = g(y_{t-1}, s_{t-1}, c)} </math>st=g(yt−1,st−1,c)
<math xmlns="http://www.w3.org/1998/Math/MathML"> P ( y t ∣ y < t , c ) = softmax ( W s t ) \boxed{P(y_t | y_{<t}, c) = \text{softmax}(W s_t)} </math>P(yt∣y<t,c)=softmax(Wst)
3.2 代码实现
python
class Seq2SeqDecoder(Decoder):
"""用于序列到序列学习的循环神经网络解码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqDecoder, self).__init__(**kwargs)
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
self.dense = nn.Linear(num_hiddens, vocab_size)
def init_state(self, enc_outputs, *args):
return enc_outputs[1]
def forward(self, X, state):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X).permute(1, 0, 2)
# 广播context,使其具有与X相同的num_steps
context = state[-1].repeat(X.shape[0], 1, 1)
X_and_context = torch.cat((X, context), dim=2)
output, state = self.rnn(X_and_context, state)
output = self.dense(output).permute(1, 0, 2)
# output的形状:(batch_size,num_steps,vocab_size)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, state
下面,我们用与前面提到的编码器中相同的超参数来实例化解码器。
python
decoder = d2l.Seq2SeqDecoder(vocab_size=10, embed_size=8,
num_hiddens=16, num_layers=2)
decoder.eval()
state = decoder.init_state(encoder(X))
output, state = decoder(X, state)
print(output.shape) # 输出:torch.Size([4, 7, 10])
print(state.shape) # 输出:torch.Size([2, 4, 16])
4. 损失函数:屏蔽无效位置
4.1 问题背景
句子长度不同需要填充(如用<pad>
补齐)。计算损失时应忽略填充部分。
示例:
- 真实标签:
[Ils, regardent, <eos>, <pad>]
- 预测标签:
[Ils, mangent, <eos>, <pad>]
只需计算前3个位置的损失。
4.2 实现方法
python
def sequence_mask(X, valid_len, value=0):
"""在序列中屏蔽不相关的项"""
maxlen = X.size(1)
mask = torch.arange(maxlen, dtype=torch.float32, device=X.device)[None, :] < valid_len[:, None]
X[~mask] = value
return X
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
"""带遮蔽的softmax交叉熵损失函数"""
# pred的形状:(batch_size,num_steps,vocab_size)
# label的形状:(batch_size,num_steps)
# valid_len的形状:(batch_size,)
def forward(self, pred, label, valid_len):
weights = torch.ones_like(label)
weights = sequence_mask(weights, valid_len)
self.reduction = 'none'
unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
pred.permute(0, 2, 1), label)
weighted_loss = (unweighted_loss * weights).mean(dim=1)
return weighted_loss
torch.arange(maxlen, dtype=torch.float32, device=X.device)[None, :]
None
在索引操作中等价于numpy.newaxis
,用于在指定维度上增加一个新的维度(即扩展张量的维度)。
python
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(d2l.sequence_mask(X, torch.tensor([1, 2])))
loss = d2l.MaskedSoftmaxCELoss()
print(loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),
torch.tensor([4, 2, 0])))
输出:
text
tensor([[1, 0, 0],
[4, 5, 0]])
tensor([2.3026, 1.1513, 0.0000])
5. 训练技巧:强制教学与梯度裁剪
5.1 强制教学(Teacher Forcing)
训练时,解码器的输入使用真实标签而非预测结果,避免错误累积。
示例:
- 输入:
<bos>, Ils, regardent
- 而非:
<bos>, 预测词1, 预测词2
python
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
"""训练序列到序列模型"""
def xavier_init_weights(m):
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
elif type(m) == nn.GRU:
for param in m._flat_weights_names:
if 'weight' in param:
nn.init.xavier_uniform_(m._parameters[param])
net.apply(xavier_init_weights)
net.to(device)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
loss = MaskedSoftmaxCELoss()
net.train()
animator = Animator(xlabel='epoch', ylabel='loss',
xlim=[10, num_epochs], figsize=(6.18, 3.82))
for epoch in range(num_epochs):
timer = Timer()
metric = Accumulator(2) # 训练损失总和,词元数量
for batch in data_iter:
optimizer.zero_grad()
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0], device=device).reshape(-1, 1)
dec_input = torch.cat([bos, Y[:, :-1]], 1) # 强制教学
Y_hat, _ = net(X, dec_input, X_valid_len)
l = loss(Y_hat, Y, Y_valid_len)
l.sum().backward() # 损失函数的标量进行"反向传播"
grad_clipping(net, 1)
num_tokens = Y_valid_len.sum()
optimizer.step()
with torch.no_grad():
metric.add(l.sum(), num_tokens)
if (epoch + 1) % 10 == 0:
animator.add(epoch + 1, (metric[0] / metric[1],))
print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
f'tokens/sec on {str(device)}')
5.2 梯度裁剪
防止梯度爆炸,设定阈值裁剪梯度。
python
grad_clipping(net, 1)
grad_clipping
定义参见 循环神经网络5-从零开始实现循环神经网络
python
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = d2l.Seq2SeqEncoder(vocab_size=len(src_vocab), embed_size=embed_size,
num_hiddens=num_hiddens, num_layers=num_layers)
decoder = d2l.Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers)
net = d2l.EncoderDecoder(encoder, decoder)
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
6. 预测:逐步生成输出
预测时解码器每一步的输入是上一步的预测结果:
python
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
device, save_attention_weights=False):
"""序列到序列模型的预测"""
# 在预测时将net设置为评估模式
net.eval()
src_tokens = src_vocab[src_sentence.lower().split(' ')] + [src_vocab['<eos>']]
enc_valid_len = torch.tensor([len(src_tokens)], device=device)
src_tokens = truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
# 添加批量轴
enc_X = torch.unsqueeze(
torch.tensor(src_tokens, dtype=torch.long, device=device),
dim=0
)
enc_outputs = net.encoder(enc_X, enc_valid_len)
dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
# 添加批量轴
dec_X = torch.unsqueeze(
torch.tensor([tgt_vocab['<bos>']], dtype=torch.long, device=device),
dim=0
)
output_seq, attention_weight_seq = [], []
for _ in range(num_steps):
Y, dec_state = net.decoder(dec_X, dec_state)
# 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
dec_X = Y.argmax(dim=2)
pred = dec_X.squeeze(dim=0).type(torch.int32).item()
# 保存注意力权重(稍后讨论)
if save_attention_weights:
attention_weight_seq.append(net.decoder.attention_weights)
# 一旦序列结束词元被预测,输出序列的生成就完成了
if pred == tgt_vocab['<eos>']:
break
output_seq.append(pred)
return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq
7. 评估翻译质量:BLEU分数
BLEU通过比较预测与参考翻译的n-gram相似度打分:
<math xmlns="http://www.w3.org/1998/Math/MathML"> B L E U = exp ( min ( 0 , 1 − l e n l a b e l l e n p r e d ) ∏ n = 1 k p n 1 / 2 n BLEU = \exp\left(\min(0, 1 - \frac{len_{label}}{len_{pred}}\right) \prod_{n=1}^k p_n^{1/2^n} </math>BLEU=exp(min(0,1−lenpredlenlabel)∏n=1kpn1/2n
其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> p n p_n </math>pn是n-gram精确度。
示例:
- 参考翻译:
Ils regardent .
- 预测:
Ils entendent .
- 1-gram精确度:1/2("Ils"正确)
- 2-gram精确度:0/1
- BLEU得分: <math xmlns="http://www.w3.org/1998/Math/MathML"> exp ( 1 − 3 / 2 ) × ( 0. 5 0.5 × 0 0.25 ) = 0 \exp(1 - 3/2) \times (0.5^{0.5} \times 0^{0.25}) = 0 </math>exp(1−3/2)×(0.50.5×00.25)=0
python
def bleu(pred_seq, label_seq, k):
"""计算BLEU"""
pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
len_pred, len_label = len(pred_tokens), len(label_tokens)
score = math.exp(min(0, 1 - len_label / len_pred))
for n in range(1, k + 1):
num_matches, label_subs = 0, collections.defaultdict(int)
for i in range(len_label - n + 1):
label_subs[' '.join(label_tokens[i: i + n])] += 1
for i in range(len_pred - n + 1):
if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
num_matches += 1
label_subs[' '.join(pred_tokens[i: i + n])] -= 1
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
return score
python
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
translation, attention_weight_seq = d2l.predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, device)
print(f'{eng} => {translation}, bleu {d2l.bleu(translation, fra, k=2):.3f}')
8. 小结
- 编码器-解码器结构 是处理序列转换任务的基础框架。
- 遮蔽损失 和 强制教学 是训练时的关键技巧。
- BLEU分数 客观评估生成质量。
- 实际应用时,可以使用注意力机制(后续章节)进一步提升性能。
通过本篇,你已经掌握了机器翻译等seq2seq任务的核心原理。试着用PyTorch实现一个简单的翻译模型吧!