跟着李沐老师学习深度学习(十三)
现代循环神经网络
循环神经网络中梯度异常在实践中的意义引发了一些问题:
- 早期观测值具有显著影响 :早期数据点对后续所有数据点的预测至关重要,在分析序列时必须特别关注这些初始信息的作用机制。
- 可能包含冗余元素 :在处理网页内容时可能会出现一些与当前任务无关的冗余元素,在模型设计中需要确保这些多余成分不会干扰到系统的正常运行。
- 系统内部状态表示可能出现混乱或断裂 :在处理复杂序列时系统内部的状态表示可能出现混乱或断裂特别是在涉及到章节转换或者其他断层的情况下需要引入专门机制来维持系统的稳定性。
解决方案
- 长短期记忆(LSTM) :该方法最初是由学术界提出的一种解决方案,在处理序列数据时表现出色。
- 门控循环单元(GRU) :作为LSTM的一种简化设计,在保持性能的同时显著提升了计算效率。
门控循环单元(GRU)
- 聚焦于一个序列:
- 并非所有观察值都具有同等的重要性
- 仅旨在记住与当前输入相关的那些观察:
- 负责捕获当前输入的重要性的机制(更新门 update gate):通过将关键数据加以保护来强化其影响
- 负责抑制不相关信息积累的机制(重置门 reset gate):有效地排除了不再具有相关性的信息
更新门和重置门

其中:

通过重置门和更新门调节隐层单元的状态转移机制 。其中 ,重置门决定了前时态信息在当前时态中的保留比例 ,而更新门则决定了新时态中包含前时态信息的比例 。这些机制均基于sigmoid函数对输入进行归一化处理,并以加权平均的方式结合以生成当前状态
候选隐状态(备胎)
将重置门 R_t 与 常规隐状态更新机制相结合,并生成时间步 t 的候选隐状态(candidate hidden state):


当重置门单元趋近于1时,则类似于普通的循环神经网络;而当其趋近于0时,则根据当前输入来决定隐状态的选择。
隐状态(真正的)

当更新门值趋近于1时,则会保持原有记忆;而当更新门值趋近于0时,则会使新隐层的状态趋于候选隐层的状态;这不仅能够有效缓解梯度消失问题;而且还能较好地捕捉到较长时间内的依赖关系。

总之,门控循环单元具有以下两个显著特征:
重置门有助于捕获序列中的短期依赖关系;
更新门有助于捕获序列中的长期依赖关系
总结
代码实现 - 从零实现
# 门循环单元GRU
# 从零开始实现
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)
# 初始化模型参数
def get_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size
def normal(shape):
return torch.randn(size=shape, device=device)*0.01
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))
W_xz, W_hz, b_z = three() # 更新门参数
W_xr, W_hr, b_r = three() # 重置门参数
W_xh, W_hh, b_h = three() # 候选隐状态参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params
# 定义模型
# 定义隐状态的初始化函数
# 返回一个形状为(批量大小,隐藏单元个数)的张量,张量的值全部为零。
def init_gru_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )
# 定义门控循环单元模型
def gru(inputs, state, params):
W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
for X in inputs:
Z = torch.sigmoid((X @ W_xz) + (H @ W_hz) + b_z)
R = torch.sigmoid((X @ W_xr) + (H @ W_hr) + b_r)
H_tilda = torch.tanh((X @ W_xh) + ((R * H) @ W_hh) + b_h)
H = Z * H + (1 - Z) * H_tilda
Y = H @ W_hq + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H,)
# 训练与预测
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_params,
init_gru_state, gru)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
结果:

代码实现 - 简洁实现
# 使用高级API,运行速度很快(使用的是编译好的运算符而不是Python来处理之前阐述的许多细节)
num_inputs = vocab_size
gru_layer = nn.GRU(num_inputs, num_hiddens)
model = d2l.RNNModel(gru_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
结果:

长短期记忆网络(LSTM)
- 忘记门:将值趋向于零
- 输入门:控制不选择丢弃输入数据
- 输出门:决定是否使用隐状态
效果表现上与GRU类似,在处理当前输入时是否需要遗忘过去的状态?或者是否仅考虑前一个时间点的信息?
忘记门、输入门、输出门
公式:


候选记忆单元
公式:

候选记忆元如下图所示:

记忆单元
上一个时刻的记忆单元会作为状态加入(与GRU不同之处)

当遗忘门始终保持在1且输入门始终保持在0时,则记忆单元Ct-1会随着时间被保留并传递至当前时刻。这种设计的引入旨在解决梯度消失的问题,并更有效地捕捉序列中的长期依赖关系。
这样就得到了计算记忆元的流程图:

隐状态
公式:(将隐藏状态约束到(-1,1))

当输出门值趋近于1时,则能够实现将全部记忆信息传递给预测模块;反之,在输出门趋近于0的情况下,则仅保留记忆单元内的所有信息,并无需更新隐态。以下展示了数据流的图形化演示过程

总结
长短期记忆网络有三种类型的门:输入门、遗忘门和输出门。
长短期记忆网络的隐藏层输出由静默单元与潜在状态构成。其对应的静默单元是唯一能够传递到输出层的部分,在长期短时记忆网络中,并没有这样的机制来实现外部信息的直接读取
长短期记忆网络可以缓解梯度消失和梯度爆炸。
代码实现 - 从零实现
# 长短期记忆网络(LSTM)
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)
# [初始化模型参数
# 超参数num_hiddens定义隐藏单元的数量
def get_lstm_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size
def normal(shape):
return torch.randn(size=shape, device=device)*0.01
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))
W_xi, W_hi, b_i = three() # 输入门参数
W_xf, W_hf, b_f = three() # 遗忘门参数
W_xo, W_ho, b_o = three() # 输出门参数
W_xc, W_hc, b_c = three() # 候选记忆元参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params
# 定义模型
# 初始化函数:
# 长短期记忆网络的隐状态需要返回一个额外的记忆元, 单元的值为0,形状为(批量大小,隐藏单元数)
def init_lstm_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device),
torch.zeros((batch_size, num_hiddens), device=device))
# 实际模型的定义
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
C = F * C + I * C_tilda
H = O * torch.tanh(C)
Y = (H @ W_hq) + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H, C)
# 训练和预测
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params,
init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
结果:

代码实现 - 简洁实现
# 简洁实现
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
深度循环神经网络
回顾RNN,主要就是如何得到更多的非线性 —— 多加几个隐藏层。
更深的:

总结
在深度循环神经网络架构中,隐层的状态信息通过流动机制分别流向当前层的时间步与下一层的时间步。从架构设计的角度来看,在深度循环神经网络领域存在多种不同的模型类型:包括长短期记忆网络(LSTM)、门控循环单元(GRU)以及经典的RNN结构等;这些模型类型均可通过主流深度学习框架中的高级模块实现。总体而言,在训练过程中需要进行复杂的参数优化过程(如学习率调节与权重剪裁),以确保模型能够达到预期的收敛效果;此外,在模型初始化阶段也需要采取谨慎策略。
代码实现
# 深度循环神经网络
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)
# 通过num_layers的值来设定隐藏层数
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*1.0, num_epochs, device)
结果:

双向循环神经网络
针对特定的序列来说

根据可获取的信息量大小的不同情况,在某些情况下我们也可以采用不同词汇填充空白位置如'很高兴'对应'happy' '不'对应'not'以及'非常'对应'very'等表达方式
- 依赖于前后情境的丰富性,在某些情况下需要选择不同的词汇
- 到目前为止,在RNN模型中仅考虑历史信息
- 在填写空白时,则有可能引入远期影响因素
双向RNN
- 一个前向RNN隐层
- 一个方向RNN隐层
- 合并两个隐状态得到输出

公式:


总结
- 特性:该系统通过融合序列前后端的信息特征进行当前观测值的综合分析。
- 问题:当面对后续词元预测任务时由于仅在历史数据范围内进行测试导致其预测精度较低;计算效率不高因为正向传播过程需要同时执行正向和反向递归操作而反向传播过程又受制于正向传递的结果从而增加了梯度求解的复杂性。
- 应用场景:该方法目前实际应用较为有限主要应用于填补数据缺损问题(例如,在命名实体识别任务中)以及作为序列编码阶段的重要环节在机器翻译等任务中得到广泛应用但其无法直接用于未来信息的预测。
代码实现
首先不建议使用双向RNN来训练语言模型举例而言与之前不同的是修改代码使其满足条件即将其设置为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)
# 通过设置“bidirective=True”来定义双向LSTM模型
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
# 改为 bidirectional=True就是双向rnn
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)
结果:很差,因为没有未来信息,精度很低,预测信息很不靠谱。

