一文详解循环神经网络的基本概念(代码版)

543 篇文章 14 订阅

这里写图片描述

作者 | 李理

目前就职于环信,即时通讯云平台和全媒体智能客服平台,在环信从事智能客服和智能机器人相关工作,致力于用深度学习来提高智能机器人的性能。

写在前面

由于工作太忙,这个系列文章有一年多没有更新了。最近在整理资料时用到了里面的一些内容,觉得做事情应该有始有终,所以打算把它继续完成。下面的系列文章会首先会介绍 vanilla RNN 的代码,希望读者能够通过代码更加深入的了解RNN的原理。代码会着重于 forward 的介绍,而对 BPTT 一带而过。之前的文章为了让读者了解原理,我们都是自己来实现梯度的计算和各种优化算法。但是在实际的工作中,我们一般使用一些成熟的深度学习框架。因为框架把常用的算法都做了封装,我们的代码会更加简单而不易出错;此外框架的实现效率一般会比我们的更高,会利用 GPU 来加速训练。

我们之前在 CNN 的地方介绍了 theano,但是深度学习的发展变化也很快,theano目前已是一个死掉的项目。目前用户最多的深度学习框架是TensorFlow,但是在 RNN 方面,基于动态图的 PyTorch 更加方便,所以这个系列文章会使用 PyTorch。因此介绍过 vanilla RNN 之后会简单的介绍一下 PyTorch,尤其是 PyTorch 在 RNN 方面相关模块。然后会介绍一些 PyTorch 的例子,接下来会介绍 seq2seq(encoder-decoder) 模型和注意力机制,包括它在机器翻译里的应用,我们会自己实现一个简单的汉语-英语的翻译系统。

最后一部分就是我们的主题—— Image Caption Generation,有了前面 CNN 和 RNN 的基础,实现它就非常轻松了。

本章会介绍循环神经网络的基本概念。

基本概念

▌RNN

RNN 的特点是利用序列的信息。之前我们介绍的神经网络假设所有的输入是相互独立的。但是对于许多任务来说这不是一个好的假设。如果你想预测一个句子的下一个词,知道之前的词是有帮助的。RNN 被成为递归的(recurrent) 原因就是它会对一个序列的每一个元素执行同样的操作,并且之后的输出依赖于之前的计算。另外一种看待RNN 的方法是可以认为它有一些“记忆”能捕获之前计算过的一些信息。理论上RNN 能够利用任意长序列的信息,但是实际中它能记忆的长度是有限的。

图5.1显示了怎么把一个RNN 展开成一个完整的网络。比如我们考虑一个包含5个词的句子,我们可以把它展开成5 层的神经网络,每个词是一层。RNN 的计算公式如下:

  1. xt 是t 时刻的输入。
  2. st 是t 时刻的隐状态。

它是网络的“记忆”。st 的计算依赖于前一个时刻的状态和当前时刻的输入:st = f (Uxt +Wst1)。函数f 通常是诸如tanh 或者ReLU 的非线性函数。s1,这是用来计算第一个隐状态,通常我们可以初始化成0。

这里写图片描述
图5.1: RNN 展开图

3 . ot是 t 时刻的输出。

有一些事情值得注意:

1.你可以把st 看成是网络的“记忆”。

st 捕获了从开始到前一个时刻的所有(感兴趣) 的信息。输出ot 只基于当前时刻的记忆。不过实际应用中st 很难记住很久以前的信息。

2.参数共享

和传统的深度神经网络不同,这些传统的网络每层使用不同的参数,RNN 的参数(上文的U, V, W) 是在所有时刻共享(一样) 的。这反映这样一个事实:我们每一步都在执行同样的操作,只不过输入不同而已。这种结构极大的减少了我们需要学习的参数【同时也让信息得以共享,是的训练变得可能】

3.每一个时刻都有输出

上图每一个时刻都有输出,但我们不一定都要使用。比如我们预测一个句子的情感倾向是我们只关注最后的输出,而不是每一个词的情感。类似的,我们也不一定每个时刻都有输入。RNN 最主要的特点是它有隐状态(记忆),它能捕获一个序列的信息。

▌RNN 的扩展

1. 双向 RNN (Bidirectional RNNs)

它的思想是 t 时刻的输出不但依赖于之前的元素,而且还依赖之后的元素。比如,我们做完形填空,在句子中“挖”掉一个词,我们想预测这个词,我们不但会看之前的词,也会分析之后的词。双向 RNN 很简单,它就是两个 RNN 堆叠在一起。输出依赖两个 RNN 的隐状态。

2. 深度(双向) RNN (Deep (Bidirectional) RNNs)

和双向 RNN 类似,不过多加几层。当然它的表示能力更强,需要的训练数据也更多。

▌RNN 代码示例

接下来我们通过简单的代码来演示的 RNN,用 RNN 来实现一个简单的 Char RNN 语言模型。为了让读者了解 RNN 的一些细节,本示例会使用 numpy 来实现 forward 和backprop 的计算。RNN 的反向传播算法一般采用 BPTT,如果读者不太明白也不要紧,但是 forward 的计算一定要清楚。本章后续的内容会使用 PyTorch 来实现更复杂的seq2seq 模型、注意力机制来做机器翻译以及 Image Caption Generation。这个 RNN 代码来自 karpathy 的 blog 文章《The Unreasonable Effectiveness of Recurrent Neural Networks》附带的代码:

https://gist.github.com/karpathy/d4dee566867f8291f086

代码总共一百来行,我们下面逐段来阅读。

这里写图片描述

图5.2: 双向RNN

数据预处理

data = open('../data/tiny-shakespeare.txt', 'r').read() # should be simple
plain text file
chars = list(set(data))
data_size, vocab_size = len(data), len(chars)
print('data has %d characters, %d unique.' % (data_size, vocab_size))
char_to_ix = {ch: i for i, ch in enumerate(chars)}
ix_to_char = {i: ch for i, ch in enumerate(chars)}

上面的代码读取莎士比亚的文字到字符串 data 里,通过 set() 得到所有的字符并放到chars 这个 list 里。然后得到 char_to_ix 和 ix_to_char 两个 dict,分别表示字符到 id 的映射和 id 到字符的映射( id 从零开始)

模型超参数和参数定义

# 超参数
hidden_size = 100 # 隐藏层神经元的个数
seq_length = 25 # BPTT时最多的unroll的步数
learning_rate = 1e-1

这里写图片描述

图5.3: 多层双向RNN

模型参数

#模型参数
Wxh = np.random.randn(hidden_size, vocab_size) * 0.01 # 输入-隐藏层参数
Whh = np.random.randn(hidden_size, hidden_size) * 0.01 # 隐藏层-隐藏层参数
Why = np.random.randn(vocab_size, hidden_size) * 0.01 # 隐藏层-输出层参数
bh = np.zeros((hidden_size, 1)) # 隐藏层bias
by = np.zeros((vocab_size, 1)) # 输出层bias

上面的代码定义超参数 hidden_size,seq_length 和 learning_rate,以及模型的参数Wxh, Whh 和 Why。

lossFun

def lossFun(inputs, targets, hprev):
"""
inputs,targets都是整数的list
hprev是Hx1的数组,是隐状态的初始值
返回loss,梯度和最后一个时刻的隐状态
"""
xs, hs, ys, ps = {}, {}, {}, {}
hs[-1] = np.copy(hprev)
loss = 0
# forward pass
for t in xrange(len(inputs)):
xs[t] = np.zeros((vocab_size, 1)) # encode in 1-of-k representation
xs[t][inputs[t]] = 1
hs[t] = np.tanh(np.dot(Wxh, xs[t]) + np.dot(Whh, hs[t - 1]) + bh) #
hidden state
ys[t] = np.dot(Why, hs[t]) + by # unnormalized log probabilities for
next chars
ps[t] = np.exp(ys[t]) / np.sum(np.exp(ys[t])) # probabilities for next
chars
loss += -np.log(ps[t][targets[t], 0]) # softmax (cross-entropy loss)
# backward pass: compute gradients going backwards
dWxh, dWhh, dWhy = np.zeros_like(Wxh), np.zeros_like(Whh),
np.zeros_like(Why)
dbh, dby = np.zeros_like(bh), np.zeros_like(by)
dhnext = np.zeros_like(hs[0])
for t in reversed(xrange(len(inputs))):
dy = np.copy(ps[t])
dy[targets[t]] -= 1 # backprop into y. see
http://cs231n.github.io/neural-networks-case-study/#grad if
confused here
dWhy += np.dot(dy, hs[t].T)
dby += dy
dh = np.dot(Why.T, dy) + dhnext # backprop into h
dhraw = (1 - hs[t] * hs[t]) * dh # backprop through tanh nonlinearity
dbh += dhraw
dWxh += np.dot(dhraw, xs[t].T)
dWhh += np.dot(dhraw, hs[t - 1].T)
dhnext = np.dot(Whh.T, dhraw)
for dparam in [dWxh, dWhh, dWhy, dbh, dby]:
np.clip(dparam, -5, 5, out=dparam) # clip to mitigate exploding
gradients
return loss, dWxh, dWhh, dWhy, dbh, dby, hs[len(inputs) - 1]

