这篇文章主要详细介绍的RNN的原理。由于看到CRNN这块,想着把RNN也好好看看,所以留下第五系列的坑,以后有时间再填吧。。。
目录
RNN(Recurrent Neural Network)是一类用于处理序列数据的神经网络。首先我们要明确什么是序列数据,摘取百度百科词条:时间序列数据是指在不同时间点上收集到的数据,这类数据反映了某一事物、现象等随时间的变化状态或程度。这是时间序列数据的定义,当然这里也可以不是时间,比如文字序列,但总归序列数据有一个特点——后面的数据跟前面的数据有关系。
1、基于Python的RNN实践
这篇文章是参考CS231n课程的RNN部分的代码实现的,主要的功能是对一张图片的内容进行描述,即翻译。如果想作相关作业,请转到相关github上,github地址:https://github.com/kingqiuol/cs231n-camp-1。这里主要是对RNN算法原理的介绍。可以忽略这一部分的实践,看看主要是做什么的就行。如果需要本文的代码,请看我的github地址:https://github.com/kingqiuol/RNN。
1.1、数据集下载
这里我们使用的是Microsoft coco数据集,这个数据集有80000张训练集和40000张验证集。使用./get_coco_captioning.sh脚本来下载数据。在终端输入以下命令:
./get_coco_captioning.sh
结果如下:
对于所有的图片,都是从VGG-16网络的第7层卷积层提取出来的特征,这些特征存储在train2014_vgg16_fc7.h5和val2014_vgg16_fc7.h5中。
为了节省处理的时间和内存要求,这些特征的维度得到了降低,从4096到512。这些特征存储在train_vgg16_fc7_pca.h5和val_vgg16_fc7_pca.h5中。
train2014_urls.txt 和 val2014_urls.txt 存储的是图片的链接,便于我们接下来的可视化图片和说明。
每个单词都被分配一个ID,这些ID被存储在 coco2014_vocab.json 文件中。
在词汇中我们增加了一些特殊的符号。<START> 和 <END> 分别表示说明的开始和结束;一些生僻词用 <UNK> 来代替。
因为我们想要训练不同长度的小批量数据,所以对于很短的说明我们在<END>后面增加了<NULL>,但是对于<NULL>符号不计算它的损失函数和梯度。
一些前提已经介绍完了,下面我们来看下我们的数据。
我们从数据集中选1个来展示,如下:
1.2、模型的训练与测试
在终端输入以下命令:
python rnn.py
训练过程如下:
训练曲线如下:
部分测试结果如下:
2、加载数据集
这部分主要参考别人的代码,具体实现可以查看我的github上的实现,这部分可以跳过,但要知道读取数据的shape。方便后续分析。加载数据的代码如下:
data = load_coco_data(pca_features=True)
这里需要说明的是我们直接读取的是经过VGG16后经过全链接层后的数据,所以我们得到的数据大小为:(batch,512,)。
3、RNN网络模型
我们知道一个标准的RNN网络如下图所示:
其中,h代表隐藏层,x代表输入,o代表输出。L,y分别代表损失函数和样本的标签。我们可以看到,“损失“也是随着序列的推荐而不断积累的。除上述特点之外,标准RNN的还有以下特点:
- 权值共享:在每一个时间序列中,其隐藏层、输入层和输出层的权值W、U、V都使用的同一权重
- 每一个输入值都只与它本身的那条路线建立权连接,不会和别的神经元连接。
3.1、前向传播
我们知道一个标准的RNN网络如下图所示:
前向传播算法其实非常简单,对于t时刻:
其中:为激活函数,一般该激活函数为tanh。b为偏置项。
为什么tanh比sigmoid好?
用tanh函数作为激活函数,那tanh函数的导数最大也才1啊,而且又不可能所有值都取到1,那相当于还是一堆小数在累乘,还是会出现“梯度消失“,那为什么还要用它做激活函数呢?原因是tanh函数相对于sigmoid函数来说梯度较大,收敛速度更快且引起梯度消失更慢。
还有一个原因是sigmoid函数还有一个缺点,Sigmoid函数输出不是零中心对称。sigmoid的输出均大于0,这就使得输出不是0均值,称为偏移现象,这将导致后一层的神经元将上一层输出的非0均值的信号作为输入。关于原点对称的输入和中心对称的输出,网络会收敛地更好。
参考链接:RNN到LSTM详解
t时刻的输出就更为简单:
最终模型的预测输出为:
其中σ为激活函数,通常RNN用于分类,故这里一般用softmax函数。
对于本文中的实现来说,对于t=0时刻的隐藏层h0的初始输入为提取输入图片的特征,即2中的data (batch,512,)。
对于任意时刻的输入,则是当前的输入单词对应的one_hot编码。具体我们来看代码。
def loss(self,feature,captions):
'''
返回loss和梯度(如果是训练过程)
:param feature:输入的特征,shape (N, D)
:param captions:Ground-truth; 数组,shape (N, T),N张图片都用T个单词描述
:return:
'''
# 输入的描述和输出的描述
# 具体在于输入去掉最后一个单词'<end>' 输出去掉第一个单词'<start>'
# 因为输入第一个单词的时候就已经需要预测下一个单词了 所以有一个单词的错位
captions_in=captions[:,:-1]
captions_out=captions[:,1:]
# 每个描述长度有所不同,短的用NULL补齐到T长度,所以NULL不计入loss
mask = (captions_out != self._null)
# 将输入特征转化为RNN输入的参数
W_proj, b_proj = self.params['W_proj'], self.params['b_proj']
# embedding矩阵
W_embed = self.params['W_embed']
# 输入到隐藏层矩阵, 隐藏到隐藏层矩阵, 偏置矩阵
Wx, Wh, b = self.params['Wx'], self.params['Wh'], self.params['b']
# 将输出转化为词向量的矩阵.
W_vocab, b_vocab = self.params['W_vocab'], self.params['b_vocab']
# 前向计算过程:
# (1)使用仿射函数将从CNN提取的特征转换到第一个RNN的隐层状态(N,H)
# (2)将captions_in从单词转换成向量(N,T,W)
# (3)在RNN各个隐层中进行运算,(N,T,H)
# (4)在每个时间点计算各个单词的得分(N,T,V)
# (5)用softmax计算各时间点的loss
# W表示单词向量的维度 V表示单词词库的个数
loss, grads = 0.0, {}
h0,affine_cache=affine_forward(feature,W_proj,b_proj) #[N,H]
x,embed_cache=word_embedding_forward(captions_in,W_embed)#标签的词嵌入
if self.cell_type=='rnn':
h,rnn_cache=rnn_forward(x,h0,Wx,Wh,b)
out,temp_affine_cache=temporal_affine_forward(h,W_vocab,b_vocab)
loss,dout=temporal_softmax_loss(out,captions_out,mask)
3.1.1、输入预处理
对于输入的描述句子,我们需要在其头、尾分别加入<start>、<end>特殊字符最为输入和结束的标志。当将描述语句作为输入时,我们只需要在前面加入<start>最为输入预测的开始,当我们对图片进行描述预测时,我们可以将<start>最为初始的输入。当作为输出的标签时,我们需要在尾部加入<end>作为结束标志,此时如果在预测时遇到<end>,我们可以将其作为预测的结束标志。
# 输入的描述和输出的描述
# 具体在于输入去掉最后一个单词'<end>' 输出去掉第一个单词'<start>'
# 因为输入第一个单词的时候就已经需要预测下一个单词了 所以有一个单词的错位
captions_in=captions[:,:-1]
captions_out=captions[:,1:]
3.1.2、词向量嵌入(word embeding)
词向量嵌入非常简单,就是生成一个W(T,H)的随机矩阵。其中T表示字典的大小,H表示隐藏单元的大小。比如我们输入一个单词为[0,1,...,0]one_hot的向量,大小为(1,T),通过h*W,最终我们得到在词向量矩阵中的第二行,是不是很好理解。为什么这样处理呢?
为什么要使用嵌入层 Embedding呢? 主要有这两大原因:
使用One-hot 方法编码的向量会很高维也很稀疏。假设我们在做自然语言处理(NLP)中遇到了一个包含2000个词的字典,当时用One-hot编码时,每一个词会被一个包含2000个整数的向量来表示,其中1999个数字是0,要是我的字典再大一点的话这种方法的计算效率岂不是大打折扣?
训练神经网络的过程中,每个嵌入的向量都会得到更新。如果你看到了博客上面的图片你就会发现在多维空间中词与词之间有多少相似性,这使我们能可视化的了解词语之间的关系,不仅仅是词语,任何能通过嵌入层 Embedding 转换成向量的内容都可以这样做。
参考链接: 深度学习中Embedding层有什么用?
这里我们通过随机生成一个正态分布的矩阵作为词向量矩阵
# 初始化代表每个单词的向量
self.params['W_embed']=np.random.randn(vocab_size,wordvec_dim)
self.params['W_embed']/=100
在前向传播的每一时刻t,通过一一映射来获取t时刻的输入,如下实现了输入的词向量嵌入。
def word_embedding_forward(x, W):
'''
词嵌入的前向传播。
:param x:
:param W:
:return:
'''
out,cache=None,None
out=W[x,:]
cache=(out,W.shape)
return out,cache
3.1.3、前向传播
我们知道,输入句子的长度T代表有T个时刻的序列,所以我们需要进行T次前向传播。这里,我们通过rnn_forward计算。具体实现如下:
def rnn_step_forward(x,prev_h,Wx,Wh,b):
'''
运行单个时间步长的RNN前向传播,使用tanh作为激活函数
:param x:当前时间的输入数据(N,D)
:param prev_h:上一时刻的影藏状态,(N,H)
:param Wx:输入到隐藏层的权重,(D,H)
:param Wh:隐藏层到隐藏层的权重,(H,H)
:param b:偏置,(H,)
:return:
- next_h: Next hidden state, of shape (N, H)
- cache: Tuple of values needed for the backward pass.
'''
next_h,cache=None,None
affine_h=np.dot(prev_h,Wh) #(N,H)
affine_x=np.dot(x,Wx)+b #(N,H)
new_h=affine_x+affine_h #(N,H)
next_h=np.tanh(new_h)
cache=(x,prev_h,Wx,Wh,next_h)
return next_h,cache
def rnn_forward(x,h0,Wx,Wh,b):
'''
在整个数据序列上运行RNN前向传播。
:param x:输入整个时间序列的数据 (N,T,D)
:param h0:初始隐藏状态 (N,H)
:param Wx:输入到隐藏层的权重,(D,H)
:param Wh:隐藏层到隐藏层的权重,(H,H)
:param b:偏置,(H,)
:return:
- h: Hidden states for the entire timeseries, of shape (N, T, H).
- cache: Values needed in the backward pass
'''
h,cache=None,None
N,T,D=x.shape
hiddens=[]
hidden=h0
for i in range(T):
xt=x[:,i,:]
hidden,_=rnn_step_forward(xt,hidden,Wx,Wh,b)
hiddens.append(hidden)
h=np.stack(hiddens,axis=1)
cache=(x,h0,Wh,Wx,b,h)
return h,cache
3.2、反向传播(BPTT)
BPTT(back-propagation through time)算法是常用的训练RNN的方法,其实本质还是BP算法,只不过RNN处理时间序列数据,所以要基于时间反向传播,故叫随时间反向传播。BPTT的中心思想和BP算法相同,沿着需要优化的参数的负梯度方向不断寻找更优的点直至收敛。综上所述,BPTT算法本质还是BP算法,BP算法本质还是梯度下降法,那么求各个参数的梯度便成了此算法的核心。
原文链接:https://blog.csdn.net/zhaojc1995/article/details/80572098
再次看图,从图中可知,我们需要更新的权重有W、U、V。其中W和U两个参数的寻优过程需要追溯之前的历史数据,参数V相对简单只需关注目前,那么我们就来先求解参数V的偏导数。
其中:N为数据的bitch size。该倒数为求softmax_loss的倒数。具体怎么求,在这里不详细描述,可以参考相关链接。
所以,
W和U的偏导的求解由于需要涉及到历史数据,其偏导求起来相对复杂,我们先假设只有三个时刻,那么在第三个时刻 L对W的偏导数为:
t=1时刻:
t=2时刻:
t=3时刻:
同理,在t=3时刻,L对U的偏导数为:
看到这,你可能已经知道怎么求导了,但是具体怎么计算呢?
从上述公式可知我们需要依次向前进行更新,也就是说我们先计算t时刻的梯度然后保存下来,将其与t-1时刻的梯度相加。这里需要注意,我们可以复用一些倒数,并依次迭代相乘。具体实现如下:
def rnn_backward(dh,cache):
'''
计算RNN网络的反向传播
:param dh: 上一层的梯度,(N,T,H)
:param cache:
:return:
- dx: Gradient of inputs, of shape (N, T, D)
- dh0: Gradient of initial hidden state, of shape (N, H)
- dWx: Gradient of input-to-hidden weights, of shape (D, H)
- dWh: Gradient of hidden-to-hidden weights, of shape (H, H)
- db: Gradient of biases, of shape (H,)
'''
dx, dh0, dWx, dWh, db = None, None, None, None, None
x, h0, Wh, Wx, b, h = cache
_,T,_=dh.shape
dx=np.zeros_like(x)
dWx=np.zeros_like(Wx)
dWh=np.zeros_like(Wh)
db = np.zeros_like(b)
dprev_h = 0
for i in range(T):
t=T-1-i
xt=x[:,t,:]
dht=dh[:,t,:]
if t>0:
prev_h=h[:,t-1,:]
else:
prev_h=h0
next_h=h[:,t,:]
dx[:,t,:],dprev_h,dwx, dwh, db_=rnn_step_backward(dht + dprev_h, (xt, prev_h, Wx, Wh, next_h))
dWx += dwx
dWh += dwh
db += db_
dh0 = dprev_h
return dx, dh0, dWx, dWh, db
def rnn_step_backward(dnext_h, cache):
"""
Backward pass for a single timestep of a vanilla RNN.
Inputs:
- dnext_h: Gradient of loss with respect to next hidden state, of shape (N, H)
- cache: Cache object from the forward pass
Returns a tuple of:
- dx: Gradients of input data, of shape (N, D)
- dprev_h: Gradients of previous hidden state, of shape (N, H)
- dWx: Gradients of input-to-hidden weights, of shape (D, H)
- dWh: Gradients of hidden-to-hidden weights, of shape (H, H)
- db: Gradients of bias vector, of shape (H,)
"""
dx, dprev_h, dWx, dWh, db = None, None, None, None, None
x, prev_h, Wx, Wh, next_h = cache
d_new_h = dnext_h * (1 - next_h ** 2) #tanh的导数
dWx = np.dot(x.T, d_new_h)
db = np.sum(d_new_h, axis=0)
dx = np.dot(d_new_h, Wx.T)
dWh = np.dot(prev_h.T, d_new_h)
dprev_h = np.dot(d_new_h, Wh.T)
return dx, dprev_h, dWx, dWh, db
4、模型优化
我们知道,模型最终的输出O为(N,M,T),其中N为输入数据的batch_size,M为字典的大小,T为有T个时间序列(输入单词的长度为T),而我们的标签y为(N,T)。这里我们使用softmax损失函数作为RNN模型的优化目标。其公式如下:
具体实现如下:
def temporal_softmax_loss(x, y, mask, verbose=False):
'''
在RNN中使用softmax_loss作为损失函数。
我们假设我们在一个大小为V的词汇表上,预测得到一个长度为T时间序列的输出。输入x是词汇表
在所有时间步长上的预测分数。y为每个时间步长上的真实标签,对于每一个时间预测采用交叉熵
作为每一步的损失,然后对这些损失求和取平均得到最终的损失。
:param x:输出分数,(N,T,V)
:param y:标签下标,(N,T)
:param mask:
:param verbose:
:return:
- loss: Scalar giving loss
- dx: Gradient of loss with respect to scores x.
'''
N,T,V=x.shape
x_flat=x.reshape(N*T,V)
y_flat=y.reshape(N*T)
mask_flat=mask.reshape(N*T)
probs=np.exp(x_flat-np.max(x_flat,axis=1,keepdims=True))
probs/=np.sum(probs,axis=1,keepdims=True)
loss=-np.sum(mask_flat*np.log(probs[np.arange(N*T),y_flat]))/N
dx_flat = probs.copy()
dx_flat[np.arange(N * T), y_flat] -= 1
dx_flat /= N
dx_flat *= mask_flat[:, None]
if verbose: print('dx_flat: ', dx_flat.shape)
dx = dx_flat.reshape(N, T, V)
return loss, dx
5、模型预测
在执行网络的前向传播时,将图像的特征作为隐藏层的初始输入,网络输入字符<start>当作第一个时刻的输入,可以得到第一个时刻的输出,把第一个时刻的输出当作第二个时刻的输入,依次类推。网络的结构如下:
具体实现如下:
def sample(self,features,max_length=30):
'''
执行网络的前向传播,通过输入特征获取输出字符
<start>当作第一个时刻的输入,可以得到第一个时刻的输出,
把第一个时刻的输出当作第二个时刻的输入,依次类推.
:param feature: 输入数据,(N,D)
:param max_length: 生成结果的最大长度
:return:
'''
N=features.shape[0]
captions = self._null * np.ones((N, max_length), dtype=np.int32)
# Unpack parameters
W_proj, b_proj = self.params['W_proj'], self.params['b_proj']
W_embed = self.params['W_embed']
Wx, Wh, b = self.params['Wx'], self.params['Wh'], self.params['b']
W_vocab, b_vocab = self.params['W_vocab'], self.params['b_vocab']
h, _ = affine_forward(features, W_proj, b_proj)
inputs, _ = word_embedding_forward(self._start, W_embed)
# inputs = np.tile(inputs, (2, 1))
c = np.zeros_like(h)
for i in range(max_length):
if self.cell_type == 'rnn':
next_h, _ = rnn_step_forward(inputs, h, Wx, Wh, b)
output, _ = temporal_affine_forward(next_h[:, np.newaxis, :], W_vocab, b_vocab)
output_idx = np.argmax(output, axis=2)
for j in range(output_idx.shape[0]):
captions[j][i] = output_idx[j]
inputs, _ = word_embedding_forward(output_idx[:, 0], W_embed)
h = next_h
return captions
参考链接: