文章目录
摘要
本周学习了现代循环神经网络中的深层循环神经网络、双向循环神经网络、机器翻译数据集处理、编码器-解码器架构、序列到序列学习(seq2seq)以及束搜索等内容,并开始接触注意力机制的基本概念。主要问题包括:在实现双向RNN时出现预测结果异常(输出全为重复词元),发现是因模型在推理时无法获取未来信息;在BLEU指标理解和代码实现中对n-gram匹配计算和权重分配存在困惑,通过手动计算示例序列和调试代码中的label_subs计数器变化解决了这个问题。此外,还需要加深注意力机制中query与key-value关系的理解。
第九章 现代循环神经网络
9.3 深层循环神经网络

- 通过添加多个隐藏层的方式来实现加深网络(和 MLP 没有本质区别),每个隐藏状态都连续地传递到当前层的下一个时间步和下一层的当前时间步
- 加深循环神经网络可以获得更多非线性

- 类似于多层感知机,隐藏层数目和隐藏单元数目都是超参数(它们是可以进行调整的)
- 使用门控循环单元或长短期记忆网络的隐状态替代上图中深度循环神经网络中的隐状态计算,就能够很容易地得到深度门控循环神经网络或长短期记忆神经网络
简洁实现
和前面的代码主要区别是:通过num_layers的值来设定隐藏层数,而不是单一层
import torch
from torch import nn
from d2l import torch as d2l
#加载时光机数据集
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
device = d2l.try_gpu()
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
训练预测
num_epochs, lr = 500, 2
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

小结
- GRU、RNN、LSTM 在结构上都是相同的,只是隐状态 H 的计算方式有区别,所以它们加深神经网络的原理都是相同的
- 在深度循环神经网络中,隐状态的信息被传递到当前层的下一时间步和下一层的当前时间步
- 存在许多不同风格的深度循环神经网络,如长短期记忆网络、门控循环单元或经典循环神经网络
- 深度循环神经网络需要大量的调参(如学习率和修剪)来确保合适的收敛,模型的初始化也需要谨慎
9.4 双向循环神经网络


- 这里任然是一个RNN网络,不过有两个方向相反的隐藏层

- 双向RNN不能用于推理(预测未来),因为需要未来的输入。
- 但是可以用来做语句特征提取、填空。
错误应用
这里只是把LSTM框架中的bidirectional参数该成True,就可以实现双向循环神经网络
import torch
from torch import nn
from d2l import torch as d2l
batch_size, num_steps, device = 32, 35, d2l.try_gpu()
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers, bidirectional=True)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
num_epochs, lr = 500, 1
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

- 可以看到这里的困惑度很低,按数值来说训练效果应该很好,但是训练结果全是re,预测不靠谱
- 这里没有未来的信息
- 双向循环神经网络适合进行填空,给定上下文的观测估计
- 双向循环神经网络不适合进行预测
小结
- 在循环神经网络中,每一个时间步的隐状态由当前时间步的前后数据同时决定
- 双向循环神经网络通过反向更新的隐藏层来利用方向时间信息
- 通过用来对序列抽取特征、填空,而不是预测未来
- 由于梯度链更长,训练成本很高
9.5 机器翻译与数据集
- 机器翻译是语言模型最成功的基准测试
- 也是输入序列转换成输出序列的序列转换模型的核心问题
下载和预处理数据集
数据集是一个双语句子对组成的“英语-法语”数据集,由英语文本序列和翻译后的法语文本序列组成。
import os
import torch
from d2l import torch as d2l
d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip',
'94646ad1522d915e7b0f9296181140edcf86a4f5')
def read_data_nmt():
"""载入“英语-法语”数据集。"""
data_dir = d2l.download_extract('fra-eng')
with open(os.path.join(data_dir, 'fra.txt'), 'r') as f:
return f.read()
raw_text = read_data_nmt()
print(raw_text[:75])

预处理
用空格代替不间断空格、用小写字母替换大写字母、在单词和标点符号之间插入空格
def preprocess_nmt(text):
"""预处理“英语-法语”数据集。"""
def no_space(char, prev_char):
return char in set(',.!?') and prev_char != ' '
text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
out = [
' ' + char if i > 0 and no_space(char, text[i - 1]) else char
for i, char in enumerate(text)]
return ''.join(out)
text = preprocess_nmt(raw_text)
print(text[:80])

