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

2018 年 4 月 28 日 人工智能头条 专注技术分享


作者 | 李理


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



写在前面



由于工作太忙,这个系列文章有一年多没有更新了。最近在整理资料时用到了里面的一些内容,觉得做事情应该有始有终,所以打算把它继续完成。下面的系列文章会首先会介绍 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.  是 t 时刻的输入。

2.  是 t 时刻的隐状态。


它是网络的“记忆”。 的计算依赖于前一个时刻的状态和当前时刻的输入:

。函数 f 通常是诸如 tanh 或者 ReLU 的非线性函数。,这是用来计算第一个隐状态,通常我们可以初始化成0。


图5.1: RNN 展开图


3.  是 t 时刻的输出。


有一些事情值得注意:


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


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


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 所示,我们要预测的需要的信息距离不是太远。


但是有的时候我们需要更多的上下文信息来预测。比如“I grew up in France…Ispeak fluent ?”。最近的信息“I speak fluent” 暗示后面很可能是一种语言,但是我们无法确定是哪种语言,除非我们有更久之前的上下文“I grew up in France”。因此为了准确的预测,我们可能需要依赖很长距离的上下文。如图 5.5 所示,为了预测 ,我们需要很远的 。理论上,如果我们的参数学得足够好,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 的隐状态  之外还有一个叫 Cell State 的 Cell  状态它基本是从上一个时刻直接通到下一个时刻的(后面会介绍修改它的操作),所以以前的重要”记忆“理论上可以很容易保存下来,如图 5.9 所示,图上从  到  存在直接的通道。


当然如果 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 所示:



这个  乘以  就表示上一个时刻的  需要遗忘多少信息。


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


接着把上一个时刻未遗忘的信息  和当前时刻候选  累加得到新的,如图 5.13 所示:


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



图5.13: LSTM 计算t 时刻的Ct


图5.14: ot 的计算


LSTM 的变种


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


第二种变体就是遗忘门  不但决定遗忘多少  的信息,而且  会乘以  中用于控制多少新的信息进入 ,如图 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 不同,在计算  的时候会用  乘以 ,类似与 LSTM 的遗忘门。而在计算新的  时, 表示从  里保留的信息比例,而  表示从  里更新的信息比例。


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




扫描二维码,关注「人工智能头条」

回复“技术路线图”获取 AI 技术人才成长路线图


点击 | 阅读原文 | 查看更多干货内容

登录查看更多
5

相关内容

RNN:循环神经网络,是深度学习的一种模型。
【ICML2020-华为港科大】RNN和LSTM有长期记忆吗?
专知会员服务
73+阅读 · 2020年6月25日
一份循环神经网络RNNs简明教程,37页ppt
专知会员服务
168+阅读 · 2020年5月6日
Sklearn 与 TensorFlow 机器学习实用指南,385页pdf
专知会员服务
126+阅读 · 2020年3月15日
《深度学习》圣经花书的数学推导、原理与Python代码实现
Transformer文本分类代码
专知会员服务
116+阅读 · 2020年2月3日
【MIT深度学习课程】深度序列建模,Deep Sequence Modeling
专知会员服务
76+阅读 · 2020年2月3日
【书籍】深度学习框架:PyTorch入门与实践(附代码)
专知会员服务
160+阅读 · 2019年10月28日
注意力机制介绍,Attention Mechanism
专知会员服务
165+阅读 · 2019年10月13日
神经网络与深度学习,复旦大学邱锡鹏老师
专知会员服务
116+阅读 · 2019年9月24日
针对初学者的循环神经网络介绍
Python程序员
7+阅读 · 2019年8月20日
深度学习循环神经网络详解
七月在线实验室
15+阅读 · 2018年5月28日
一文详解LSTM网络
论智
18+阅读 · 2018年5月2日
教程 | 从头开始了解PyTorch的简单实现
机器之心
20+阅读 · 2018年4月11日
[深度学习] 我理解的循环神经网络RNN
机器学习和数学
16+阅读 · 2017年12月2日
循环神经网络的介绍、代码及实现
AI研习社
3+阅读 · 2017年11月21日
tensorflow系列笔记:流程,概念和代码解析
北京思腾合力科技有限公司
30+阅读 · 2017年11月11日
LSTM入门必读:从基础知识到工作方式详解
炼数成金订阅号
6+阅读 · 2017年7月26日
Arxiv
4+阅读 · 2018年3月30日
Arxiv
25+阅读 · 2017年12月6日
Arxiv
5+阅读 · 2017年11月13日
Arxiv
23+阅读 · 2017年3月9日
VIP会员
相关VIP内容
【ICML2020-华为港科大】RNN和LSTM有长期记忆吗?
专知会员服务
73+阅读 · 2020年6月25日
一份循环神经网络RNNs简明教程,37页ppt
专知会员服务
168+阅读 · 2020年5月6日
Sklearn 与 TensorFlow 机器学习实用指南,385页pdf
专知会员服务
126+阅读 · 2020年3月15日
《深度学习》圣经花书的数学推导、原理与Python代码实现
Transformer文本分类代码
专知会员服务
116+阅读 · 2020年2月3日
【MIT深度学习课程】深度序列建模,Deep Sequence Modeling
专知会员服务
76+阅读 · 2020年2月3日
【书籍】深度学习框架:PyTorch入门与实践(附代码)
专知会员服务
160+阅读 · 2019年10月28日
注意力机制介绍,Attention Mechanism
专知会员服务
165+阅读 · 2019年10月13日
神经网络与深度学习,复旦大学邱锡鹏老师
专知会员服务
116+阅读 · 2019年9月24日
相关资讯
针对初学者的循环神经网络介绍
Python程序员
7+阅读 · 2019年8月20日
深度学习循环神经网络详解
七月在线实验室
15+阅读 · 2018年5月28日
一文详解LSTM网络
论智
18+阅读 · 2018年5月2日
教程 | 从头开始了解PyTorch的简单实现
机器之心
20+阅读 · 2018年4月11日
[深度学习] 我理解的循环神经网络RNN
机器学习和数学
16+阅读 · 2017年12月2日
循环神经网络的介绍、代码及实现
AI研习社
3+阅读 · 2017年11月21日
tensorflow系列笔记:流程,概念和代码解析
北京思腾合力科技有限公司
30+阅读 · 2017年11月11日
LSTM入门必读:从基础知识到工作方式详解
炼数成金订阅号
6+阅读 · 2017年7月26日
Top
微信扫码咨询专知VIP会员