书籍链接:动手学深度学习
笔记是从第四章开始,前面三章为基础知识,有需要的可以自己去看看
关于本系列笔记: 书里为了让读者更好的理解,有大篇幅的描述性的文字,内容很多,笔记只保留主要内容,同时也是对之前知识的查漏补缺
前一章中我们介绍了循环神经网络的基础知识,这种网络可以更好地处理序列数据。我们在文本数据上实现了基于循环神经网络的语言模型,但是对于当今各种各样的序列学习问题,这些技术可能并不够用。仍需要通过设计更复杂的序列模型来进一步处理它。具体来说,将引入两个广泛使用的网络,即门控循环单元(gated recurrent units,GRU) 和 长短期记忆网络(long short‐term memory,LSTM)。
在 8.7节中,讨论了如何在循环神经网络中计算梯度,以及矩阵连续乘积可以导致梯度消失或梯度爆炸的问题。下面简单思考一下这种梯度异常在实践中的意义:
在学术界已经提出了许多方法来解决这类问题。其中
门控循环单元与普通的循环神经网络之间的关键区别在于:前者支持隐状态的门控。
这意味着模型有专门的机制来确定应该何时更新隐状态,以及应该何时重置隐状态。 这些机制是可学习的,并且能够解决了上面列出的问题。
- 例如,如果第一个词元非常重要,模型将学会在第一次观测之后不更新隐状态。
- 同样,模型也可以学会跳过不相关的临时观测。
- 最后,模型还将学会在需要的时候重置隐状态。
重置门(reset gate)和更新门(update gate)
(我们把它们设计成(0, 1)区间中的向量,这样我们就可以进行凸组合。)
从构造这些门控开始:
图9.1.1: 在门控循环单元模型中计算重置门和更新门
门控循环单元的数学表达:
重置门: R t = σ ( X t W x r + H t − 1 W h r + b r ) ∈ R n × h , ( 9.1.1 ) 重置门:R_t = σ(X_tW_{xr} + H_{t−1}W_{hr} + b_r)∈ R^{n×h},(9.1.1) 重置门:Rt=σ(XtWxr+Ht−1Whr+br)∈Rn×h,(9.1.1)
更新门: Z t = σ ( X t W x z + H t − 1 W h z + b z ) ∈ R n × h , ( 9.1.1 ) 更新门:Z_t = σ(X_tW_{xz} + H_{t−1}W_{hz} + b_z)∈ R^{n×h},(9.1.1) 更新门:Zt=σ(XtWxz+Ht−1Whz+bz)∈Rn×h,(9.1.1)
其中 W x r , W x z ∈ R d × h W_{xr} , W_{xz} ∈ R^{d×h} Wxr,Wxz∈Rd×h 和 W h r , W h z ∈ R h × h W_{hr}, W_{hz} ∈ R^{h×h} Whr,Whz∈Rh×h是权重参数, b r , b z ∈ R 1 × h b_r, b_z ∈ R_{1×h} br,bz∈R1×h是偏置参数。注意,在求和过程中会触发广播机制。使用sigmoid函数(如 4.1节中介绍的)将输入值转换到区间(0, 1)。
候选隐状态
将重置门 R t R_t Rt与 (8.4.5) 中的常规隐状态更新机制集成,得到在时间步 t t t的候选隐状态(candidatehidden state) H ^ t ∈ R n × h \hat{H}_t ∈ R^{n×h} H^t∈Rn×h。
H ^ t = t a n h ( X t W x h + ( R t ⊙ H t − 1 ) W h h + b h ) , ( 9.1.2 ) \hat{H}_t = tanh(X_tW_{xh} + (R_t ⊙ H_{t−1}) W_{hh} + b_h), (9.1.2) H^t=tanh(XtWxh+(Rt⊙Ht−1)Whh+bh),(9.1.2)
其中 W x h ∈ R d × h W_{xh} ∈ R^{d×h} Wxh∈Rd×h 和 W h h ∈ R h × h W_{hh} ∈ R^{h×h} Whh∈Rh×h是权重参数, b h ∈ R 1 × h b_h ∈ R^{1×h} bh∈R1×h是偏置项,符号⊙是Hadamard积(按元素乘积)运算符。
使用 t a n h tanh tanh非线性激活函数来确保候选隐状态中的值保持在区间(−1, 1)中。(与 (8.4.5)相比,(9.1.2)中的 R t R_t Rt和 H t − 1 H_{t−1} Ht−1 的元素相乘可以减少以往状态的影响。)
应用重置门之后的计算流程:
图9.1.2: 在门控循环单元模型中计算候选隐状态
隐状态
上述的计算结果只是候选隐状态,仍然需要结合更新门 Z t Z_t Zt的效果。这一步确定 新的隐状态 H t ∈ R n × h H_t ∈ R^{n×h} Ht∈Rn×h 在多大程度上来自旧的状态 H t − 1 H_{t-1} Ht−1 和新的候选状态 H ^ t \hat{H}_t H^t。更新门 Z t Z_t Zt仅需要在 H t − 1 H_{t-1} Ht−1和 H ^ t \hat{H}_t H^t 之间进行按元素的凸组合就可以实现这个目标。这就得出了门控循环单元的最终更新公式:
H t = Z t ⊙ H t − 1 + ( 1 − Z t ) ⊙ H ^ t . ( 9.1.3 ) H_t = Z_t ⊙ H_{t-1} + (1 − Z_t) ⊙ \hat{H}_t. (9.1.3) Ht=Zt⊙Ht−1+(1−Zt)⊙H^t.(9.1.3)
这些设计可以处理循环神经网络中的梯度消失问题,并更好地捕获时间步距离很长的序列的依赖关系。
图9.1.3: 计算门控循环单元模型中的隐状态
总之,门控循环单元具有以下两个显著特征:
从零开始实现门控循环单元模型。(读取 8.5节中使用的时间机器数据集)
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)
初始化模型参数
下一步是初始化模型参数。我们从标准差为0.01的高斯分布中提取权重,并将偏置项设为0,超参数num_hiddens定义隐藏单元的数量,实例化与更新门、重置门、候选隐状态和输出层相关的所有权重和偏置。
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
定义模型
定义隐状态的初始化函数init_gru_state
。此函数返回一个形状为(批量大小,隐藏单元个数)的张量,张量的值全部为零(与 8.5节中定义的init_rnn_state函数一样)
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)
==================================================================
perplexity 1.1, 19911.5 tokens/sec on cuda:0
time traveller firenis i heidfile sook at i jomer and sugard are
travelleryou can show black is white by argument said filby
高级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)
=====================================================
perplexity 1.0, 109423.8 tokens/sec on cuda:0
time travelleryou can show black is white by argument said filby
traveller with a slight accession ofcheerfulness really thi
长期以来,隐变量模型存在着长期信息保存和短期输入缺失的问题。解决这一问题的最早方法之一是长短期存储器(long short‐term memory,LSTM)(Hochreiter and Schmidhuber, 1997)。(它有许多与门控循环单元(9.1节)一样的属性。有趣的是,长短期记忆网络的设计比门控循环单元稍微复杂一些,却比门控循环单元早诞生了近20年。)
可以说,长短期记忆网络的设计灵感来自于计算机的逻辑门。长短期记忆网络引入了记忆元(memory cell),或简称为单元(cell)。有些文献认为记忆元是隐状态的一种特殊类型,它们与隐状态具有相同的形状,其设计目的是用于记录附加的信息。为了控制记忆元需要许多门。
输入门、忘记门和输出门就如在门控循环单元中一样,由三个具有sigmoid激活函数的全连接层处理,以计算输入门、遗忘门和输出门的值。因此,这三个门的值都在(0, 1)的范围内。
细化一下长短期记忆网络的数学表达。假设有 h h h个隐藏单元,批量大小为 n n n,输入数为 d d d。因此,
它们的计算方法如下:
I t = σ ( X t W x i + H t − 1 W h i + b i ) , I_t = σ(X_tW_{xi} + H_{t−1}W_{hi} + b_i), It=σ(XtWxi+Ht−1Whi+bi),
F t = σ ( X t W x f + H t − 1 W h f + b f ) , F_t = σ(X_tW_{xf}+ H_{t−1}W_{hf} + b_f ), Ft=σ(XtWxf+Ht−1Whf+bf),
O t = σ ( X t W x o + H t − 1 W h o + b o ) , O_t = σ(X_tW_{xo} + H_{t−1}W_{ho} + b_o), Ot=σ(XtWxo+Ht−1Who+bo),
候选记忆元
由于还没有指定各种门的操作,所以先介绍候选记忆元(candidate memory cell) C ^ t ∈ R n × h \hat{C}_t ∈ R^{n×h} C^t∈Rn×h。它的计算与上面描述的三个门的计算类似,但是使用tanh函数作为激活函数,函数的值范围为(−1, 1)。下面导出在时间步 t t t处的方程:
C ^ t = t a n h ( X t W x c + H t − 1 W h c + b c ) , \hat{C}_t= tanh(X_tW_{xc} + H_{t−1}W_{hc} + b_c), C^t=tanh(XtWxc+Ht−1Whc+bc),
图9.2.2: 长短期记忆模型中的候选记忆元
记忆元
在长短期记忆网络中,也有两个门用于这样的目的:
使用按元素乘法,得出:
C t = F t ⊙ C t − 1 + I t ⊙ C ^ t . ( 9.2.3 ) C_t = F_t ⊙ C_{t−1} + I_t ⊙ \hat{C}_t. (9.2.3) Ct=Ft⊙Ct−1+It⊙C^t.(9.2.3)
如果遗忘门始终为1且输入门始终为0,则过去的记忆元 C t − 1 C_{t−1} Ct−1 将随时间被保存并传递到当前时间步。引入这种设计是为了缓解梯度消失问题,并更好地捕获序列中的长距离依赖关系。这样我们就得到了计算记忆元的流程图,如 图9.2.3
隐状态
最后,定义如何计算隐状态 H t ∈ R n × h H_t ∈ R^{n×h} Ht∈Rn×h,这就是输出门发挥作用的地方。在长短期记忆网络中,它仅仅是记忆元的tanh的门控版本。这就确保了 H t H_t Ht的值始终在区间(−1, 1)内:
H t = O t ⊙ t a n h ( C t ) . ( 9.2.4 ) H_t = O_t ⊙ tanh(C_t). (9.2.4) Ht=Ot⊙tanh(Ct).(9.2.4)
图9.2.4提供了数据流的图形化演示。
从零开始实现长短期记忆网络 (与 8.5节中的实验相同,我们首先加载时光机器数据集)
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定义隐藏单元的数量。按照标准差0.01的高斯分布初始化权重,并将偏置项设为0。)
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))
实际模型的定义与前面讨论的一样:提供三个门和一个额外的记忆元。(请注意,只有隐状态才会传递到输出层,而记忆元 C t C_t Ct不直接参与输出计算。)
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)
训练和预测
通过实例化 8.5节中引入的RNNModelScratch类来训练一个长短期记忆网络,就如我们在 9.1节中所做的一样。
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)
========================================================
perplexity 1.3, 17736.0 tokens/sec on cuda:0
time traveller for so it will leong go it we melenot ir cove i s
traveller care be can so i ngrecpely as along the time dime
使用高级API,可以直接实例化LSTM模型。高级API封装了前文介绍的所有配置细节。这段代码的运行速度要快得多,因为它使用的是编译好的运算符而不是Python来处理之前阐述的许多细节。
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)
==========================================
perplexity 1.1, 234815.0 tokens/sec on cuda:0
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby
长短期记忆网络是典型的具有重要状态控制的隐变量自回归模型。多年来已经提出了其许多变体,例如,多层、残差连接、不同类型的正则化。然而,由于序列的长距离依赖性,训练长短期记忆网络和其他序列模型(例如门控循环单元)的成本是相当高的。在后面的内容中,我们将讲述更高级的替代模型,如Transformer。
小结
到目前为止,只讨论了具有一个单向隐藏层的循环神经网络。其中,隐变量和观测值与具体的函数形式的交互方式是相当随意的。只要交互类型建模具有足够的灵活性,这就不是一个大问题。然而,对一个单层来说,这可能具有相当的挑战性。
之前在线性模型中,通过添加更多的层来解决这个问题。而在循环神经网络中,首先需要确定如何添加更多的层,以及在哪里添加额外的非线性,因此这个问题有点棘手。
事实上,可以将多层循环神经网络堆叠在一起,通过对几个简单层的组合,产生了一个灵活的机制。
图9.3.1描述了一个具有L个隐藏层的深度循环神经网络,每个隐状态都连续地传递到当前层的下一个时间步和下一层的当前时间步。
图9.3.1: 深度循环神经网络结构
可以将深度架构中的函数依赖关系形式化,这个架构是由 图9.3.1中描述了L个隐藏层构成。
则:
H t ( l ) = ϕ l ( H t ( l − 1 ) W x h ( l ) + H t − 1 ( l ) W h h ( l ) + b h ( l ) ) H^{(l)}_t = ϕ_l(H^{(l-1)}_t W^{(l)}_{xh} + H^{(l)}_{t-1}W^{(l)}_{hh} + b^{(l)}_h) Ht(l)=ϕl(Ht(l−1)Wxh(l)+Ht−1(l)Whh(l)+bh(l))
输出层的计算仅基于第l个隐藏层最终的隐状态:
O t = H t ( l ) W h q + b q O_t = H^{(l)}_tW_{hq}+b_q Ot=Ht(l)Whq+bq
与多层感知机一样,隐藏层数目L和隐藏单元数目h都是超参数。也就是说,它们可以由我们调整的。另外,用门控循环单元或长短期记忆网络的隐状态来代替 (9.3.1)中的隐状态进行计算,可以很容易地得到深度门控循环神经网络或深度长短期记忆神经网络。
实现多层循环神经网络所需的许多逻辑细节在高级API中都是现成的。简单起见,仅示范使用此类内置函数的实现方式。以长短期记忆网络模型为例,该代码与之前在 9.2节中使用的代码非常相似,实际上唯一的区别是指定了层的数量,而不是使用单一层这个默认值。像往常一样,我们从加载数据集开始。
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)
像选择超参数这类架构决策也跟 9.2节中的决策非常相似。因为有不同的词元,所以输入和输出都选择相同数量,即vocab_size。隐藏单元的数量仍然是256。唯一的区别是,现在通过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)
================================================================
perplexity 1.0, 186005.7 tokens/sec on cuda:0
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby
在序列学习中,我们以往假设的目标是:在给定观测的情况下(例如,在时间序列的上下文中或在语言模型的上下文中),对下一个输出进行建模。虽然这是一个典型情景,但不是唯一的。还可能发生什么其它的情况呢?我们考虑以下三个在文本序列中填空的任务。
• 我___。
• 我___饿了。
• 我___饿了,我可以吃半头猪。
根据可获得的信息量,可以用不同的词填空,如“很高兴”(“happy”)、“不”(“not”)和“非常”(“very”)。很明显,每个短语的“下文”传达了重要信息(如果有的话),而这些信息关乎到选择哪个词来填空,所以无法利用这一点的序列模型将在相关任务上表现不佳。例如,如果要做好命名实体识别(例如,识别“Green”指的是“格林先生”还是绿色),不同长度的上下文范围重要性是相同的。为了获得一些解决问题的灵感,让我们先迂回到概率图模型。
这一小节是用来说明动态规划问题的,具体的技术细节对于理解深度学习模型并不重要,但它有助于我们思考为什么要使用深度学习,以及为什么要选择特定的架构。
如果我们想用概率图模型来解决这个问题,可以设计一个隐变量模型:在任意时间步 t t t,假设存在某个隐变量 h t h_t ht,通过概率 P ( x t ∣ h t ) P(x_t | h_t) P(xt∣ht)控制观测到的 x t x_t xt。此外,任何 h t → h t + 1 h_t → h_{t+1} ht→ht+1转移都是由一些状态转移概率 P ( h t + 1 ∣ h t ) P(h_{t+1} | h_t) P(ht+1∣ht)给出。这个概率图模型就是一个隐马尔可夫模型(hidden Markov model,HMM),如图9.4.1所示。
因此,对于有 T T T个观测值的序列,在观测状态和隐状态上具有以下联合概率分布:
现 在, 假 设 观 测 到 所 有 的 x i x_i xi, 除 了 x j x_j xj, 并 且 目 标 是 计 算 P ( x j ∣ x − j ) P(x_j | x_{−j} ) P(xj∣x−j), 其 中 x − j = ( x 1 , . . . , x j − 1 , x j + 1 , . . . , x T ) x_{−j} =(x_1, . . . , x_{j−1}, x_{j+1}, . . . , x_T ) x−j=(x1,...,xj−1,xj+1,...,xT)
由于 P ( x j ∣ x − j ) P(x_j | x_{−j} ) P(xj∣x−j)中没有隐变量,因此考虑对 h 1 , . . . , h T h_1, . . . , h_T h1,...,hT选择构成的所有可能的组合进行求和。如果任何 h i h_i hi可以接受 k k k个不同的值(有限的状态数),这意味着我们需要对 k T k^T kT个项求和,这个任务显然难于登天。幸运的是,有个巧妙的解决方案:动态规划(dynamic programming)。
要了解动态规划的工作方式,我们考虑对隐变量 h 1 , . . . , h T h_1, . . . , h_T h1,...,hT的依次求和。根据 (9.4.1),将得出:
通常,我们将前向递归(forward recursion)写为:
递归被初始化为 π 1 ( h 1 ) = P ( h 1 ) π_1(h_1) = P(h_1) π1(h1)=P(h1)。符号简化,也可以写成 π t + 1 = f ( π t , x t ) π_{t+1} = f(π_t, x_t) πt+1=f(πt,xt),其中 f f f是一些可学习的函数。这看起来就像我们在循环神经网络中讨论的隐变量模型中的更新方程。与前向递归一样,我们也可以使用后向递归对同一组隐变量求和。这将得到:
因此,我们可以将后向递归(backward recursion)写为:
初始化ρT (hT ) = 1。前 初始化 ρ T ( h T ) = 1 ρT (hT ) = 1 ρT(hT)=1。前向和后向递归都允许我们对T个隐变量在 O ( k T ) O(kT) O(kT) (线性而不是指数)时间内对 ( h 1 , . . . , h T ) (h_1, . . . , h_T) (h1,...,hT)的所有值求和。这是使用图模型进行概率推理的巨大好处之一。它也是通用消息传递算法 (Aji and McEliece, 2000)的一个非常特殊的例子。
结合前向和后向递归,能够计算:
因为符号简化的需要,后向递归也可以写为 ρ t − 1 = g ( ρ t , x t ) ρ_{t−1} = g(ρ_t, x_t) ρt−1=g(ρt,xt),其中 g g g是一个可以学习的函数。同样,这看起来非常像一个更新方程,只是不像我们在循环神经网络中看到的那样前向运算,而是后向计算。事实上,知道未来数据何时可用对隐马尔可夫模型是有益的。信号处理学家将是否知道未来观测这两种情况区分为内插和外推,有关更多详细信息,请参阅 (Doucet et al., 2001)。
如果希望在循环神经网络中拥有一种机制,使之能够提供与隐马尔可夫模型类似的前瞻能力,就需要修改循环神经网络的设计。幸运的是,这在概念上很容易,只需要增加一个“从最后一个词元开始从后向前运行”的循环神经网络,而不是只有一个在前向模式下“从第一个词元开始运行”的循环神经网络。双向循环神经网络(bidirectional RNNs)添加了反向传递信息的隐藏层,以便更灵活地处理此类信息。图9.4.2描述了具有单个隐藏层的双向循环神经网络的架构。
图9.4.2: 双向循环神经网络架构
事实上,这与隐马尔可夫模型中的动态规划的前向和后向递归没有太大区别。其主要区别是,在隐马尔可夫模型中的方程具有特定的统计意义。双向循环神经网络没有这样容易理解的解释,我们只能把它们当作通用的、可学习的函数。这种转变集中体现了现代深度网络的设计原则:首先使用经典统计模型的函数依赖类型,然后将其参数化为通用形式。
定义
双向循环神经网络是由 (Schuster and Paliwal, 1997)提出的,关于各种架构的详细讨论请参阅 (Graves and Schmidhuber, 2005)。
对于任意时间步t,给定一个小批量的输入数据 X t ∈ R n × d X_t ∈ R_{n×d} Xt∈Rn×d (样本数n,每个示例中的输入数d),并且令隐藏层激活函数为 ϕ ϕ ϕ。在双向架构中,设该时间步的前向和反向隐状态分别为 H → t ∈ R n × h \overrightarrow{H}_t ∈ R^{n×h} Ht∈Rn×h和 H ← t ∈ R n × h \overleftarrow{H}_t∈ R^{n×h} Ht∈Rn×h,其中h是隐藏单元的数目。前向和反向隐状态的更新如下:
H → t = ϕ ( X t W x h ( f ) + H → t − 1 W h h ( f ) + b h ( f ) ) , \overrightarrow{H}_t = ϕ(X_tW^{(f)}_{xh} +\overrightarrow{H}_{t-1} W^{(f)}_{hh} + b^{(f)}_h), Ht=ϕ(XtWxh(f)+Ht−1Whh(f)+bh(f)),
H ← t = ϕ ( X t W x h ( f ) + H ← t − 1 W h h ( f ) + b h ( f ) ) , \overleftarrow{H}_t = ϕ(X_tW^{(f)}_{xh} +\overleftarrow{H}_{t-1} W^{(f)}_{hh} + b^{(f)}_h), Ht=ϕ(XtWxh(f)+Ht−1Whh(f)+bh(f)),
接下来,将前向隐状态 H → t ∈ R n × h \overrightarrow{H}_t ∈ R^{n×h} Ht∈Rn×h和反向隐状态 H ← t ∈ R n × h \overleftarrow{H}_t∈ R^{n×h} Ht∈Rn×h连接起来,获得需要送入输出层的隐状态 H t ∈ R n × 2 h {H}_t ∈ R^{n×2h} Ht∈Rn×2h。在具有多个隐藏层的深度双向循环神经网络中,该信息作为输入传递到下一个双向层。最后,输出层计算得到的输出为 O t ∈ R n × q Ot ∈ R^{n×q} Ot∈Rn×q(q是输出单元的数目):
O t = H t W h q + b q . O_t = H_tW_{hq}+ b_q. Ot=HtWhq+bq.
模型的计算代价及其应用
双向循环神经网络的一个关键特性是:使用来自序列两端的信息来估计输出。也就是说,我们使用来自过去和未来的观测信息来预测当前的观测。但是在对下一个词元进行预测的情况中,这样的模型并不是我们所需的。因为在预测下一个词元时,我们终究无法知道下一个词元的下文是什么,所以将不会得到很好的精度。具体地说,在训练期间,我们能够利用过去和未来的数据来估计现在空缺的词;而在测试期间,我们只有过去的数据,因此精度将会很差。 下面的实验将说明这一点。
另一个严重问题是,双向循环神经网络的计算速度非常慢。其主要原因是网络的前向传播需要在双向层中进行前向和后向递归,并且网络的反向传播还依赖于前向传播的结果。因此,梯度求解将有一个非常长的链。
由于双向循环神经网络使用了过去的和未来的数据,所以我们不能盲目地将这一语言模型应用于任何预测任务。尽管模型产出的困惑度(perplexity)是合理的,该模型预测未来词元的能力却可能存在严重缺陷。
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
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)
===========================================
perplexity 1.1, 131129.2 tokens/sec on cuda:0
time travellerererererererererererererererererererererererererer
travellerererererererererererererererererererererererererer
小结
语言模型是自然语言处理的关键,而机器翻译是语言模型最成功的基准测试。因为机器翻译正是将输入序列转换成输出序列的序列转换模型(sequence transduction)的核心问题。
机器翻译(machine translation) 指的是将序列从一种语言自动翻译成另一种语言。在使用神经网络进行端到端学习的兴起之前,统计学方法在这一领域一直占据主导地位 (Brown et al., 1990, Brown et al., 1988)。因为统计机器翻译(statistical machine translation)涉及了翻译模型和语言模型等组成部分的统计分析,因此基于神经网络的方法通常被称为 神经机器翻译(neural machine translation),用于将两种翻译模型区分开来。
下面,我们看一下如何将预处理后的数据加载到小批量中用于训练。
import os
import torch
from d2l import torch as d2l
首先,下载一个由Tatoeba项目的双语句子对113 组成的“英-法”数据集,数据集中的每一行都是制表符分隔的文本序列对,序列对由英文文本序列和翻译后的法语文本序列组成。
请注意,每个文本序列可以是一个句子,也可以是包含多个句子的一个段落。在这个将英语翻译成法语的机器翻译问题中,英语是源语言(source language),法语是目标语言(target language)。
#@save
d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip','94646ad1522d915e7b0f9296181140edcf86a4f5')
#@save
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])
=================================================================================
Downloading ../data/fra-eng.zip from http://d2l-data.s3-accelerate.amazonaws.com/fra-eng.zip...
Go. Va !
Hi. Salut !
Run! Cours !
Run! Courez !
Who? Qui ?
Wow! Ça alors !
下载数据集后,原始文本数据需要经过几个预处理步骤。例如,我们用空格代替不间断空格(non‐breaking space),使用小写字母替换大写字母,并在单词和标点符号之间插入空格。
#@save
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])
=====================================
go . va !
hi . salut !
run ! cours !
run ! courez !
who ? qui ?
wow ! ça alors !
与 8.3节中的字符级词元化不同,在机器翻译中,更喜欢单词级词元化(最先进的模型可能使用更高级的词元化技术)。
下面的tokenize_nmt
函数对前num_examples个文本序列对进行词元,其中每个词元要么是一个词,要么是一个标点符号。
此函数返回两个词元列表:source和target:
#@save
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]
=============================================
([['go', '.'],
['hi', '.'],
['run', '!'],
['run', '!'],
['who', '?'],
['wow', '!']],
[['va', '!'],
['salut', '!'],
['cours', '!'],
['courez', '!'],
['qui', '?'],
['ça', 'alors', '!']])
绘制每个文本序列所包含的词元数量的直方图。在这个简单的“英-法”数据集中,大多数文本序列的词元数量少于20个。
#@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次的低频率词元视为 相同的未知 (“ < u n k > ”) (“
这些特殊词元在自然语言处理任务中比较常用。
src_vocab = d2l.Vocab(source, min_freq=2,reserved_tokens=['' , '' , '' ])
len(src_vocab)
10012
回想一下,语言模型中的序列样本都有一个固定的长度,无论这个样本是一个句子的一部分还是跨越了多个句子的一个片断。这个固定长度是由 8.3节中的 num_steps(时间步数或词元数量)参数指定的。
在机器翻译中,每个样本都是由源和目标组成的文本序列对,其中的每个文本序列可能具有不同的长度。为了提高计算效率,我们仍然可以通过截断(truncation) 和 填充(padding)方式实现一次只处理一个小批量的文本序列。
假设同一个小批量中的每个序列都应该具有相同的长度num_steps
- 如果文本序列的词元数目少于num_steps时,我们将继续在其末尾添加特定的 “ < p a d > ” “
” “<pad>”词元,直到其长度达到num_steps;- 反之,将截断文本序列时,只取其前num_steps 个词元,并且丢弃剩余的词元。
这样,每个文本序列将具有相同的长度,以便以相同形状的小批量进行加载。
如前所述,下面的truncate_pad函数将截断或填充文本序列。
#@save
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['' ])
====================================================
[47, 4, 1, 1, 1, 1, 1, 1, 1, 1]
现在定义一个函数,可以将文本序列转换成小批量数据集用于训练。
#@save
def build_array_nmt(lines, vocab, num_steps):
"""将机器翻译的文本序列转换成小批量"""
lines = [vocab[l] for l in lines]
lines = [l + [vocab['' ]] for l in lines]
array = torch.tensor([truncate_pad(l, num_steps, vocab['' ]) for l in lines])
valid_len = (array != vocab['' ]).type(torch.int32).sum(1)
return array, valid_len
最后,定义load_data_nmt函数来返回数据迭代器,以及源语言和目标语言的两种词表。
#@save
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=['' , '' , '' ])
tgt_vocab = d2l.Vocab(target, min_freq=2,reserved_tokens=['' , '' , '' ])
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
=================================
X: tensor([[ 7, 43, 4, 3, 1, 1, 1, 1],[44, 23, 4, 3, 1, 1, 1, 1]], dtype=torch.int32)
X的有效长度: tensor([4, 4])
Y: tensor([[ 6, 7, 40, 4, 3, 1, 1, 1],[ 0, 5, 3, 1, 1, 1, 1, 1]], dtype=torch.int32)
Y的有效长度: tensor([5, 3])
小结
正如我们在 9.5节中所讨论的,机器翻译是序列转换模型的一个核心问题,其输入和输出都是长度可变的序列。
为了处理这种类型的输入和输出,可以设计一个包含两个主要组件的架构:
这被称为编码器-解码器(encoder‐decoder)架构,如 图9.6.1 所示。
图9.6.1: 编码器‐解码器架构
以英语到法语的机器翻译为例:给定一个英文的输入序列:“They”“are”“watching”“.”。
在编码器接口中,只指定长度可变的序列作为编码器的输入X。任何继承这个Encoder基类的模型将完成代码实现。
from torch import nn
#@save
class Encoder(nn.Module):
"""编码器-解码器架构的基本编码器接口"""
def __init__(self, **kwargs):
super(Encoder, self).__init__(**kwargs)
def forward(self, X, *args):
raise NotImplementedError
在下面的解码器接口中,新增一个init_state函数,用于将编码器的输出(enc_outputs)转换为编码后的状态。
注意,此步骤可能需要额外的输入,例如:输入序列的有效长度,这在 9.5.4节中进行了解释。为了逐个地生成长度可变的词元序列,解码器在每个时间步都会将输入(例如:在前一时间步生成的词元)和编码后的状态映射成当前时间步的输出词元。
#@save
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
总而言之,“编码器‐解码器”架构包含了一个编码器和一个解码器,并且还拥有可选的额外的参数。在前向传播中,编码器的输出用于生成编码状态,这个状态又被解码器作为其输入的一部分。
#@save
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.5节中看到的,机器翻译中的输入序列和输出序列都是长度可变的。为了解决这类问题,在 9.6节中设计了一个通用的”编码器-解码器“架构。
遵循编码器-解码器架构的设计原则,循环神经网络编码器使用长度可变的序列作为输入,将其转换为固定形状的隐状态。
为了连续生成输出序列的词元,独立的循环神经网络解码器是基于输入序列的编码信息和输出序列已经看见的或者生成的词元来预测下一个词元。图9.7.1演示了如何在机器翻译中使用两个循环神经网络进行序列到序列学习。
图9.7.1: 使用循环神经网络编码器和循环神经网络解码器的序列到序列学习
特定的 “ < e o s > ” “
在循环神经网络解码器的初始化时间步,有两个特定的设计决定:
例如,在 (Sutskever et al., 2014)的设计中,正是基于这种设计将输入序列的编码信息送入到解码器中来生成输出序列的。在其他一些设计中 (Cho et al., 2014),如 图9.7.1所示,编码器最终的隐状态在每一个时间步都作为解码器的输入序列的一部分。
类似于 8.3节中语言模型的训练,可以允许标签成为原始的输出序列,从源序列词元 “ < b o s > ” “
import collections
import math
import torch
from torch import nn
from d2l import torch as d2l
从技术上讲,编码器将长度可变的输入序列转换成形状固定的上下文变量c,并且将输入序列的信息在该上下文变量中进行编码。
如 图9.7.1所示,可以使用循环神经网络来设计编码器。考虑由一个序列组成的样本(批量大小是1)。
h t = f ( x t , h t − 1 ) . h_t = f(x_t, ht−1). ht=f(xt,ht−1).
总之,编码器通过选定的函数 q q q,将所有时间步的隐状态转换为上下文变量:
c = q ( h 1 , . . . , h T ) . c = q(h_1, . . . , h_T ). c=q(h1,...,hT).
比如,当选择 q ( h 1 , . . . , h T ) = h T q(h_1, . . . , h_T ) = h_T q(h1,...,hT)=hT时(就像 图9.7.1中一样),上下文变量仅仅是输入序列在最后时间步的隐状态 h T h_T hT。
现在,实现循环神经网络编码器。注意,使用了嵌入层(embedding layer) 来获得输入序列中每个词元的特征向量。
对于任意输入词元的索引i,嵌入层获取权重矩阵的第i行(从0开始)以返回其特征向量。另外,本文选择了一个多层门控循环单元来实现编码器。
#@save
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) # 输入词表的大小(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) num_layers,批量大小,隐藏单元数
return output, state
循环层返回变量的说明可以参考 8.6节。
实例化上述编码器的实现:
encoder = 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)
output.shape
================================================================
torch.Size([7, 4, 16]) # 时间步为7, 批量大小为4,隐藏单元数为16
由于这里使用的是门控循环单元,所以在最后一个时间步的多层隐状态的形状是(隐藏层的数量,批量大小,隐藏单元的数量)。如果使用长短期记忆网络,state中还将包含记忆单元信息。
state.shape
torch.Size([2, 4, 16])
正如上文提到的,编码器输出的上下文变量 c c c对整个输入序列 x 1 , . . . , x T x_1, . . . , x_T x1,...,xT进行编码。来自训练数据集的输出序列 y 1 , y 2 , . . . , y T ′ y_1, y_2, . . . , y_{T^′} y1,y2,...,yT′,对于每个时间步 t ′ t′ t′(与输入序列或编码器的时间步t不同),解码器输出 y t ′ y_{t^′} yt′的概率 取决于先前的输出子序列 y 1 , . . . , y t ′ − 1 y1, . . . , yt′−1 y1,...,yt′−1和上下文变量c,即 P ( y t ′ ∣ y 1 , . . . , y t ′ − 1 , c ) P(yt′ | y1, . . . , yt′−1, c) P(yt′∣y1,...,yt′−1,c)。
为了在序列上模型化这种条件概率,可以使用另一个循环神经网络作为解码器。在输出序列上的任意时间步 t ′ t^′ t′,循环神经网络将来自上一时间步的输出 y t ′ − 1 y_{t^′−1} yt′−1 和上下文变量 c c c作为其输入,然后在当前时间步将它们和上一隐状态 s t ′ − 1 s_{t^′−1} st′−1转换为隐状态 s t ′ s_{t^′} st′。因此,可以使用函数 g g g来表示解码器的隐藏层的变换:
s t ′ = g ( y t ′ − 1 , c , s t ′ − 1 ) . s_{t^′} = g(y_{t^′−1}, c, s_{t^′−1}). st′=g(yt′−1,c,st′−1).
在获得解码器的隐状态之后,可以使用输出层和softmax操作来计算在时间步 t ′ t^′ t′时输出 y t ′ y_{t^′} yt′的条件概率分布 P ( y t ′ ∣ y 1 , . . . , y t ′ − 1 , c ) P(y_{t^′} | y_1, . . . , y_{t^′−1}, c) P(yt′∣y1,...,yt′−1,c)。根据 图9.7.1,当实现解码器时,直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。为了进一步包含经过编码的输入序列的信息,上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。为了预测输出词元的概率分布,在循环神经网络解码器的最后一层使用全连接层来变换隐状态。
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
===============================
(torch.Size([4, 7, 10]), torch.Size([2, 4, 16]))
图9.7.2: 循环神经网络编码器‐解码器模型中的层
在每个时间步,解码器预测了输出词元的概率分布。类似于语言模型,可以使用softmax来获得分布,并通过计算交叉熵损失函数来进行优化。 9.5节中,特定的填充词元被添加到序列的末尾,因此不同长度的序列可以以相同形状的小批量加载。但是,应该将填充词元的预测排除在损失函数的计算之外。为此,可以使用下面的sequence_mask
函数通过零值化屏蔽不相关的项,以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。例如,如果两个序列的有效长度(不包括填充词元)分别为1和2,则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。
#@save
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]))
==============================================
tensor([[1, 0, 0],
[4, 5, 0]])
还可以使用此函数屏蔽最后几个轴上的所有项。
X = torch.ones(2, 3, 4)
sequence_mask(X, torch.tensor([1, 2]), value=-1)
========================================
tensor([[[ 1., 1., 1., 1.],
[-1., -1., -1., -1.],
[-1., -1., -1., -1.]],
[[ 1., 1., 1., 1.],
[ 1., 1., 1., 1.],
[-1., -1., -1., -1.]]])
通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。最初,所有预测词元的掩码都设置为1。一旦给定了有效长度,与填充词元对应的掩码将被设置为0。最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。
#@save
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
创建三个相同的序列来进行代码健全性检查,然后分别指定这些序列的有效长度为4、2和0。结果就是,第一个序列的损失应为第二个序列的两倍,而第三个序列的损失应为零。
loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long),torch.tensor([4, 2, 0]))
=====================================
tensor([2.3026, 1.1513, 0.0000])
在下面的循环训练过程中,如 图9.7.1所示,特定的序列开始词元 (“ < b o s > ”) (“
#@save
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['' ]] * 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)
========================================
loss 0.019, 12745.1 tokens/sec on cuda:0
为了采用一个接着一个词元的方式预测输出序列,每个解码器当前时间步的输入都将来自于前一时间步的预测词元。与训练类似,序列开始词元 (“ < b o s > ”) (“
图9.7.3: 使用循环神经网络编码器‐解码器逐词元地预测输出序列。
将在 9.8节中介绍不同的序列生成策略。
#@save
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['' ]]
enc_valid_len = torch.tensor([len(src_tokens)], device=device)
src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['' ])
# 添加批量轴
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['' ]], 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['' ]:
break
output_seq.append(pred)
return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq
小结
在 9.7节中,逐个预测输出序列,直到预测序列中出现特定的序列结束词元 “ < e o s > ” “
在正式介绍贪心搜索之前,使用与 9.7节中相同的数学符号定义搜索问题。
在任意时间步 t ′ t^′ t′,解码器输出 y t ′ y_{t^′} yt′的概率取决于时间步 t ′ t^′ t′之前的输出子序列 y 1 , . . . , y t ′ − 1 y_1, . . . , y_{t^′−1} y1,...,yt′−1 和对输入序列的信息进行编码得到的上下文变量 c c c。为了量化计算代价,用 Y Y Y表示输出词表,其中包含 “ < e o s > ” “
首先,看一个简单的策略:贪心搜索,该策略已用于 9.7节的序列预测。对于输出序列的每一时间步t′,都将基于贪心搜索从Y中找到具有最高条件概率的词元,即:
一旦输出序列包含了 “ < e o s > ” “
图9.8.1: 在每个时间步,贪心搜索选择具有最高条件概率的词元
如 图9.8.1中,假设输出中有四个词元“A”“B”“C”和 “ < e o s > ” “
**那么贪心搜索存在的问题是什么呢?**现实中,最优序列(optimal sequence)应该是最大化 ∏ t ′ = 1 T ′ P ( y t ′ ∣ y 1 , . . . , y t ′ − 1 , c ) \prod \limits_{t^′=1}^{T^′}P(yt^′ | y1, . . . , y_{t′−1}, c) t′=1∏T′P(yt′∣y1,...,yt′−1,c) 值的输出序列,这是基于输入序列生成输出序列的条件概率。然而,贪心搜索无法保证得到最优序列。
图9.8.2: 在时间步2,选择具有第二高条件概率的词元“C”(而非最高条件概率的词元)
与 图9.8.1不同,在时间步2中,选择 图9.8.2中的词元“C”,它具有第二高的条件概率。
如果目标是获得最优序列,可以考虑使用穷举搜索(exhaustive search):穷举地列举所有可能的输出序列及其条件概率,然后计算输出条件概率最高的一个。虽然可以使用穷举搜索来获得最优序列,但其计算量 O ( ∣ Y ∣ T ′ ) O(|Y|T^′) O(∣Y∣T′)可能高的惊人。
那么该选取哪种序列搜索策略呢?如果精度最重要,则显然是穷举搜索。
如果计算成本最重要,则显然是贪心搜索。而束搜索的实际应用则介于这两个极端之间。
束搜索(beam search) 是贪心搜索的一个改进版本。它有一个超参数,名为束宽(beam size)k。在时间步1,我们选择具有最高条件概率的 k k k个词元。这k个词元将分别是k个候选输出序列的第一个词元。在随后的每个时间步,基于上一时间步的k个候选输出序列,我们将继续从 k ∣ Y ∣ k |Y| k∣Y∣个可能的选择中挑出具有最高条件概率的k个候选输出序列。
图9.8.3: 束搜索过程(束宽:2,输出序列的最大长度:3)。候选输出序列是A、C、AB、CE、ABD和CED
图9.8.3演示了束搜索的过程。假设输出的词表只包含五个元素:Y = {A, B, C, D, E},其中有一个是 “ < e o s > ” “
设置束宽为2,输出序列的最大长度为3。
从这十个值中选择最大的两个,比如P(A, B | c)和P(C, E | c)。然后在时间步3,我们计算所有y3 ∈ Y为:
从这十个值中选择最大的两个,即 P ( A , B , D ∣ c ) P(A, B, D | c) P(A,B,D∣c)和 P ( C , E , D ∣ c ) P(C, E, D | c) P(C,E,D∣c),我们会得到六个候选输出序列: ( 1 ) A ;( 2 ) C ;( 3 ) A , B ;( 4 ) C , E ;( 5 ) A , B , D ;( 6 ) C , E , D (1)A;(2) C;(3)A, B;(4)C, E;(5)A, B, D;(6)C, E, D (1)A;(2)C;(3)A,B;(4)C,E;(5)A,B,D;(6)C,E,D。
最后,基于这六个序列(例如,丢弃包括 “ < e o s > ” “
我们选择其中条件概率乘积最高的序列作为输出序列:
其中L是最终候选序列的长度,α通常设置为0.75。因为一个较长的序列在 (9.8.4) 的求和中会有更多的对数项,因此分母中的 L α L^α Lα用于惩罚长序列。束搜索的计算量为 O ( k ∣ Y ∣ T ′ ) O(k |Y| T^′) O(k∣Y∣T′),这个结果介于贪心搜索和穷举搜索之间。实际上,贪心搜索可以看作一种束宽为1的特殊类型的束搜索。通过灵活地选择束宽,束搜索可以在正确率和计算代价之间进行权衡。
小结