我们这里只阅读一下 forward 的代码,对 backward 代码感兴趣的读者请参https://github.com/pangolulu/rnn-from-scratch

# forward pass
for t in xrange(len(inputs)):
xs[t] = np.zeros((vocab_size, 1)) # encode in 1-of-k representation
xs[t][inputs[t]] = 1
hs[t] = np.tanh(np.dot(Wxh, xs[t]) + np.dot(Whh, hs[t - 1]) + bh) #
hidden state
ys[t] = np.dot(Why, hs[t]) + by # unnormalized log probabilities for
next chars
ps[t] = np.exp(ys[t]) / np.sum(np.exp(ys[t])) # probabilities for next
chars
loss += -np.log(ps[t][targets[t], 0]) # softmax (cross-entropy loss)

上面的代码变量每一个时刻 t,首先把字母的 id 变成 one-hot 的表示,然后计算 hs[t],计算方法是:hs[t] = np.tanh(np.dot(Wxh, xs[t]) + np.dot(Whh, hs[t - 1]) +bh)。也就是根据当前输入 xs[t] 和上一个状态 hs[t-1] 计算当前新的状态 hs[t],注意如果 t=0 的时候 hs[t-1] = hs[-1] = np.copy(hprev),也就是函数参数传入的隐状态的初始值 hprev。接着计算 ys[t] = np.dot(Why, hs[t]) + by。然后用softmax 把它变成概率:ps[t] = np.exp(ys[t]) /np.sum(np.exp(ys[t]))。最后计算交叉熵的损失:loss +=-np.log(ps[t][targets[t], 0])。注意:ps[t] 的 shape 是 [vocab_size,1]

sample 函数

这个函数随机的生成一个句子(字符串)。

def sample(h, seed_ix, n):
"""
使用rnn模型生成一个长度为n的字符串
h是初始隐状态,seed_ix是第一个字符
"""
x = np.zeros((vocab_size, 1))
x[seed_ix] = 1
ixes = []
for t in xrange(n):
h = np.tanh(np.dot(Wxh, x) + np.dot(Whh, h) + bh)
y = np.dot(Why, h) + by
p = np.exp(y) / np.sum(np.exp(y))
ix = np.random.choice(range(vocab_size), p=p.ravel())
x = np.zeros((vocab_size, 1))
x[ix] = 1
ixes.append(ix)
return ixes

sample 函数会生成长度为n 的字符串。一开始 x 设置为 seed_idx:x[seed_idx]=1 (这是one-hot 表示),然后和 forward 类似计算输出下一个字符的概率分布 p。然后根据这个分布随机采样一个字符 (id) ix,把 ix 加到结果 ixes 里,最后用这个ix 作为下一个时刻的输入:x[ix]=1

训练

n, p = 0, 0
mWxh, mWhh, mWhy = np.zeros_like(Wxh), np.zeros_like(Whh), np.zeros_like(Why)
mbh, mby = np.zeros_like(bh), np.zeros_like(by) # memory variables for
Adagrad
smooth_loss = -np.log(1.0 / vocab_size) * seq_length # loss at iteration 0
while True:
# prepare inputs (we're sweeping from left to right in steps seq_length
long)
if p + seq_length + 1 >= len(data) or n == 0:
hprev = np.zeros((hidden_size, 1)) # reset RNN memory
p = 0 # go from start of data
inputs = [char_to_ix[ch] for ch in data[p:p + seq_length]]
targets = [char_to_ix[ch] for ch in data[p + 1:p + seq_length + 1]]

# sample from the model now and then
if n % 1000 == 0:
sample_ix = sample(hprev, inputs[0], 200)
txt = ''.join(ix_to_char[ix] for ix in sample_ix)
print('----\n %s \n----' % (txt,))

# forward seq_length characters through the net and fetch gradient
loss, dWxh, dWhh, dWhy, dbh, dby, hprev = lossFun(inputs, targets, hprev)
smooth_loss = smooth_loss * 0.999 + loss * 0.001
if n % 1000 == 0:
print('iter %d, loss: %f' % (n, smooth_loss)) # print progress

# perform parameter update with Adagrad
for param, dparam, mem in zip([Wxh, Whh, Why, bh, by],
[dWxh, dWhh, dWhy, dbh, dby],
[mWxh, mWhh, mWhy, mbh, mby]):
mem += dparam * dparam
param += -learning_rate * dparam / np.sqrt(mem + 1e-8) # adagrad update

p += seq_length # move data pointer
n += 1 # iteration counter

上面是训练的代码,首先初始化 mWxh, mWhh, mWhy。因为这里实现的是Adgrad,所以需要这些变量来记录每个变量的“delta”,有兴趣的读者可以参考:http://cs231n.github.io/neural-networks-3/#ada

接下来是一个无限循环来不断的训练,首先是得到一个训练数据,输入是data[p:p +seq_length],而输出是data[p+1:p +seq_length+1]。然后是lossFun 计算这个样本的loss,梯度和最后一个时刻的隐状态(用于下一个时刻的隐状态的初始值),然后用梯度更新参数。每 1000 次训练之后会用sample 函数生成一下句子,可以通过它来了解目前模型生成的效果。

完整代码:

https://github.com/fancyerii/deep_learning_theory_and_practice/blob/master/codes/ch05/rnn.py

▌LSTM/GRU

长距离依赖(Long Term Dependency) 问题

RNN 最有用的地方在于它(可能) 能够把之前的信息传递到当前时刻,比如在理解一个视频的当前帧时利用之前的帧是非常有用的。如果 RNN 可以做到这一点,那么它会非常有用。但是它能够实现这个目标吗?

这里写图片描述
图5.4: RNN 的短距离依赖

这里写图片描述

图5.5: RNN 的长距离依赖

有的时候,我们只需要最近的一些信息就可以很好的预测当前的任务。比如在语言模型里,我们需要预测”the clouds are in the ?” 的下一个单词,我们很容易根据之前的这几个此就可以预测最可能的词是”sky”。如图5.4所示,我们要预测的h3 需要的信息x0, x1 距离不是太远。

但是有的时候我们需要更多的上下文信息来预测。比如”I grew up in France…I speak fluent ?”。最近的信息”I speak fluent” 暗示后面很可能是一种语言,但是我们无法确定是哪种语言,除非我们有更久之前的上下文”I grew up in France”。因此为了准确的预测,我们可能需要依赖很长距离的上下文。如图5.5所示,为了预测xt+1,我们需要很远的x0, x1。理论上,如果我们的参数学得足够好,RNN 是可以学习到这种长距离依赖关系的。但是很不幸的是,在实际应用中RNN 很难学到。

接下来会介绍的 LSTM 就是试图解决这个问题。

这里写图片描述

图5.6: RNN 的结构

这里写图片描述
图5.7: LSTM

Long Short Term Memory(LSTM) 网络基本概念

本节内容主要来自Colah 的博客:http://colah.github.io/posts/2015-08-Understanding-LSTMs/

LSTM 是一种特殊的RNN 网络,它使用门(Gate) 的机制来解决长距离依赖的问题。

回顾一下,所有的RNN 都是如图 5.6 的结构,把 RNN 看成一个黑盒子的话,它会有一个“隐状态”来“记忆”一些重要的信息。当前时刻的输出除了受当前输入影响之外,也受这个“隐状态”影响。并且在这个时刻结束时,除了输出之外,这个“隐状态”的内容也会发生变化——可能“记忆”了新的信息同时有“遗忘”了一些旧的信息。

LSTM 也是这样的结果,只不过相比于原始的 RNN,它的内部结构更加复杂。

普通的 RNN 就是一个全连接的层,而 LSTM 有四个用于控制”记忆“和运算的门,如图5.7所示。

这个图初看比较复杂,我们后面会详细解释里面的细节。在介绍之前,我们首先来熟悉图中的一下部件,如图 5.8 所示。

这里写图片描述

图5.8: LSTM 示意图的组件

这里写图片描述
图5.9: LSTM Cell State 的通道

在图 5.8 中,每条有向边代表向量,黄色的方框代表神经网络层,粉色的圆圈代表逐点运算(Pointwise Operation)。两条边的合并代表向量的拼接(concatenation),边的分叉代表把一个向量复制到两个地方。

LSTM 核心思想

LSTM 除了有普通RNN 的隐状态ht 之外还有一个叫Cell State 的Cell 状态Ct,它基本是从上一个时刻直接通到下一个时刻的(后面会介绍修改它的操作),所以以前的重要”记忆“理论上可以很容易保存下来,如图5.9所示,图上从Ct−1 到Ct 存在直接的通道。

当然如果LSTM 只是原封不动的保存之前的”记忆“,那就没有太多价值,它还必须根据需要,能够增加新的记忆同时擦除旧的无用的记忆。LSTM 是通过一种叫作门的机制来控制怎么擦除旧记忆写入新记忆的,下面我们逐步来介绍它的这种机制。

如图5.10所示,门可以用来控制信息是否能够通过,它一般是一个激活函数是sigmoid 的层,0 表示阻止任何信息通过,1 表示所有信息通过,而0-1 直接的值表示
部分通过。

LSTM 门的细节

首先我们来了解 LSTM 的遗忘门(Forget Gate),它会决定遗忘多少之前的记忆。它的输入是上一个时刻的隐状态 和当前时刻的输入 ,它的输出是 0-1 直接的数,0 表示完全遗忘之前的记忆,而 1 表示完全保留原来的记忆。

这里写图片描述
图5.10: LSTM 的Gate

这里写图片描述

图5.11: LSTM 的Forget Gate

这里写图片描述
图5.12: LSTM 的Input Gate

如图 5.11 所示:

这里写图片描述

这个ft 乘以Ct−1 就表示上一个时刻的Cell State Ct−1 需要遗忘多少信息。

接下来LSTM 有一个输入门(Input Gate):这里写图片描述,它用来控制输入的信息多少可以进入LSTM。t 时刻的输入候选这里写图片描述

注意˜Ct 的激活函数是tanh,因为输入的范围我们不能限制,因此用(-1,1) 的tanh;而门我们要求它的范围是(0,1),因此门用sigmoid 激活。然后把输入门和输入候选点乘起来,表示当前时刻有多少信息应该进入Cell State,如图5.12所示。

接着把上一个时刻未遗忘的信息Ct−1 × ft 和当前时刻候选˜Ct 累加得到新的Ct,
如图5.13所示:这里写图片描述

最后我们需要计算当前时刻的输出ht(它就是隐状态),它是使用当前的Ct 使用tanh 计算后再通过一个输出门(Output Gate) 得到,如图5.14所示。

这里写图片描述

这里写图片描述

图5.13: LSTM 计算t 时刻的Ct

这里写图片描述

图5.14: ot 的计算

LSTM 的变种

下面介绍一些常见的LSTM 变种,包括很流行的GRU(Gated Recurrent Unit)。第一种变体是计算三个门时不只利用ht−1 和xt,还使用Ct−1,也就是从Ct−1 有一个peephole 的边,如图5.15所示。

第二种变体就是遗忘门ft 不但决定遗忘多少Ct−1 的信息,而且1 − ft 会乘以˜Ct 中用于控制多少新的信息进入Ct,如图5.16所示。

第三种就是GRU,它把遗忘门和输入门合并成一个更新门(Update Gate),并且

这里写图片描述
图5.15: 有peephole 连接的LSTM

这里写图片描述
5.16: LSTM 变种2

这里写图片描述
图5.17: GRU

把 Cell State 和 Hidden State 也合并成一个 Hidden State,它的计算如图 5.17 所示。

这里写图片描述

和LSTM 不同,在计算˜ht 的时候会用rt 乘以ht−1,类似与LSTM 的遗忘门。而在计算新的ht 时,(1 − zt) 表示从ht−1 里保留的信息比例,而zt 表示从˜ht 里更新的信息比例。

下节预告:PyTorch 教程(敬请关注)

相关文章:

李理:递归神经网络RNN扼要
李理:从Image Caption Generation理解深度学习(part I)
李理:从Image Caption Generation理解深度学习(part II)
李理:从Image Caption Generation理解深度学习(part III)
李理:自动梯度求解反向传播算法的另外一种视角
李理:自动梯度求解——cs231n的notes
李理:自动梯度求解——使用自动求导实现多层神经网络
李理:详解卷积神经网络
李理:Theanotutorial和卷积神经网络的Theano实现Part1
李理:Theanotutorial和卷积神经网络的Theano实现Part2
李理:卷积神经网络之Batch Normalization的原理及实现
李理:卷积神经网络之Dropout
李理:三层卷积网络和vgg的实现
李理:Caffe训练ImageNet简介及深度卷积网络最新技术



这里写图片描述

  • 5
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值