基本概念

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 的遗忘门。而在计算新的 一文详解循环神经网络的基本概念(代码版) 时,一文详解循环神经网络的基本概念(代码版) 表示从 一文详解循环神经网络的基本概念(代码版) 里保留的信息比例,而 一文详解循环神经网络的基本概念(代码版) 表示从 一文详解循环神经网络的基本概念(代码版) 里更新的信息比例。

【转自:https://www.tinymind.cn/articles/147