词元化
对前num_examples个 文本序列进行词元,其中每个词元要么是一个词,要么是一个标点符号
def tokenize_nmt(text, num_examples=None):
"""词元化“英语-法语”数据数据集。"""
source, target = [], []
for i, line in enumerate(text.split('\n')):
if num_examples and i > num_examples:
break
parts = line.split('\t')
if len(parts) == 2:
source.append(parts[0].split(' '))
target.append(parts[1].split(' '))
return source, target
source, target = tokenize_nmt(text)
source[:6], target[:6]
绘制每个文本序列所包含的标记数量的直方图
d2l.set_figsize()
_, _, patches = d2l.plt.hist([[len(l)
for l in source], [len(l) for l in target]],
label=['source', 'target'])
for patch in patches[1].patches:
patch.set_hatch('/')
d2l.plt.legend(loc='upper right');

- 大部分文本序列的词元数少于20个
词表
这里将出现次数少于2次的低频词元视为相同的未知(’ < unk > ‘)词元,在小批量时用于序列填充到相同长度的填充词元(’ < pad > '),以及序列的开始词元( ’ < bos > ‘)和结束词元(’ < eos > ')
src_vocab = d2l.Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab)

序列样本都有一个固定的长度截断或填充文本序列
def truncate_pad(line, num_steps, padding_token):
"""截断或填充文本序列。"""
if len(line) > num_steps:
return line[:num_steps]
return line + [padding_token] * (num_steps - len(line))
truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])
转换成小批量数据集用于训练
这里将’ < eos > '添加到了序列末尾,用于表示序列结束。
def build_array_nmt(lines, vocab, num_steps):
"""将机器翻译的文本序列转换成小批量。"""
lines = [vocab[l] for l in lines]
lines = [l + [vocab['<eos>']] for l in lines]
array = torch.tensor([
truncate_pad(l, num_steps, vocab['<pad>']) for l in lines])
valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)
return array, valid_len
训练模型
定义load_data_nmt 函数来返回数据迭代器,以及源语言和目标语言的两种词表
def load_data_nmt(batch_size, num_steps, num_examples=600):
"""返回翻译数据集的迭代器和词汇表。"""
text = preprocess_nmt(read_data_nmt())
source, target = tokenize_nmt(text, num_examples)
src_vocab = d2l.Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
tgt_vocab = d2l.Vocab(target, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
data_iter = d2l.load_array(data_arrays, batch_size)
return data_iter, src_vocab, tgt_vocab
读出“英语-法语”数据集中的第一个小批量数据
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
print('X:', X.type(torch.int32))
print('valid lengths for X:', X_valid_len)
print('Y:', Y.type(torch.int32))
print('valid lengths for Y:', Y_valid_len)
break

小结
- 机器翻译是指将文本序列从一种语言自动翻译成另一个语言
- 使用单词级词元化时的词表大小,将明显大于使用字符级词元化时的词表大小。为了缓解这一问题,我们可以将低频词元视为相同的未知词元
- 通过截断和填充文本序列,可以保证所有的文本序列都具有相同长度,以便以小批量的方式加载
9.6 编码器-解码器架构
回顾CNN

- 在 CNN 中,输入一张图片,经过多层的卷积层,最后到输出层判别图片中的物体的类别
- CNN中使用卷积层来进行特征提取,也可以说是把原始内容编码成中间形式
- 使用softmax回归来进行预测,把前面编码后的内容解码
回顾RNN

- RNN中通过Embedding词嵌入层来将每个文本、单词转换为词向量(将离散词汇转为连续向量表示),然后通过LSTM层(长短期记忆网络),逐步处理并捕获序列信息。这部分把文本编码成了向量
- 最后通过Dense全连接层进行特征变换和处理来输出结果,把向量解码输出出去
编码器-解码器架构

- 编码器:接收一个长度可变的序列作为输入,并将其转换为具有固定形状的编码状态 (
处理输入) - 解码器:将固定形状的编码状态映射到长度可变的序列 (处理输出)
代码实现
编码器
from torch import nn
class Encoder(nn.Module):
"""编码器-解码器结构的基本编码器接口。"""
def __init__(self, **kwargs):
super(Encoder, self).__init__(**kwargs)
def forward(self, X, *args):
raise NotImplementedError
解码器
class Decoder(nn.Module):
"""编码器-解码器结构的基本解码器接口。"""
def __init__(self, **kwargs):
super(Decoder, self).__init__(**kwargs)
def init_state(self, enc_outputs, *args):
raise NotImplementedError
def forward(self, X, state):
raise NotImplementedError
合并编码器和解码器
class EncoderDecoder(nn.Module):
"""编码器-解码器结构的基类。"""
def __init__(self, encoder, decoder, **kwargs):
super(EncoderDecoder, self).__init__(**kwargs)
self.encoder = encoder
self.decoder = decoder
def forward(self, enc_X, dec_X, *args):
enc_outputs = self.encoder(enc_X, *args)
dec_state = self.decoder.init_state(enc_outputs, *args)
return self.decoder(dec_X, dec_state)
小结
- 编码器-解码器可以将长度可变的序列作为输入和输出,因此适用于机器翻译等序列转换问题
- 编码器将长度可变的序列作为输入,并将其转换为具有固定形状的编码状态
- 解码器将具有固定形状的编码状态映射为长度可变的序列
9.7 序列到序列学习(seq2seq)
序列到序列(sequence to sequence)由双向RNN组成的encoder-decoder神经网络结构,核心功能为处理输入输出序列长度不同的映射任务。

- 双向RNN用于encoder
- ’ < bos> '表示序列开始, ’ < eos > '表示序列结束
- 使用编码器最后的隐状态来初始化编码器的隐状态
- RNN做编码器可以输入任意长度的序列,最后返回最后时刻的隐藏状态,使用RNN编码器最终的隐状态来初始化解码器的隐状态,解码器一直输出,直到看到句子的结束标志为止
- seq2seq也可以做可变长度到可变长度的句子之间的翻译
编码器细节

- 编码器是没有输出的RNN
- 编码器最后时间步的隐藏状态作为解码器的初始隐藏状态
训练

- 训练时将特定的开始词元(“”)和原始的输出序列(不包括序列结束词元"")拼接在一起作为解码器的输入,这也称为强制教学(teacher forcing,因为原始的输出序列(词元的标签)被送入了解码器)
- 也可以将来自上一个时间步的预测得到的词元作为解码器的当前输入
- 训练和推理是不同的: 编码器是相同的,但是在训练的时候,解码器是知道目标句子的,它知道真正的翻译是什么样子的,所以解码器的输入(每个RNN 时刻的输出)所使用的实际上是真正的目标句子的输入,所以就算是在训练的时候翻译错了,下一个时刻的输入还是正确的输入,也就是说,在训练的时候所使用的是真正的目标句子来帮助训练,这样就降低了预测长句子的难度
预测
推理的时候没有真正的目标句子作为参考,每一个时刻只能将上一个时刻的输出作为这一时刻的输入,以此来不断地进行预测,即每个解码器当前时间步的输入都来自前一个时间步的预测词元,因此能够一个词元接一个词元地预测输出序列·和训练类似,序列开始词元(“< bos >”)在初始时间步就被输入到了解码器中·当输出序列的预测遇到序列结束词元(“< eos >”)时,预测就结束了。

但是要注意的是这里:编码器最终的隐状态在每⼀个时间步都作为解码器的输⼊序列的⼀部分
评估指标
我们可以通过与真实的标签序列进行比较来评估预测序列。虽然(Papineni et al., 2002)提出的BLEU最先是用于评估机器翻译的结果,但现在它已经被广泛用于测量许多应用的输出序列的质量。其中BLEU的值越大越好,最大值为1,越小的话效果越差,原则上说,对于预测序列中的任意n元语法(n-grams),BLEU的评估都是这个n元语法是否出现在标签序列中。
BLEU定义:

len_label: 标签序列中的词元数len_pred: 预测序列中的词元数k: 用于匹配的最长n元语法p_n: n元语法精度,它是两个数量的比值:第一个是预测序列与标签序列中匹配的n元语法的数量,第⼆个是预测序列中n元语法的数量的比率。
例如:给定标签序列A、B、C、D、E、F和预测序列A、B、B、C、D。我们有 p 1 = 4 5 p_{1}=\frac{4}{5} p1=54、 p 2 = 3 4 p_{2}=\frac{3}{4} p2=43、 p 3 = 1 3 p_{3}=\frac{1}{3} p3=31和 p 4 = 0 p_{4}= 0 p4=0。
- p 1 p_1 p1 (1-gram精度):考虑预测序列中所有的1-gram,有5个1-gram,即A、B、B、C、D,所以分母为5。再考虑这5个1-gram,是不是每一个1-gram都在标签序列中出现过(预测序列中除了第二个B并没有出现,因为在标签序列中B只出现了一次,其它都出现了),所以 p 1 = 4 5 p_1=\frac{4}{5} p1=54。
- p 2 p_2 p2 (2-gram精度)考虑预测序列中所有的2-gram,有4个2-gram,即AB、BB、BC、CD,所以分母为4。再考虑这4个2-gram,是不是每一个2-gram都在标签序列中出现过(预测序列中除了第二个BB并没有出现,因为在标签序列中B只出现了一次,其它都出现了),所以 p 2 = 3 4 p_2=\frac{3}{4} p2=43。
- p 3 p_3 p3 (3-gram)精度考虑预测序列中所有的3-gram,有3个3-gram,即ABB、BBC、BCD,所以分母为3。再考虑这3个3-gram,是不是每一个3-gram都在标签序列中出现过(预测序列中只有BCD在标签序列中出现了一次,其它都没有出现了),所以 p 3 = 1 3 p_3=\frac{1}{3} p3=31。
- p 4 p_4 p4 (4-gram精度):考虑预测序列中所有的4-gram,有2个4-gram,即ABBC、BBCD,所以分母为2。再考虑这2个4-gram,是不是每一个4-gram都在标签序列中出现过(预测序列中所有的4-gram都没有在标签序列中出现过),所以 p 4 = 0 2 = 0 p_4=\frac{0}{2}=0 p4=20=0。
根据BLEU的定义,当预测序列与标签序列完全相同时,BLEU为1。此外,由于n元语法越长则匹配难度越大,所以BLEU为更长的n元语法的精确度分配更大的权重。
代码实现
编码器
import collections
import math
import torch
from torch import nn
from d2l import torch as d2l
class Seq2SeqEncoder(d2l.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[0]的形状为(num_layers,batch_sie,num_hiddens)
return output, state
- 使用embedding layer嵌入层来获得输入序列中每个词元的特征向量
- 嵌入层的权重是一个矩阵,其行数等于输入词表的大小(vocab_size),其列数等于特征向量的维度(embed_size)
- 对于任意输入词元的索引i,嵌入层获取权重矩阵的第i行(从0开始)以返回特征向量
- num_hiddens定义了 GRU 隐藏状态的维度大小,这里使用了多层门控循环单元来实现编码器
实现上述编码器
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
num_layers=2)
# 设置为评估模式(关闭dropout等训练专用层)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
# 前向传播:获取编码器输出和隐藏状态
output, state = encoder(X)
output.shape

- 这是一个二层门控循环单元编码器,有16个隐藏单元
- x批量大小为4,时间步为7
- 其形状为(时间步数,批量大小,隐藏单元数)

- 这里使用的是门控循环单元,最后一个时间步的多层隐藏状态的形状是(隐藏层数,批量大小,隐藏单元数)
- 如果使用长短期记忆网络,state中还将包含记忆元信息
解码器
class Seq2SeqDecoder(d2l.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)
# GRU层:输入为嵌入向量+编码器上下文,输出隐藏状态
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):
# 词嵌入并调整维度:(batch_size, num_steps) -> (num_steps, batch_size, embed_size)
X = self.embedding(X).permute(1, 0, 2)
# 复制编码器最终隐藏状态作为上下文向量,与解码器输入序列长度对齐
context = state[-1].repeat(X.shape[0], 1, 1)
# 拼接解码器输入和上下文信息
X_and_context = torch.cat((X, context), 2)
# GRU前向传播
output, state = self.rnn(X_and_context, state)
# 线性变换并调整维度:(num_steps, batch_size, vocab_size)
output = self.dense(output).permute(1, 0, 2)
return output, state
实例化解码器
decoder = 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)
output.shape, state.shape

- 解码器的输出形状变为(批量大小,时间步数,词表大小),其中张量的最后一个维度存储预测的词元分布
decoder.eval()设置模型为评估模式(evaluation mode),主要作用是:
- 关闭Dropout:在评估时不需要随机丢弃神经元,确保结果的一致性
- 关闭Batch Normalization的统计更新:使用训练阶段学到的运行统计量,而不是基于当前批次的统计量
- 影响某些特定层的行为:如某些层在训练和评估时有不同表现
损失函数
通过零值化屏蔽不相关的项,以便于后面任何不相关的计算都是与零的乘积,结果都等于零
def sequence_mask(X, valid_len, value=0):
"""在序列中屏蔽不相关的项。"""
# 获取序列的最大长度
maxlen = X.size(1)
# 创建掩码:比较每个位置索引与有效长度
# mask形状: (batch_size, maxlen),True表示有效位置
mask = torch.arange((maxlen), dtype=torch.float32,
device=X.device)[None, :] < valid_len[:, None]
# 将无效位置(mask为False)的值设置为指定值(默认为0)
X[~mask] = value
return X
# 示例输入:2个样本,每个样本长度为3
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
# 应用序列掩码:第一个样本保留前1个元素,第二个样本保留前2个元素
sequence_mask(X, torch.tensor([1, 2]))

- ~mask是 按位取反操作(bitwise NOT),它将布尔掩码中的 True和 False值进行反转。
- 将输入的序列只保留有效长度
我们还可以使用此函数屏蔽最后几个轴上的所有项,如果需要也可以用非零值来替换这些项
X = torch.ones(2, 3, 4)
sequence_mask(X, torch.tensor([1, 2]), value=-1)

通过扩展softmax交叉熵损失函数来遮蔽不相关的预测
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
"""带遮蔽的softmax交叉熵损失函数"""
def forward(self, pred, label, valid_len):
weights = torch.ones_like(label)
weights = sequence_mask(weights, valid_len)
## 设置损失 reduction='none',不进行自动求和或平均
self.reduction = 'none'
# 计算未加权的交叉熵损失(需要调整pred的维度)
unweighted_loss = super(MaskedSoftmaxCELoss,
self).forward(pred.permute(0, 2, 1), label)
#对损失进行加权:有效位置保留原损失,填充位置损失为0,然后按序列维度平均
weighted_loss = (unweighted_loss * weights).mean(dim=1)
return weighted_loss
- 先将所有预测词元的掩码都设置为1,一旦给定有效长度,与填充词元对应的掩码将被设置为 0。最后将所有词元的损失乘以掩码,来过滤掉损失中填充词元产生的不相关预测。
- pred的形状为(batch_size,num_steps,vocab_size)
- label的形状为(batch_size,num_steps)
- valid_len的形状为(batch_size,)
代码健全性检查
loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),
torch.tensor([4, 2, 0]))

