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

一文详解循环神经网络的基本概念(代码版)_第1张图片


作者 | 李理


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



写在前面



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

2. 640?wx_fmt=png 是 t 时刻的隐状态。


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

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


一文详解循环神经网络的基本概念(代码版)_第2张图片

图5.1: RNN 展开图


3. 640?wx_fmt=png 是 t 时刻的输出。


有一些事情值得注意:


1. 你可以把 640?wx_fmt=png 看成是网络的“记忆”。


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


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


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


一文详解循环神经网络的基本概念(代码版)_第3张图片

图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


一文详解循环神经网络的基本概念(代码版)_第4张图片

图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张图片

图5.4: RNN 的短距离依赖


一文详解循环神经网络的基本概念(代码版)_第6张图片

图5.5: RNN 的长距离依赖


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


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


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


一文详解循环神经网络的基本概念(代码版)_第7张图片

图5.6: RNN 的结构


一文详解循环神经网络的基本概念(代码版)_第8张图片

图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 所示。


640?wx_fmt=png

图5.8: LSTM 示意图的组件

一文详解循环神经网络的基本概念(代码版)_第9张图片

图5.9: LSTM Cell State 的通道


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


LSTM 核心思想


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


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


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


LSTM 门的细节


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


一文详解循环神经网络的基本概念(代码版)_第10张图片

图5.10: LSTM 的Gate


一文详解循环神经网络的基本概念(代码版)_第11张图片

图5.11: LSTM 的Forget Gate


一文详解循环神经网络的基本概念(代码版)_第12张图片

图5.12: LSTM 的Input Gate


如图 5.11 所示:


640?wx_fmt=png


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


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


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


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


640?wx_fmt=png


一文详解循环神经网络的基本概念(代码版)_第13张图片

图5.13: LSTM 计算t 时刻的Ct


一文详解循环神经网络的基本概念(代码版)_第14张图片

图5.14: ot 的计算


LSTM 的变种


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


第二种变体就是遗忘门 640?wx_fmt=png 不但决定遗忘多少 640?wx_fmt=png 的信息,而且 640?wx_fmt=png 会乘以 640?wx_fmt=png 中用于控制多少新的信息进入 640?wx_fmt=png,如图 5.16 所示。


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

一文详解循环神经网络的基本概念(代码版)_第15张图片

图5.15: 有peephole 连接的LSTM


一文详解循环神经网络的基本概念(代码版)_第16张图片

5.16: LSTM 变种2


一文详解循环神经网络的基本概念(代码版)_第17张图片

图5.17: GRU


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


一文详解循环神经网络的基本概念(代码版)_第18张图片


和 LSTM 不同,在计算 640?wx_fmt=png 的时候会用 640?wx_fmt=png 乘以 640?wx_fmt=png,类似与 LSTM 的遗忘门。而在计算新的 640?wx_fmt=png 时,640?wx_fmt=png 表示从 640?wx_fmt=png 里保留的信息比例,而 640?wx_fmt=png 表示从 640?wx_fmt=png 里更新的信息比例。


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




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

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

一文详解循环神经网络的基本概念(代码版)_第19张图片


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

你可能感兴趣的:(一文详解循环神经网络的基本概念(代码版))