编码器-解码器架构
机器翻译和数据集
机器翻译 是指将文本串从一种语言自动化处理为另一种语言。
在进行词语级别的单元化时所使用的词汇表规模 显然会显著高于字符级别的单元化所使用的词汇表规模 为了解决这一规模差异的问题 我们可以将出现频率较低的单元视为同一未知单元。
通过截取和填充文本序列 可以使所有序列保持一致长度 从而能够以小批量的方式高效加载数据。
代码实现
# 机器翻译与数据集
# 语言模型是自然语言处理的关键, 而机器翻译是语言模型最成功的基准测试
# 机器翻译(machine translation)指的是 将序列从一种语言自动翻译成另一种语言。
import os
import torch
from d2l import torch as d2l
# 下载和预处理数据集
# 下载一个由Tatoeba项目的双语句子对(https://www.manythings.org/anki/) 组成的“英-法”数据集
#
# 英语是源语言(source language), 法语是目标语言(target language)
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',
encoding='utf-8') as f:
return f.read()
raw_text = read_data_nmt()
print(raw_text[:75])
# 预处理
# 用空格代替不间断空格(non-breaking space)
# 使用小写字母替换大写字母
# 在单词和标点符号之间插入空格。
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])
# 词元化
# tokenize_nmt函数对前num_examples个文本序列对进行词元, 其中每个词元要么是一个词,要么是一个标点符号。
# 此函数返回两个词元列表:source和target:
# source[i]是源语言(这里是英语)第 𝑖 个文本序列的词元列表
# target[i]是目标语言(这里是法语)第 𝑖
个文本序列的词元列表。
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]
# 绘制每个文本序列所包含的词元数量的直方图
#@save
def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist):
"""绘制列表长度对的直方图"""
d2l.set_figsize()
_, _, patches = d2l.plt.hist(
[[len(l) for l in xlist], [len(l) for l in ylist]])
d2l.plt.xlabel(xlabel)
d2l.plt.ylabel(ylabel)
for patch in patches[1].patches:
patch.set_hatch('/')
d2l.plt.legend(legend)
show_list_len_pair_hist(['source', 'target'], '# tokens per sequence',
'count', source, target);
# 构建两个词表
# 将出现次数少于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>'])
# 定义一个函数: 将文本序列 [转换成小批量数据集用于训练
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
# 训练模型
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('X的有效长度:', X_valid_len)
print('Y:', Y.type(torch.int32))
print('Y的有效长度:', Y_valid_len)
break
编码器和解码器
回顾:
- 在CNN中,在"特征提取"环节将其视为编码模块,在"分类"环节将其视为解码模块;—> 编码模块:通过卷积运算将输入数据转化为一系列中间状态表达形式(如图像特征);解码模块:基于这些中间状态生成最终的分类结果。
- 在RNN中,在"文本转码"环节将其视为编码过程,在"输出生成"环节将其视为解码过程。具体而言,在进行逐词嵌入后通过序列模型生成序列向量;随后通过递归机制逐步构建最终输出内容。
引出:
- 机器翻译属于序列转换模型的核心任务之一。
- 其处理的对象包括所有长度可变的数据序列。
- 我们可以构建一个包含两个主要模块的整体架构:
- 首先是一个编码器(encoder),它负责接收输入数据并将其转化为统一格式的状态表示。
- 然后是一个解码器(decoder),根据编码状态生成相应的输出数据。
这被称为编码器-解码器(encoder-decoder)架构。

一个模块被分为两部分:编码器处理输入;解码器负责输出
代码实现
# 编码器-解码器架构
# 机器翻译是序列转换模型的一个核心问题, 其输入和输出都是长度可变的序列。
# 为了处理这种类型的输入和输出, 我们可以设计一个包含两个主要组件的架构:
# 第一个组件是一个编码器(encoder): 它接受一个长度可变的序列作为输入, 并将其转换为具有固定形状的编码状态。
# 第二个组件是解码器(decoder): 它将固定形状的编码状态映射到长度可变的序列。
# 这被称为编码器-解码器(encoder-decoder)架构
# 编码器
from torch import nn
class Encoder(nn.Module):
"""编码器-解码器架构的基本编码器接口"""
def __init__(self, **kwarge):
super(Encoder, self).__init__(**kwarge)
def forward(self, X, *args):
raise NotImplementedError
# 解码器
# 用于将编码器的输出(enc_outputs)转换为编码后的状态
class Decoder(nn.Module):
def __init__(self, **kwargs):
super(Decoder, self).__init__(**kwargs)
# 中间状态:编码的输出enc_outputs
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)
序列到序列学习(seq2seq)
在生物中,将DNA序列转为RNA。
机器翻译
- 机器翻译是主要的应用领域
- 对于任何一个来源语言的文本
- 系统会将其转换为目标语言
- 这两个版本都有可能出现不同的长度
- 对于任何一个来源语言的文本
Seq2Seq

编码器是一个RNN,读取输入的句子
* 可以是**双向** 的(经常用在Encoder中)
解码器使用另一个RNN来输入
编码器解码器细节
- 编码器是无输出的RNN * 在最后一个时间段步生成的隐藏状态被用作解码器初始化隐藏状态的基础

