循环神经网络Recurrent Neural Networks
介绍
时序预测问题
用前馈神经网络来做时序信号预测有什么问题?前馈网络是利用窗处理将不同时刻的向量并接成一个更大的向量,以此利用前后发生的事情预测当前所发生的情况。这样的方式受限于将多少个向量(window size)并接在一起。所能考虑的依赖始终是固定长度的。
前馈网络与递归网络对比
在前馈网络预测时序信号时,如下图左侧是时间维度展开前,右侧是展开后(单位时刻实际工作的只有灰色部分。)。前馈网络的特点使不同时刻的预测完全是独立的。我们只能通过窗处理的方式让其照顾到前后相关性。
其数学公式:
concat表示将向量并接成一个更大维度的向量,需要从大量的数据中学习Wxh和b,要学习各个时刻(3个)下所有维度m的关系(m*3个),就需要很多数据。
对于递归网络,不再有window size的概念,而是time step。左侧是时间维度展开前,回路方式的表达方式,其中黑方框表示时间延迟。右侧展开后,可以看到当前时刻的ht并不仅仅取决于当前时刻的输入xt,同时与上一时刻的ht-1也相关。其数学公式:
ht也由xt和Wzh的相乘得到,但也多了一份信息,即Whh*h(t-1),而该信息是从上一时刻的隐藏状态h(t-1)经过一个不同的Whh变换后得出的。Wxh的行:dim_input,Wxh的列:dim_hidden_state,而Whh是一个行列都为dim_hidden_state的方阵。前馈网络需要3个时刻来帮助学习一次Wxh,而递归网络可以用3个时刻来帮助学习3次Wxh和Whh。换句话说:所有时刻的权重矩阵都是共享的。这是递归网络相对于前馈网络而言最为突出的优势。
递归神经网络是在时间结构上存在共享特性的神经网络变体。时间结构共享是递归网络的核心中的核心。
递归网络特点
时序长短可变:只要知道上一时刻的隐藏状态ht-1与当前时刻的输入xt,就可以计算当前时刻的隐藏状态ht。并且由于计算所用到的Wxh与Whh在任意时刻都是共享的。递归网络可以处理任意长度的时间序列。
顾及时间依赖:若当前时刻是第5帧的时序信号,那计算当前的隐藏状态h5就需要当前的输入x5和第4帧的隐藏状态h4,而计算h4又需要h3,这样不断逆推到初始时刻为止。意味着常规递归网络对过去所有状态都存在着依赖关系。(在计算h0的值时,若没有特别指定初始隐藏状态,则会将ht-1全部补零)
未来信息依赖:前馈网络是通过并接未来时刻的向量来引入未来信息对当前内容判断的限制,但常规的递归网络只对所有过去状态存在依赖关系。所以递归网络的一个扩展就是双向(bidirectional)递归网络:两个不同方向的递归层叠加。
关系图:正向(forward)递归层是从最初时刻开始,而反向(backward)递归层是从最末时刻开始。
递归网络数据的输入输出
递归网络由于引入time step的缘故,使得其训练数据与前馈网络有所不同。
前馈网络:
输入矩阵形状:(n_samples, dim_input)
输出矩阵形状:(n_samples, dim_output)
注:真正测试/训练的时候,网络的输入和输出就是向量而已。加入n_samples这个维度是为了可以实现一次训练多个样本,求出平均梯度来更新权重,这个叫做Mini-batch gradient descent。如果n_samples等于1,那么这种更新方式叫做Stochastic Gradient Descent (SGD)。
递归网络:
输入和输出:维度至少是3的张量,如果是图片等信息,则张量维度仍会增加。
输入张量形状:(time_steps, n_samples, dim_input)
输出张量形状:(time_steps, n_samples, dim_output)
注:同样是保留了Mini-batch gradient descent的训练方式,但不同之处在于多了time step这个维度。Recurrent 的任意时刻的输入的本质还是单个向量,只不过是将不同时刻的向量按顺序输入网络。你可能更愿意理解为一串向量 a sequence of vectors,或者是矩阵。
递归网络问题
常规递归网络从理论上应该可以顾及所有过去时刻的依赖,然而实际却无法按人们所想象工作。原因在于梯度消失(vanishinggradient)和梯度爆炸(exploding gradient)问题。Long Short Term Memory(LSTM)和Gated Recurrent Unit(GRU)解决了以上 问题。
LSTM/GRU
RNN问题
最常用的RNN实现方式:Long-Short Term Memory(LSTM),为什么会有LSTM出现呢?
循环神经网络处理数据流程。
由上可以知道循环神经网络用相同的方式处理每个时刻的数据。我们希望循环神经网络可以将过去时刻发生的状态信息传递给当前时刻的计算中,但普通的RNN结构却难以传递相隔较远的信息。另一方面,将蓝色箭头的传递过程简化公式为:ht = Whh*h(t-1),若不考虑非线性的部分,隐藏信息的状态信息h0,向t时刻传递得到ht = (Whh)^t * h0,会发现Whh被乘了多次,这样,不断相乘导致的结果,导致要么整体数值趋于无穷(Whh的数值非常大),要么整体数值趋于0(Whh的数值趋于0),这时想要传递的h0中的信息会被掩盖掉,无法传递到ht。
上面情况类似公式:y= α^t *x,如果α等于0.1,x在被不断乘以0.1一百次后会变成非常小,如果α等于10,x在被不断乘以10一百次后会变得非常大,若想要所包含的信息既不消失,又不爆炸,就需要尽可能的将α的值保持在1。
Long Short Term Memory (LSTM)
上面的现象可能并不意味着无法学习,但是即便可以,也会非常非常的慢。为了有效的利用梯度下降法学习,我们希望使不断相乘的梯度的积(the product of derivatives)保持在接近1的数值。
一种实现方式是建立线性自连接单元(linear self-connections)和在自连接部分数值接近1的权重,叫做leaky units。但Leaky units的线性自连接权重是手动设置或设为参数,而目前最有效的方式gated RNNs是通过gates的调控,允许线性自连接的权重在每一步都可以自我变化调节。LSTM就是gated RNNs中的一个实现。
LSTM(或者其他gated RNNs)是在标准RNN的基础上装备了若干个控制数级(magnitude)的gates。可以理解成神经网络(RNN整体)中加入其他神经网络(gates),而这些gates只是控制数级,控制信息的流动量。
门(gate)的理解
理解门(gate)的定义和作用是理解Gated RNNs的首要步骤,gate本身可看成是十分有物理意义的一个神经网络,gate的输入是控制依据,gate的输出是值域为(0,1)的数值,表示该如何调节其他数据的数级的控制方式。gate所产生的输出会用于控制其他数据的数级,相当于过滤器的作用。
例如:当用gate来控制向量[20 5 7 8]时,若gate的输出为[0.1 0.2 0.9 0.5],原来的向量就会被对应元素相乘(element-wise)后变成:[20 5 7 8]⊙[0.1 0.2 0.9 0.5] = [2 1 6.3 4 ];若gate的输出为[0.5 0.5 0.5 0.5],时,原来的向量就会被对应元素相乘(element-wise)后变成:[20 5 7 8]⊙[0.5 0.5 0.5 0.5] = [10 2.5 3.5 4 ]。
gate的输入,究竟是以什么信息为控制依据呢,例如:g = sigmoid(Wxg · xt + Whg · h(t-1) +b ),这个gate的输入有当前的输入xt和上一时刻的状态h(t-1),表示gate是将这两个信息流作为控制依据而产生输出的。例如:g = sigmoid(Wxg · xt + Whg · h(t-1) + Wcg · c(t-1)+b ),这个gate的输入有当前的输入xt和上一时刻的状态h(t-1),还有上一时刻cell的状态c(t-1),表示gate是将这三个信息流作为控制依据而产生输出的。这种方式的LSTM叫做peephole connections。
理解LSTM的数学公式:
LSTM的数学公式如下:
前半部分的三个式子it、ft、ot,在LSTM中,是网络首先构建了3个gates来控制信息的流通量。虽然gates的式子构成方式一样,但是注意3个gates式子W和b的下角标并不相同。它们有各自的物理意义,在网络学习过程中会产生不同的权重。有了这3个gates后,接下来要考虑的就是如何用它们装备在普通的RNN上来控制信息流,而根据它们所用于控制信息流通的地点不同,它们又被分为不同的门。
输入门it:控制有多少信息可以流入memory cell(第四个式子ct)。
遗忘门ft:控制有多少上一时刻的memory cell中的信息可以累积到当前时刻的memory cell中。
输出门ot:控制有多少当前时刻的memory cell中的信息可以流入当前隐藏状态中ht。
注:gates并不提供额外信息,gates只是起到限制信息的量的作用。因为gates起到的是过滤器作用,所以所用的激活函数是sigmoid而不是tanh。
信息流
信息流的来源主要有三处:当前输入xt、上一时刻隐藏状态ht-1、上一时刻cell状态c(t-1)(c(t-1)是额外创造的,可线性自连接的单元leaky units)。
再来看LSTM是如何累积历史信息和计算隐藏状态h的。
ct = ft⊙c(t-1) + it⊙tanh(Wxc·xt +Whc·h(t-1)+bc)
new = tanh(Wxc·xt +Whc·h(t-1) + bc)
得到ct = ft⊙c(t-1) + it ⊙new
所以历史信息的累积是并不是靠隐藏状态h自身,而是依靠memory cell这个自连接来累积。 在累积时,靠遗忘门来限制上一时刻的memory cell的信息,并靠输入门来限制新信息。并且真的达到了leaky units的思想,memory cell的自连接是线性的累积。
计算隐藏状态:
ht = ot ⊙tanh(ct),当前隐藏状态ht是从ct计算得来的,因为ct是以线性的方式自我更新的,所以先将其加入带有非线性功能的tanh(ct)。 随后再靠输出门ot的过滤来得到当前隐藏状态ht。
普通RNN与LSTM的比较
LSTM最大的区别是比RNN多了三个神经网络(gates)来控制数据的流通。普通RNN:ht = tanh(Wxh · xt + Whh·h(t-1)+b),如下:
LSTM:ht = ot⊙tanh( ft⊙c(t-1) + it⊙tanh(Wxc·xt +Whc·h(t-1)+bc)),如下(加号圆圈表示线性相加,乘号圆圈表示用gate来过滤信息):
对比可得:二者的信息来源都是来源于tanh(Wxh · xt + Whh·h(t-1)+b),不同的是LSTM靠3个gates将信息的积累建立在线性自连接的memory cell之上,并靠其作为中间物来计算当前ht。
Gated RNNs的变种
标准的RNN的信息流有两处:input输入和hidden state隐藏状态。但往往信息流并非只有两处,即便是有两处,也可以拆分成多处,并通过明确多处信息流之间的结构关系来加入先验知识,减少训练所需数据量,从而提高网络效果。例如:Tree-LSTM在具有此种结构的自然语言处理任务中的应用。
gates的控制方式:与LSTM一样有名的是Gated Recurrent Unit (GRU),而GRU使用gate的方式就与LSTM的不同,GRU只用了两个gates,将LSTM中的输入门和遗忘门合并成了更新门。并且并不把线性自更新建立在额外的memory cell上,而是直接线性累积建立在隐藏状态上,并靠gates来调控。
实现LSTM
这一节就演示如何利用Tensorflow来搭建LSTM网络。为了更深刻的理解LSTM的结构,这次所用的并非是tensorflow自带的rnn_cell类,而是从新编写,并且用scan来实现graph里的loop (动态RNN)。
本次任务:用声音来预测口腔移动,同时拿前馈神经网络与循环神经网络进行比较。
处理训练数据
目的:减掉每句数据的平均值,除以每句数据的标准差,降低模型拟合难度。
代码:
# 所需库包
import tensorflow as tf
import numpy as np
import time
import matplotlib.pyplot as plt
%matplotlib inline
# 直接使用在代码演示LV3中定义的function
def Standardize(seq):
#subtract mean
centerized=seq-np.mean(seq, axis = 0)
#divide standard deviation normalized=centerized/np.std(centerized, axis = 0)
return normalized
# 读取输入和输出数据
mfc=np.load('X.npy')
art=np.load('Y.npy')
totalsamples=len(mfc)
# 20%的数据作为validation set
vali_size=0.2
# 将每个样本的输入和输出数据合成list,再将所有的样本合成list
# 其中输入数据的形状是[n_samples, n_steps, D_input]
# 其中输出数据的形状是[n_samples, D_output]
def data_prer(X, Y):
D_input=X[0].shape[1]
data=[]
for x,y in zip(X,Y):
data.append([Standardize(x).reshape((1,-1,D_input)).astype("float32"), Standardize(y).astype("float32")])
return data
# 处理数据
data=data_prer(mfc, art)
# 分训练集与验证集
train=data[int(totalsamples*vali_size):]
test=data[:int(totalsamples*vali_size)]
1,2,3,4,5表示list中的每个元素,而每个元素又是一个长度为2的list。
解释:比如全部数据有100个序列,如果设定每个input的形状就是[1, n_steps, D_input],那么处理后的list的长度就是100,这样的数据使用的是SGD的更新方式。而如果想要使用mini-batch GD,将batch size(也就是n_samples)的个数为2,那么处理后的list的长度就会是50,每次网络训练时就会同时计算2个样本的梯度并用均值来更新权重。 因为每句语音数据的时间长短都不相同,如果使用3维tensor,需要大量的zero padding,所以将n_samples设成1。但是这样处理的缺点是:只能使用SGD,无法使用mini-batch GD。如果想使用mini-batch GD,需要几个n_steps长度相同的样本并在一起形成3维tensor(不等长时需要zero padding,如下图)。v表示一个维度为39的向量,序列1的n_steps的长度为3,序列2的为7,如果想把这三个序列并成3维tensor,就需要选择最大的长度作为n_steps的长度,将不足该长度的序列补零(都是0的39维的向量)。最后会形成shape为[3,7,39]的一个3维tensor。
权重初始化方法
目的:合理的初始化权重,可以降低网络在学习时卡在鞍点或极小值的损害,增加学习速度和效果
代码:
def weight_init(shape):
initial = tf.random_uniform(shape,minval=-np.sqrt(5)*np.sqrt(1.0/shape[0]), maxval=np.sqrt(5)*np.sqrt(1.0/shape[0]))
return tf.Variable(initial,trainable=True)
# 全部初始化成0
def zero_init(shape):
initial = tf.Variable(tf.zeros(shape)) return tf.Variable(initial,trainable=True)
# 正交矩阵初始化
def orthogonal_initializer(shape,scale = 1.0):
#https://github.com/Lasagne/Lasagne/blob/master/lasagne/init.py
scale = 1.0
flat_shape = (shape[0], np.prod(shape[1:])) a = np.random.normal(0.0, 1.0, flat_shape) u, _, v = np.linalg.svd(a, full_matrices=False)
q = u if u.shape == flat_shape else v
q = q.reshape(shape)#this needs to be corrected to float32
return tf.Variable(scale * q[:shape[0], :shape[1]],trainable=True, dtype=tf.float32)
def bias_init(shape):
initial = tf.constant(0.01, shape=shape) return tf.Variable(initial)
# 洗牌
def shufflelists(data):
ri=np.random.permutation(len(data))
data=[data[i] for i in ri]
return data
解释:其中shufflelists是用于洗牌重新排序list的。正交矩阵初始化是有利于gated_rnn的学习的方法。
定义LSTM类
属性:使用class类来定义是因为LSTM中有大量的参数,定义成属性方便管理。
代码:在init中就将所有需要学习的权重全部定义成属性
class LSTMcell(object):
def __init__(self, incoming, D_input, D_cell, initializer, f_bias=1.0):
# var
# incoming是用来接收输入数据的,其形状为[n_samples, n_steps, D_input]
self.incoming = incoming
# 输入的维度
self.D_input = D_input
# LSTM的hidden state的维度,同时也是memory cell的维度
self.D_cell = D_cell
# parameters
# 输入门的 三个参数
# igate = W_xi.* x + W_hi.* h + b_i
self.W_xi = initializer([self.D_input, self.D_cell])
self.W_hi = initializer([self.D_cell, self.D_cell])
self.b_i = tf.Variable(tf.zeros([self.D_cell]))
# 遗忘门的 三个参数
# fgate = W_xf.* x + W_hf.* h + b_f
self.W_xf = initializer([self.D_input, self.D_cell])
self.W_hf = initializer([self.D_cell, self.D_cell])
self.b_f = tf.Variable(tf.constant(f_bias, shape=[self.D_cell]))
# 输出门的 三个参数
# ogate = W_xo.* x + W_ho.* h + b_o
self.W_xo = initializer([self.D_input, self.D_cell])
self.W_ho = initializer([self.D_cell, self.D_cell])
self.b_o = tf.Variable(tf.zeros([self.D_cell]))
# 计算新信息的三个参数
# cell = W_xc.* x + W_hc.* h + b_c
self.W_xc = initializer([self.D_input, self.D_cell])
self.W_hc = initializer([self.D_cell, self.D_cell])
self.b_c = tf.Variable(tf.zeros([self.D_cell]))
# 最初时的hidden state和memory cell的值,二者的形状都是[n_samples, D_cell]
# 如果没有特殊指定,这里直接设成全部为0
init_for_both = tf.matmul(self.incoming[:,0,:], tf.zeros([self.D_input, self.D_cell]))
self.hid_init = init_for_both
self.cell_init = init_for_both
# 所以要将hidden state和memory并在一起。
self.previous_h_c_tuple = tf.stack([self.hid_init, self.cell_init])
# 需要将数据由[n_samples, n_steps, D_cell]的形状变成[n_steps, n_samples, D_cell]的形状
self.incoming = tf.transpose(self.incoming, perm=[1,0,2])
解释:将hidden state和memory并在一起,以及将输入的形状变成[n_steps, n_samples, D_cell]是为了满足tensorflow中的scan的特点,后面会提到。每步计算方法:定义一个function,用于制定每一个step的计算。代码:
def one_step(self, previous_h_c_tuple, current_x):
# 再将hidden state和memory cell拆分开
prev_h, prev_c = tf.unstack(previous_h_c_tuple)
# 这时,current_x是当前的输入,
# prev_h是上一个时刻的hidden state
# prev_c是上一个时刻的memory cell
# 计算输入门
i = tf.sigmoid( tf.matmul(current_x, self.W_xi) + tf.matmul(prev_h, self.W_hi) + self.b_i)
# 计算遗忘门
f = tf.sigmoid(tf.matmul(current_x, self.W_xf) + tf.matmul(prev_h, self.W_hf) + self.b_f)
# 计算输出门
o = tf.sigmoid(tf.matmul(current_x, self.W_xo) + tf.matmul(prev_h, self.W_ho) + self.b_o)
# 计算新的数据来源
c = tf.tanh(tf.matmul(current_x, self.W_xc) +tf.matmul(prev_h, self.W_hc) + self.b_c)
# 计算当前时刻的memory cell
current_c = f*prev_c + i*c
# 计算当前时刻的hidden state
current_h = o*tf.tanh(current_c)
# 再次将当前的hidden state和memory cell并在一起返回
return tf.stack([current_h, current_c])
解释:将上一时刻的hidden state和memory拆开,用于计算后,所出现的新的当前时刻的hidden state和memory会再次并在一起作为该function的返回值,同样是为了满足scan的特点。定义该function后,LSTM就已经完成了。one_step方法会使用LSTM类中所定义的parameters与当前时刻的输入和上一时刻的hidden state与memory cell计算当前时刻的hidden state和memory cell。scan:使用scan逐次迭代计算所有timesteps,最后得出所有的hidden states进行后续的处理。
代码:
def all_steps(self):
# 输出形状 : [n_steps, n_sample, D_cell]
hstates = tf.scan(fn = self.one_step,
elems = self.incoming, #形状为[n_steps, n_sample, D_input]
initializer = self.previous_h_c_tuple,
name = 'hstates')[:,0,:,:]
return hstates
解释:scan接受的fn, elems, initializer有以下要求:
fn:第一个输入是上一时刻的输出(需要与fn的返回值保持一致),第二个输入是当前时刻的输入。
elems:scan方法每一步都会沿着所要处理的tensor的第一个维进行一次一次取值,所以要将数据由[n_samples, n_steps, D_cell]的形状变成[n_steps, n_samples, D_cell]的形状。
initializer:初始值,需要与fn的第一个输入和返回值保持一致。
scan的返回值在上例中是[n_steps, 2, n_samples, D_cell],其中第二个维度的2是由hidden state和memory cell组成的。
构建网络
D_input = 39D_label = 24
learning_rate = 7e-5
num_units=1024
# 样本的输入和标签
inputs = tf.placeholder(tf.float32, [None, None, D_input],
name="inputs")
labels = tf.placeholder(tf.float32, [None, D_label],
name="labels")
# 实例LSTM类
rnn_cell = LSTMcell(inputs, D_input, num_units, orthogonal_initializer)
# 调用scan计算所有hidden states
rnn0 = rnn_cell.all_steps()
# 将3维tensor [n_steps, n_samples, D_cell]转成 矩阵[n_steps*n_samples, D_cell]
# 用于计算outputs
rnn = tf.reshape(rnn0, [-1, num_units])
# 输出层的学习参数
W = weight_init([num_units, D_label])
b = bias_init([D_label])
output = tf.matmul(rnn, W) + b
# 损失
loss=tf.reduce_mean((output-labels)**2)
# 训练所需
train_step = tf.train.AdamOptimizer(learning_rate).minimize(loss)
解释:以hard coding的方式直接构建一个网络,输入是39维,第一个隐藏层也就是RNN-LSTM,1024维,而输出层又将1024维的LSTM的输出变换到24维与label对应。
注: 这个网络并不仅仅取序列的最后一个值,而是要用所有timestep的值与实际轨迹进行比较计算loss
训练网络
# 建立session并实际初始化所有参数
sess = tf.InteractiveSession()
tf.global_variables_initializer().run()
# 训练并记录
def train_epoch(EPOCH):
for k in range(EPOCH):
train0=shufflelists(train)
for i in range(len(train)):
sess.run(train_step,feed_dict={inputs:train0[i][0],labels:train0[i][1]})
tl=0
dl=0
for i in range(len(test)):
dl+=sess.run(loss,feed_dict={inputs:test[i][0],labels:test[i][1]})
for i in range(len(train)):
tl+=sess.run(loss,feed_dict={inputs:train[i][0],labels:train[i][1]})
print(k,'train:',round(tl/83,3),'test:',round(dl/20,3))
t0 = time.time()
train_epoch(10)
t1 = time.time()
print(" %f seconds" % round((t1 - t0),2))
# 训练10次后的输出和时间
(0, 'train:', 0.662, 'test:', 0.691)
(1, 'train:', 0.558, 'test:', 0.614)
(2, 'train:', 0.473, 'test:', 0.557)
(3, 'train:', 0.417, 'test:', 0.53)
(4, 'train:', 0.361, 'test:', 0.504)
(5, 'train:', 0.327, 'test:', 0.494)
(6, 'train:', 0.294, 'test:', 0.476)
(7, 'train:', 0.269, 'test:', 0.468)
(8, 'train:', 0.244, 'test:', 0.452)
(9, 'train:', 0.226, 'test:', 0.453)
563.110000 seconds
解释:由于上文的LSTM是非常直接的编写方式,并不高效,在实际使用中会花费较长时间。
预测效果
代码
(0, 'train:', 0.662, 'test:', 0.691)
pY=sess.run(output,feed_dict={inputs:test[10][0]})
plt.plot(pY[:,8])
plt.plot(test[10][1][:,8])
plt.title('test')
plt.legend(['predicted','real'])
解释:plot出一个样本中的维度的预测效果与真是轨迹进行对比
总结
该文是尽可能只展示LSTM最核心的部分(只训练了10次,有兴趣的朋友可以自己多训练几次),帮助理解其工作方式而已。
双向LSTM&GRU
以下主要是关于双向LSTM和GRU用scan的方式实现,与上面实现的LSTM的不同在于:·
- 简单梳理调整了代码结构,方便使用
- 将所有gate的计算并在一个大矩阵乘法下完成提高GPU的利用率
- 除了LSTM(Long-Short Term Memory)以外的cell,提供了GRU(gate recurrent unit)
cell模块 - 双向RNN(可选择任意cell组合)
- 该代码可被用于练习结构改造或实际建模任务
定义LSTMcell类
目的:
LSTMcell包含所有学习所需要的parameters以及每一时刻所要运行的step方法代码:
定义GRUcell类
定义RNN函数
目的:
用于接受cell的实例,并用scan计算所有time steps的hidden states代码:
训练
未完。。。
参考:https://www.zhihu.com/people/YJango/posts?page=3