- 这里创建了三个相同的序列,并指定了有效长度为4,2,0
- 结果第一个序列的损失是第二个序列的两倍,第三个序列的损失为零
训练
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
"""训练序列到序列模型。"""
def xavier_init_weights(m): #使用xavier初始化权重
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
if 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)
# 定义Adam优化器和掩码损失函数
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
loss = MaskedSoftmaxCELoss()
#设置为训练模式
net.train()
#初始化动画显示器和计时器
animator = d2l.Animator(xlabel='epoch', ylabel='loss',
xlim=[10, num_epochs])
# 训练循环
for epoch in range(num_epochs):
timer = d2l.Timer()
metric = d2l.Accumulator(2) # 用于累积损失和token数量
for batch in data_iter:
# 获取批次数据并移动到设备
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
# 创建解码器输入:在目标序列前添加<bos>起始符,并去掉最后一个token
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()
d2l.grad_clipping(net, 1)
# 更新参数并累积指标
num_tokens = Y_valid_len.sum()
optimizer.step()
with torch.no_grad():
metric.add(l.sum(), num_tokens)
# 每10个epoch显示损失
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)}')
创建和训练一个循环神经网络编码器-解码器模型用于序列到序列学习
# 设置模型超参数
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 = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,
dropout)
# 创建解码器实例
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
dropout)
# 组合编码器和解码器构成完整的seq2seq模型
net = d2l.EncoderDecoder(encoder, decoder)
# 开始训练模型
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

预测
为了采用一个接着一个词元的方式预测输出序列,每个解码器当前时间步的输入都将来自前一个时间步的预测词元
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
device, save_attention_weights=False):
"""序列到序列模型的预测"""
#设置为评估模型
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 = d2l.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)
# 选择概率最高的token作为下一个输入(贪婪搜索)
dec_X = Y.argmax(dim=2)
# 获取预测的token索引
pred = dec_X.squeeze(dim=0).type(torch.int32).item()
#保存注意力权重
if save_attention_weights:
attention_weight_seq.append(net.decoder.attention_weights)
#一旦eos结束词元被预测,输出序列就完成了
if pred == tgt_vocab['<eos>']:
break
output_seq.append(pred)
# 将token索引转换回文本
return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq
评估
我们可以通过与真实标签序列进行比较来评估预测序列,BLEU被广泛用于度量许多应用的输出序列的质量
def bleu(pred_seq, label_seq, k):
"""计算 BLEU"""
# 将预测序列和标签序列分割成token列表
pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
len_pred, len_label = len(pred_tokens), len(label_tokens)
# 计算长度惩罚因子( brevity penalty)
score = math.exp(min(0, 1 - len_label / len_pred))
# 计算1-gram到k-gram的精度
for n in range(1, k + 1):
num_matches, label_subs = 0, collections.defaultdict(int)
# 统计标签序列中所有n-gram的出现次数
for i in range(len_label - n + 1):
label_subs[''.join(label_tokens[i:i + n])] += 1
# 统计预测序列中与标签匹配的n-gram数量
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
# 将n-gram精度乘以权重后累乘到总分数中
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
return score
将几个英语句子翻译成法语,计算BLEU的结果
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 = predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, device)
print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')

- 第一行< unk >表示未知词元,预测出了一个根本不存在的词——,这导致所有n-gram的匹配数都为0。
- 第二行预测的结果和标准答案的fras完全相同,结果BLEU就等于1
小结
- Seq2seq从一个句子生成另一个句子
- 编码器和解码器都是RNN
- 在编码器-解码器的训练中,强制教学方法将原始输出序列(而非预测结果)输入解码器
- BLEU是一种常用的评估方法,它通过测量预测序列和标签序列之间的n元语法的匹配度来衡量生成序列的好坏
9.8 束搜索 Beam Search
贪心搜索greedy search
在 seq2seq 中使用了贪心搜索来预测序列(对于输出序列的每一时间步,将当前时刻具有最高条件概率的词元输出,作为下一个序列,一旦输出序列包含了“< eos >”或者达到最大长度,则输出完成)
但是贪心搜索的结果可能不是最优的,局部最优推不出全局最优,举例如下
- 上图中将两次选择各部分的概率分别进行相乘,会发现虽然右边没有选择最优,但是最终生成 sequence 的概率比贪心搜索所得到的概率更高(因为当前步所选择的最优的词从整个句子来看的话不一定是最优的,所以
贪心搜索可能是效率最高的,但是不一定是最优的)
穷举搜索 Exhaustive Search
- 穷举列举出所有可能的输出序列及其条件概率,最后输出条件概率最大的
如果输出字典大小为 n ,序列最长为 T ,那么需要考察 n ^ T 个序列
- n = 10000 , T = 10 : n^T = 10^4
- 计算上不可行,现有的计算机几乎不可能计算它
- 贪心搜索是最快的,但不是最好的;穷举搜索是最好的,但是计算量巨大,计算起来比较困难
束搜索 Beam Search
- 如果精确度最重要,显然选择穷举搜索;如果计算成本最重要,显然选择贪心搜索。束搜索介于二者之间。
- 在每个时刻,保存最好的 k 个候选序列,对每个候选新加一项( n 种可能),在 kn 个选项中选出最好的 k 个
举例:

- 束搜索是贪心搜索的改进版本,它和贪心算法的区别在于,贪心算法每次都选择最好的一个词元来组成序列,而束搜索每次选择 k 个作为候选
- k 是超参数,称为束宽(beam size),在时间步 1 ,算法所选择的是具有条件概率最高的 k 个词元,这 k 个词元将分别是 k 个候选输出序列的第一个词元;在随后的每个时间步,基于上一时间步的 k 个候选输出序列,将继续挑出具有最高条件概率的 k 个候选输出序列
- k=1时,等价于贪心搜索
小结
- 序列搜索策略包括贪心搜索、穷举搜索和束搜索
- 贪心搜索所选取序列的计算量最小,但精确度相对较低
- 穷举搜索所选取的精确度较高,但计算量最大
- 束搜索可以灵活选择束宽
第十章 注意力机制
10.1 注意力机制
心理学

从心理学来讲,动物需要在复杂环境下有效关注值得关注的点。
在心理学上有框架:
- 人会主动、有意识的去选择注意点。注意点可以理解为值得关注的点、引人注意的点。
以上图举例说明:
上图中,摆在眼前的有五样东西,人们往往最先注意到的是颜色鲜艳的红色杯子。
当拿起红色杯子喝了里面的咖啡后可能会想要去学习、读书。
这里理解为,人们会去有意识的关注想要的东西。
其中,红色杯子是“不随意线索”(可理解为除了想要的物品之外的其它物品们,像红色杯子和报纸都是不随意线索),想读书是“随意线索”(可理解为想要的那个物品)。
注意力机制

卷积、全连接、池化层都只考虑“不随意线索”。如何理解?
-
拿池化层举例说明:最大池化层只考虑窗口上的最大值,只要是最大值就行。
-
首先“随意线索”就是query,就是你想查询的物品。结合之前内容,query就是想读书。
-
其它的物品就是key-value 对,即“不随意线索-值”对。结合之前内容,key就是红色杯子、报纸。value可以一样也可以不一样。以key是红色杯子举例,value就可以理解为杯子的价值,也就是有什么用。
注意力机制也可以叫注意力池化(attention pooling),会根据query有偏向性的选择某些key-value对。
注意力池化(attention pooling)和之前的池化(pooling)不一样的地方是:attention显示的加入了query,即“随意线索”,然后根据query去找到感兴趣的key-value对。
具体是怎么加入query来有偏向的选择键值对?
1、非参注意力池化层
给定数据key-value pair: ( x i , y i ) , i = 1 , 2 , . . . , n (x_{i}, y_{i}), i=1, 2, ..., n (xi,yi),i=1,2,...,n
最简单的池化方案是平均池化: f ( x ) = 1 n ∑ i y i f(x)=\frac{1}{n}\sum_{i}y_{i} f(x)=n1i∑yi
更好的池化方案是Nadaraya-Watson核回归:
f
(
x
)
=
∑
i
=
1
n
K
(
x
−
x
i
)
∑
j
=
1
n
K
(
x
−
x
j
)
y
i
f(x)=\sum_{i=1}^{n}\frac{K(x-x_{i})}{\sum_{j=1}^{n}K(x-x_j)}y_{i}
f(x)=i=1∑n∑j=1nK(x−xj)K(x−xi)yi
- 其中,x是query;x_{i}是key;
- K是衡量x和x_{i}之间距离的一个函数(kernel),比如这个值越大距离就越近,越小就距离越远;
- 公式中的分式,就是概率,每一项是一个相对重要性。对这项加权对y_{i}求和,意思是将和x相近那些的x_{i}和y_{i}选出来。可以这么理解,找出和query相近的key-value pair,别的就不管了。
非参的意思是不需要学任何东西。拿一个新值去求其近似值,有点像KNN。
2、Nadaraya-Watson核回归中K怎么选?

- 使用高斯核 K ( u ) = 1 2 π e x p ( − u 2 2 ) K(u)=\frac{1}{\sqrt{2\pi}}exp(-\frac{u^2}{2}) K(u)=2π1exp(−2u2),将其代入之前的f(x)。得到的式子中softmax就是在做权重。
等下会看到这个高斯核和注意力机制非常像。
3、参数化的注意力机制

参数化就是加入一个可以学的w。每次迭代可以学习。这里的w是一元的(因为是标量不是向量)。
小结
- 注意力机制与全连接层或汇聚层的区别源于增加的自主性提示(query)
- 注意力机制通过注意力汇聚使选择偏向于值(感官输入),其中包含查询(query自主性提示)和键(key非自主性提示)
- 可视化查询和键之间的注意力权重是可行的
总结
本周学习了现代循环神经网络的核心内容,包括深层RNN、双向RNN的结构与特性,机器翻译数据集的预处理与加载方法,编码器-解码器架构的实现原理,序列到序列学习(seq2seq)模型的训练、预测与评估(特别是BLEU指标),以及束搜索(Beam Search)这一重要推理策略。同时,初步接触了注意力机制的基本概念,理解了其基于query-key-value的池化思想和与心理学的关联。下周计划继续深入学习注意力机制的具体实现和数学原理,并开始学习Transformer这一基于自注意力机制的强大模型架构。
1148

被折叠的 条评论
为什么被折叠?