训练:
- 训练阶段中解码器会采用目标句子作为输入来源
- 在推理过程中系统会基于最新的输入内容和当前状态来生成输出结果
衡量生成序列的好坏的BLEU
- pn代表预测中所有n-gram的准确度。
- 它被用来衡量n元语法的表现程度,在这里它是指两个数量之比:第一个数量指的是标签序列与预测序列之间匹配成功的n元语法数目;第二个数量则是指出现在预测序列中的全部n元语法数目。
- 这里有两个对比关系:一是标签序列为A B C D E F共六个元素;而预测序列为A B B C D则包含了五个元素;具体来说:
- p₁=4/5(即四个匹配的一元语法)
- p₂=3/4(即三个匹配的二元语法)
- p₃=1/3(即一个匹配的三元语法)
- p₄=0(即没有出现任何四元语法)
- BLEU定义:

前部分min()代表 惩罚过段的预测;后部分有高权重。
总结
- seq2seq模型能够根据输入序列生成相应的输出序列。
- 基于'编码器-解码器'架构的设计理念下, 我们可以构建一个基于循环神经网络的序列到序列学习框架。
- 在构建编码器与解码器的过程中, 可以采用多层循环神经网络结构以提升模型性能。
- 可以利用编码器最终时刻的隐状态初始化解码器的初始隐状态状态, 从而实现信息的有效传递。
- 在'编码器-解码器'框架下的训练过程中, 强制 teacher-forcing 方法将真实输出序列输入到解码器中进行训练。
- BLEU评估指标则通过衡量预测序列与真实标签之间的n-gram语义匹配程度来衡量模型性能。
代码实现
# 序列到序列学习
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)
# 使用嵌入层(embedding layer) 获得输入序列中每个词元的特征向量。
# 嵌入层的权重是一个矩阵
# 其行数等于输入词表的大小(vocab_size), 其列数等于特征向量的维度(embed_size)
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
# 实例化 上述编码器的实现:使用一个两层门控循环单元编码器,其隐藏单元数为 16
# 定一小批量的输入序列X(批量大小为 4 ,时间步为 7 )。
# 完成所有时间步之后,最后一层的隐状态的输出是一个张量,形状:(时间步数,批量大小,隐藏单元数)
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
num_layers=2)
encoder.eval() # dropout就不会生效了
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
# output形状:(时间步数,批量大小,隐藏单元数)
# state形状:(隐藏层的数量,批量大小,隐藏单元的数量)
output.shape,state.shape
# 解码器
# 直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态
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)
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), 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
# 实例化解码器
# 解码器的输出形状变为(批量大小,时间步数,词表大小)
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
# 损失函数
# sequence_mask函数 [通过零值化屏蔽不相关的项]
# 以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。
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
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2]))
# 还可以使用此函数屏蔽最后几个轴上的所有项
X = torch.ones(2, 3, 4)
sequence_mask(X, torch.tensor([1, 2]), value=-1)
# 通过扩展softmax交叉熵损失函数来遮蔽不相关的预测
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
# 创建三个相同序列进行代码健全性检查
loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),
torch.tensor([4, 2, 0]))
# 训练
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)
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)
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) # 训练损失总和,词元数量
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() # 损失函数的标量进行“反向传播”
d2l.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)}')
# 创建和训练一个循环神经网络“编码器-解码器”模型 用于序列到序列的学习
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)
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设置为评估模式
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)
# 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
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
# 预测序列的评估
def bleu(pred_seq, label_seq, k): #@save
"""计算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
# 利用训练好的循环神经网络“编码器-解码器”模型, [将几个英语句子翻译成法语],并计算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}')
束搜索
beam search
贪心搜索(greedy search)
-
对于seq2seq模型而言,我们采用贪心搜索算法来进行序列推断。
-
用于推断当前时刻具有最高预测概率的值。
- 但贪心很可能不是最优的:


效率高但可能不是最优的
穷举搜索
如果我们的目标是获得最优序列,则可以采用穷举搜索的方式:我们需遍历所有可能的输出序列及其对应的条件概率值,并从中筛选出具有最高条件概率的那个序列。
- 最佳算法:遍历所有可能的序列并评估其概率值后选择具有最高评估值的那个。
- 当n=10,000时,在处理数量级达到约十万亿级别的数据
- 虽然该方法能够保证找到全局最优解
- 但在实际应用中由于计算量过大导致方案不可行
束搜索(折中)
保持最好的k个候选(束宽:k)
在每个时刻,对每个候选新加一项(n种可能),在 kn 个选项中选出最好的k个。

候选输出序列是A、C、AB、CE、ABD和CED
时间复杂度O(knT)
每个候选的最终分数是:

* L是最终候选序列的长度,通常α = 0.75
总结
束搜索的结果位于贪心搜索与穷举搜索之间(当k=1时属于贪心)。根据需要调节束宽以实现对计算代价与正确率的权衡正误。
