参考资料:
参考博客:
1. 前言
在使用深度学习处理时序问题时,RNN是最常使用的模型之一。RNN之所以在时序数据上有着优异的表现是因为RNN在 t t t 时间片时会将 t − 1 t−1 t−1 时间片的隐节点作为当前时间片的输入,也就是RNN具有图1的结构。这样有效的原因是之前时间片的信息也用于计算当前时间片的内容,而传统模型的隐节点的输出只取决于当前时间片的输入特征。
RNN的数学表达式可以表示为:
h
t
=
σ
(
x
t
×
w
x
t
+
h
t
−
1
×
w
h
t
+
b
)
h_t=σ(x_t×w_{xt}+h_{t−1}×w_{ht}+b)
ht=σ(xt×wxt+ht−1×wht+b)
而传统的DNN的隐节点表示为:
h
t
=
σ
(
x
t
×
w
x
t
+
b
)
h_t=σ(x_t×w_{xt}+b)
ht=σ(xt×wxt+b)
对比RNN和DNN的隐节点的计算方式,我们发现唯一不同之处在于RNN将上个时间片的隐节点状态
h
t
−
1
h_t−1
ht−1 也作为了神经网络单元的输入,这也是RNN擅长处理时序数据最重要的原因。
所以,RNN的隐节点 h t − 1 h_t−1 ht−1 有两个作用:
- 计算在该时刻的预测值 y t : y t = σ ( h t ∗ w + b ) y^t: y^t=σ(h_t∗w+b) yt:yt=σ(ht∗w+b)
- 计算下个时间片的隐节点状态 h t h_t ht
RNN的该特性也使RNN在很多学术和工业前景,例如OCR,语音识别,股票预测等领域上有了十足的进展。
(1)长期依赖(Long Term Dependencies)
在深度学习领域中(尤其是RNN),“长期依赖“问题是普遍存在的。长期依赖产生的原因是当神经网络的节点经过许多阶段的计算后,之前比较长的时间片的特征已经被覆盖,例如下面例子:
eg1: The cat, which already ate a bunch of food, was full. | | | | | | | | | | | t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 eg2: The cats, which already ate a bunch of food, were full. | | | | | | | | | | | t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 t10
我们想预测’full’之前系动词的单复数情况,显然full是取决于第二个单词’cat‘的单复数情况,而非其前面的单词food。根据图1展示的RNN的结构,随着数据时间片的增加,RNN丧失了学习连接如此远的信息的能力(图2)。
(2)梯度消失/爆炸
梯度消失和梯度爆炸是困扰RNN模型训练的关键原因之一,产生梯度消失和梯度爆炸是由于RNN的权值矩阵循环相乘导致的,相同函数的多次组合会导致极端的非线性行为。梯度消失和梯度爆炸主要存在RNN中,因为RNN中每个时间片使用相同的权值矩阵。对于一个DNN,虽然也涉及多个矩阵的相乘,但是通过精心设计权值的比例可以避免梯度消失和梯度爆炸的问题。
处理梯度爆炸可以采用梯度截断的方法。所谓梯度截断是指将梯度值超过阈值 θ θ θ 的梯度手动降到 θ θ θ 。虽然梯度截断会一定程度上改变梯度的方向,但梯度截断的方向依旧是朝向损失函数减小的方向。
对比梯度爆炸,梯度消失不能简单的通过类似梯度截断的阈值式方法来解决,因为长期依赖的现象也会产生很小的梯度。在上面例子中,我们希望 t 9 t9 t9 时刻能够读到 t 1 t1 t1 时刻的特征,在这期间内我们自然不希望隐层节点状态发生很大的变化,所以 [ t 2 , t 8 ] [t2,t8] [t2,t8] 时刻的梯度要尽可能的小才能保证梯度变化小。很明显,如果我们刻意提高小梯度的值将会使模型失去捕捉长期依赖的能力。
2. LSTM网络结构
LSTM的全称是Long Short Term Memory,顾名思义,它具有记忆长短期信息的能力的神经网络。LSTM提出的动机是为了解决上面我们提到的长期依赖问题。传统的RNN节点输出仅由权值,偏置以及激活函数决定(图3)。RNN是一个链式结构,每个时间片使用的是相同的参数。
而LSTM之所以能够解决RNN的长期依赖问题,是因为LSTM引入了门(gate
)机制用于控制特征的流通和损失。对于上面的例子,LSTM可以做到在
t
9
t_9
t9 时刻将
t
2
t_2
t2 时刻的特征传过来,这样就可以非常有效的判断
t
9
t_9
t9 时刻使用单数还是复数了。LSTM是由一系列LSTM单元(LSTM Unit)组成,其链式结构如下图:
- 每个黄色方框表示一个神经网络层,由权值,偏置以及激活函数组成;
- 每个粉色圆圈表示元素级别操作;
- 箭头表示向量流向;
- 相交的箭头表示向量的拼接;
- 分叉的箭头表示向量的复制。
2.1 LSTM的前向计算
- 长期记忆单元 c t c_t ct;
- 短期记忆单元 h t h_t ht;
- 候选记忆单元 c t ~ \tilde{c_t} ct~;
- 遗忘门 f t f_t ft;
- 输入门 i t i_t it;
- 输出门 o t o_t ot;
在 t t t 时刻,LSTM的输入有三个:
- 当前时刻网络的输入值 x t x_t xt ;
- 上一时刻的短期记忆 h t − 1 h_{t-1} ht−1 ;
- 上一时刻的长期记忆 c t − 1 c_{t-1} ct−1 ;
LSTM的输出有两个:
- 当前时刻LSTM的短期记忆单元 h t h_t ht ;
- 当前时刻的长期记忆单元 c t c_t ct;
LSTM的关键,就是怎样控制长期状态c。在这里,LSTM的思路是使用三个控制开关:
- 第一个开关,负责控制继续保存长期状态 c t c_t ct ;
- 第二个开关,负责控制把即时状态输入到长期状态 c t c_t ct ;
- 第三个开关,负责控制是否把长期状态 c t c_t ct 作为当前的LSTM的输出。
(1)门
前文描述的开关在算法实现中使用门(gate)。门实际上是一层全连接层,它的输入是一个向量,输出是一个 [ 0 , 1 ] [0,1] [0,1] 的实数向量(一般使用sigmoid函数)。
假设W是门的权重向量,b是偏置项,那么门可以表示为:
g ( x ) = σ ( W x + b ) g(x)=σ(Wx+b) g(x)=σ(Wx+b)
门的使用,就是用门的输出向量按元素乘以我们需要控制的那个向量。
因为门的输出是0到1之间的实数向量,那么,当门输出为0时,任何向量与之相乘都会得到0向量,这就相当于啥都不能通过;输出为1时,任何向量与之相乘都不会有任何改变,这就相当于啥都可以通过。因为 σ \sigma σ 的值域是(0,1),所以门的状态都是半开半闭的。
LSTM用了三个门:
遗忘门(forget gate)
,决定上一时刻的长期状态 c t − 1 c_{t-1} ct−1 有多少保留到当前时刻长期状态 c t c_t ct ;输入门(input gate)
,决定了当前时刻网络的输入 x t x_t xt 有多少保存到长期状态 c t c_t ct 。输出门(output gate)
,控制长期状态 c t c_t ct 有多少输出到LSTM的当前输出值 h t h_t ht 。
(2)遗忘门
在我们LSTM中的第一步是决定我们会从长期状态中丢弃什么信息。这个决定通过一个称为“遗忘门”的结构完成。该遗忘门会读取上一个输出 h t − 1 h_t-1 ht−1和当前输入 x t x_t xt,做一个sigmoid的非线性映射,然后输出一个向量 f t f_t ft (sigmoid函数取值为(0,1)区间,所以该向量的每一个维度都在(0,1)之间,1表示完全保留,0表示完全舍弃,相当于记住了重要的,忘记了无关紧要的),最后与上一时刻的细胞状态 c t − 1 c_{t-1} ct−1 相乘。
事实上,权重矩阵都是两个矩阵拼接而成的:一个是 W f h W_{fh} Wfh ,它对应着输入项 h t − 1 h_{t-1} ht−1,其维度为 d c × d h d_c \times d_h dc×dh;一个是 W f x W_{fx} Wfx ,它对应着输入项 x t x_t xt,其维度为 d c × d x d_c \times d_x dc×dx 。 W f W_f Wf 可以写为:
上式中, W f W_f Wf 是遗忘门的权重矩阵, [ h t − 1 , x t ] [h_{t-1},x_t] [ht−1,xt] 表示把两个向量连接成一个更长的向量, b f b_f bf 是遗忘门的偏置项, σ \sigma σ 是sigmoid函数。如果输入的维度是 d x d_x dx ,隐藏层的维度是 d h d_h dh ,单元状态的维度是 d c d_c dc (通常 d h = d c d_h=d_c dh=dc ),则遗忘门的权重矩阵维度是 d c × ( d h + d x ) d_c \times (d_h+d_x) dc×(dh+dx) 。
(3)输入门
下一步是确定什么样的新信息被存放在长期状态 c t c_{t} ct 中。这里包含两个部分:
- sigmoid层称“输入门层”决定什么值我们将要更新;
- tanh层创建一个新的候选值向量 c t ~ \tilde{c_t} ct~ ,会被加入到长期状态中;
(4)更新长期状态 c t c_t ct
- 我们把上一时刻的长期状态 c t − 1 c_{t-1} ct−1与遗忘门的输出 f t f_t ft 相乘,丢弃掉不需要的信息;
- 将候选状态 c t ~ \tilde{c_t} ct~ 与输入门的输出 i t i_t it 相乘,用于保留当前重要的信息 ;
- 最后把二者加和;
由于遗忘门的控制,它可以保存很久很久之前的信息,由于输入门的控制,它又可以避免当前无关紧要的内容进入记忆。
(5)输出门 o t o_t ot
输出门
o
t
o_t
ot 控制当前时刻的长期状态
c
t
c_t
ct 有多少信息需要输出给短期状态
h
t
h_t
ht 。
2.2 相关问题总结
(1)LSTM实现长短期记忆的原理是什么?
与传统的循环神经网络相比,LSTM在 h t − 1 h_{t-1} ht−1, x t x_t xt 的基础上加入了一个cell state (即长时记忆状态 c t c_{t} ct ) 来计算 h t h_t ht ,并且对网络模型内部进行了更加精心的设计。
- 遗忘门神经元 f t f_t ft 控制前一步记忆单元中信息有多大程度上被遗忘掉;
- 输入门神经元 i t i_t it 控制当前记忆中的信息以多大程度更新到记忆单元中;
- 输出门神经元 o t o_t ot 控制当前的 h t h_t ht (即短时记忆单元)的输出有多大程度取决于当前的长时记忆单元。
①当输入的序列没有重要信息时,LSTM遗忘门的值接近于1,输入门的值接近于0,此时过去的信息将会保留,从而实现了长时记忆的功能;
②当输入的序列中出现了重要信息时,LSTM应当将其存入记忆中,此时其输入门的值会接近于1;
③当输入的序列中出现了重要信息,且该信息意味着之前的记忆不再重要时,则输入门的值会接近于1,而遗忘门的值会接近于0,这样旧的记忆就会遗忘,新的重要信息被记忆。
经过这样的设计,整个网络更容易学习到序列之间的长期依赖。
(2)LSTM的原理公式是什么?
(3)LSTM为什么选择 s i g m o i d sigmoid sigmoid 作为遗忘门、输入门以及输出门神经元的激活函数,又为什么选择 tanh \tanh tanh 作为记忆门神经元的激活函数?
我们可以注意到,无论是 s i g m o i d sigmoid sigmoid 还是 tanh \tanh tanh ,它们均属于饱和函数,也就是说当输入达到一定值的情况下,输出就不会明显变化了。比如,当输入小于一定值时, s i g m o i d sigmoid sigmoid 输出几乎接近于0, tanh \tanh tanh 输出几乎接近于-1;当输入大于一定值时, s i g m o i d sigmoid sigmoid , tanh \tanh tanh 输出均接近于1。
- (1)如果使用非饱和的激活函数,例如 R e L U ReLU ReLU ,我们很难实现门控效果。 s i g m o i d sigmoid sigmoid 的函数输出在0~1之间,且输入较大或较小时,其输出会非常接近1或0,从而保证该门是开或关。故选择 s i g m o i d sigmoid sigmoid 作为遗忘门、输入门以及输出门神经元的激活函数最合适不过。
- (2) tanh \tanh tanh 函数的输出在-1~1之间,并且中心为0,这与大多数场景下特征分布是0中心吻合,并且, tanh \tanh tanh 在输入为0附近的时相比 s i g m o i d sigmoid sigmoid 函数有更大的梯度,通常使模型收敛更快。
2.3 LSTM的参数学习
LSTM的训练算法仍然是反向传播算法,具体参考:长短时记忆网络(LSTM)
3. Pytorch实现
参考资料:
首先根据Pytorch中的LSTM实现,对其输入输出和参数进行分析:
class torch.nn.LSTM(*args, **kwargs)
(1)参数列表:
-
input_size:x的特征维度;
-
hidden_size:隐藏层的特征维度;
-
num_layers:LSTM隐层的层数,默认为1;
-
bias:False则bih=0和bhh=0。默认为True;
-
batch_first:如果为True,则输入输出的数据格式为 (batch, seq, feature);
-
dropout:除最后一层,每一层的输出都进行dropout,默认为: 0;
-
bidirectional:True则为双向LSTM默认为False;
-
输入:input,( h 0 h_0 h0, c 0 c_0 c0);
-
输出:output,( h n , c n h_n,c_n hn,cn);
(2)输入数据格式:
- input(seq_len, batch, input_size)
- h 0 h_0 h0(num_layers * num_directions, batch, hidden_size),初始化隐状态
- c 0 c_0 c0(num_layers * num_directions, batch, hidden_size),初始化细胞状态
(3)输出数据格式:
-
output(seq_len, batch, hidden_size * num_directions)
-
h n h_n hn(num_layers * num_directions, batch, hidden_size),最后一个时间步的隐状态
-
c n c_n cn(num_layers * num_directions, batch, hidden_size),最后一个时间步的细胞状态
3.1 单向LSTM
输入数据(以batch_first=True,单层单向为例):
- 输入维度 = 28
- time_steps= 3
- batch_first = True
- batch_size = 10
- hidden_size =4
- num_layers = 1
- bidirectional = False
(1)h_init:维度形状为 (num_layers * num_directions, batch, hidden_size):
- 第一个参数的含义num_layers * num_directions, 即LSTM的层数乘以方向数量。这个方向数量是由前面介绍的bidirectional决定,如果为False,则等于1;反之等于2(可以结合下图理解num_layers * num_directions的含义)。
- batch:批数据量大小
- hidden_size: 隐藏层节点数
(2)c_init:维度形状也为(num_layers * num_directions, batch,hidden_size),各参数含义与h_init相同。因为本质上,h_init与c_init只是在不同时刻的不同表达而已。
如果没有传入,h_init和c_init,根据源代码来看,这两个参数会默认为0。
import torch
from torch.autograd import Variable
from torch import nn
input_size = 28 # 输入维度
batch_size = 10 # 批量大小
seq_len = 3 # 序列长度[x1~xn]
hidden_size = 4 # 隐藏层维度
# 单向单层LSTM网络
lstm = nn.LSTM(input_size, hidden_size, num_layers=1,batch_first=True) # 构建LSTM网络
# 构建输入(batch,seqlen,feature)->(10,3,28)
lstm_input = Variable(torch.randn(batch_size, seq_len, input_size))
h_init = Variable(torch.randn(1, lstm_input.size(0), hidden_size)) # 构建h输入参数 -- 每个batch对应一个隐层
c_init = Variable(torch.randn(1, lstm_input.size(0), hidden_size)) # 构建c输出参数 -- 每个batch对应一个隐层
out, (h, c) = lstm(lstm_input, (h_init, c_init)) # 将输入数据和初始化隐层、记忆单元信息传入
print(lstm.weight_ih_l0.shape) # 对应的输入学习参数
print(lstm.weight_hh_l0.shape) # 对应的隐层学习参数
print(out.shape, h.shape, c.shape)
输出结果如下:
(1)lstm_seq.weight_ih_l0.shape的结果为:torch.Size([16, 28]),表示对应的输入到隐层的学习参数:(4*hidden_size, input_size)。
(2)lstm_seq.weight_hh_l0.shape的结果为:torch.Size([16, 4]),表示对应的隐层到隐层的学习参数:(4*hidden_size, num_directions * hidden_size)
(3)out.shape的输出结果:torch.Size([10,3, 4]),表示隐层到输出层学习参数,即(batch,time_steps, num_directions * hidden_size),维度和输入数据类似,会根据batch_first是否为True进行对应的输出结果,(如果代码中,batch_first=False,则out.shape的结果会变为:torch.Size([3, 10, 4])),
h.shape输出结果是: torch.Size([1, 10, 4]),表示隐层到输出层的参数,h_n:(num_layers * num_directions, batch, hidden_size),只会输出最后个time step的隐状态结果
c.shape的输出结果是: torch.Size([1, 10, 4]),表示隐层到输出层的参数,c_n :(num_layers * num_directions, batch, hidden_size),同样只会输出最后个time step的cell状态结果
3.2 双向LSTM
RNN和LSTM都只能依据之前时刻的时序信息来预测下一时刻的输出,但在有些问题中,当前时刻的输出不仅和之前的状态有关,还可能和未来的状态有关系。
比如预测一句话中缺失的单词不仅需要根据前文来判断,还需要考虑它后面的内容,真正做到基于上下文判断。即:对于每个时刻t,输入会同时提供给两个方向相反的RNN,输出由这两个单向RNN共同决定。
因此提出了双向循环神经网络,网络结构如下图。可以看到Forward层和Backward层共同连接着输出层,其中包含了6个共享权值w1-w6。
在Forward层从1时刻到t时刻正向计算一遍,得到并保存每个时刻向前隐含层的输出。在Backward层沿着时刻t到时刻1反向计算一遍,得到并保存每个时刻向后隐含层的输出。最后在每个时刻结合Forward层和Backward层的相应时刻输出的结果得到最终的输出,用数学表达式如下:
输入数据(以batch_first=True,双层双向):
'''
batch_first = True : 输入形式:(batch, seq, feature)
bidirectional = True
num_layers = 2
'''
num_layers = 2 # 两层隐藏层
bidirectional_set = True # 使用双向LSTM
bidirectional = 2 if bidirectional_set else 1
input_size = 28 # 输入维度
hidden_size = 4 # 隐层维度
lstm_seq = nn.LSTM(input_size, hidden_size, num_layers=num_layers,
bidirectional=bidirectional_set,batch_first=True) # 构建LSTM网络
lstm_input = Variable(torch.randn(10, 3, 28)) # 构建输入
h_init = Variable(torch.randn(num_layers*bidirectional, lstm_input.size(0), hidden_size)) # 构建h输入参数
c_init = Variable(torch.randn(num_layers*bidirectional, lstm_input.size(0), hidden_size)) # 构建c输出参数
out, (h, c) = lstm_seq(lstm_input, (h_init, c_init)) # 计算
print(lstm_seq.weight_ih_l0.shape)
print(lstm_seq.weight_hh_l0.shape)
print(out.shape, h.shape, c.shape)
输出结果如下: