Recurrent Neural Network 是反馈神经网络,简称为 RNN 。与最基础的前向传播神经网络不同的是,RNN 是一个有记忆的神经网络,他把上一次的输出存了起来作为下一次的输入参数的一部分影响下一次的输出结果。为什么要这么做呢?让我们来看一个例子。
假设有这样一个应用场景,我希望输入一段话给我的语音助手,例如 “I would like to arrive Chengdu on November 2nd” (意思是我将会在 11 月 2 日抵达成都),我希望我的语音助手能够识别出 Chengdu 是我的目的地,November 2nd 是日期,这样可以帮我自动设一个日程表或者是帮我查交通方式等等。
我们知道神经网络的输入一般都是数值,没法处理这种字符,那么一个常用的方法就是 1-of-N Encoding 。在进行编码前,我们会有一个词汇表(lexicon)。下面举一个例子便于理解,假设我们的词汇表是: l e x i c o n = { a p p l e , b a g , c a t , d o g } \rm lexicon = \{apple,bag,cat,dog\} lexicon={apple,bag,cat,dog} ,当我们的词是 a p p l e \rm apple apple 时,编码的结果就是 [ 1 , 0 , 0 , 0 ] [1,0,0,0] [1,0,0,0] ,同理若为 c a t \rm cat cat 时,编码的结果就是 [ 0 , 0 , 1 , 0 ] [0,0,1,0] [0,0,1,0] 。下图也是一个例子。
独热编码还有一个改版叫做 Beyond 1-of-N Encoding ,就是在 1-of-N Encoding 的基础上多一个 o t h e r \rm other other 表示一个词未出现在词汇表中,例如词汇表还是上面的(没有 e l e p h a n t \rm elephant elephant),若一个词是 b a n a n a \rm banana banana 则会编码成 [ 0 , 0 , 0 , 0 , 1 ] [0,0,0,0,1] [0,0,0,0,1] 。改版之前是会有词无法进行编码的,因为没有出现在词汇表中,改版后所有词都能编码,但是如果词汇表设置的不好,则会有很多次都被归到 o t h e r \rm other other ,变的没有差别,这样训练出的网络可能会有问题。
还有一种编码方式就是 Word hashing ,他是把三个字母的组合作为一个维度,所以一共有 26 × 26 × 26 26 \times 26 \times 26 26×26×26 种组合,然后去看单词中的某一种组合是否出现,比如 a p p l e \rm apple apple 这个单词,就可以拆成 a p p 、 p p l 、 p l e \rm app、ppl、ple app、ppl、ple ,那么这三种对应的维度上就是 1 1 1 ,其他都是 0 0 0 。(有个问题是,要是有一个组合在单词中重复出现的话,不知道这个维度上是只记一个 1 1 1 ,还是记他的计数值,课上举的例子没有这个问题)
解决了怎么把单词输入到神经网络中后,回到我们一开始那么问题,首先可以想到的是,我们可以把输出变成单词类型的类别,例如:输入 I \rm I I 输出是 o t h e r \rm other other ,输入是 C h e n g d u \rm Chengdu Chengdu 输出是 d e s t i n a t i o n \rm destination destination ,输入是 N o v e m b e r \rm November November 输出是 t i m e \rm time time ,输入是 2 n d \rm 2nd 2nd 输出是 t i m e \rm time time ,输出的类别一共有三种。似乎用一个普通的神经网络就可以解决这个分类问题了,根本不需要什么有记忆的 RNN 。但是如果问题变成我输入的一句话变成了 “I would like to leave Chengdu on November 2nd” 呢,输入这句话表示我将在十一月二号离开成都,那么我希望我的语音助手进行的操作肯定和原来是不一样的,比如提醒我走之前买纪念品。那么对于原来的无记忆的神经网络来说,是无法鉴别这个 C h e n g d u \rm Chengdu Chengdu 到底是 d e s t i n a t i o n \rm destination destination (目的地)还是 d e p a r t u r e \rm departure departure (出发地)的。但是其实只要考虑了前面那个词就能够分别到底是目的地还是出发地了。所以我们需要一个有记忆的神经网络来做这件事。
RNN 其实就是把上一次的输出结果,例如计算 l e a v e \rm leave leave 时的各个层的输出,参与下一次的输入的计算,即作为 C h e n g d u \rm Chengdu Chengdu 的网络的输入参数的一部分。一种 RNN 的实现是把每一层的上一次的输出作为该层的下一次的输入的一部分。他的结构如下图所示,把计算 l e a v e \rm leave leave 时第 i i i 层的输出存到 a i a^i ai ,然后在计算 C h e n g d u \rm Chengdu Chengdu 时把他作为输出输入到第 i i i 层:
这样做以后,从原来的一个单词是一个独立的输入以后变成了一句话是一个独立的输入,这句话里的单词之间会相互影响,单词与单词之间不再独立,所以单词的输入顺序也不能改变,不同的顺序输入到网络会得到不同的结果,存储单元一开始需要初始化,比如说设为 0 0 0 。下图是 RNN 的运行原理,这个网络中的权重参数是一样的,只是不同时刻的网络的输入不同。
上面那个网络是每一层的输出会作为下一次该层的输入,还有一种是只把上一次最后的输出 y y y 作为下一层所有层的输入,这种结构叫做 Jordan Network ,其运行原理如下图所示:
还有一种是按照句子的正向词序输入训练一个 RNN ,再按照反向输入训练另一个 RNN ,然后根据两个 RNN 的输出得到一个最终的输入,其运行原理如下图,这种方法被称为 Bidirection RNN(双向反馈神经网络) 可以想象到,与前面几种不同的是,前面的输入一个按顺序每输入一个词就能得到一个最终分类结果,前面词的分类与后面的词无关,这个网络必须在两个网络中都得到结果后才能得到最终结果,每个词的最终结果与句子前面的词和后面的词都是有关的。
上面这种记忆方式是每次记忆单元里的值都会被改变,每次记忆单元里的值都会参与下一次输入参数的运算,并且记忆单元的值是不会被清零的。这种反馈神经网络也被称为 Simple RNN 。接下来介绍一个更为复杂,也更为常用的反馈神经网络—— LSTM 。让我们首先来看下他的记忆机制的工作原理,如下图所示:
与 Simple RNN 不同的是,LSTM 的记忆单元不一定会每次都被改变,也不一定每次都会输出所存的值,还有可以被清零。他的工作原理是,当有一个输出被输入进来,首先会经过一个 Input Gate ,这个 Input Gate 决定了是否接受这个输入, Input Gate 又是由其他输入来决定的,接受的话,那么 Inpute Gate 的输出就是输入,否则就是 0 ,接下来到了 Memory Cell ,Memory Cell 原来是有存一个值的,这个值会先输入到 Forget Gate ,看是否要清零,如果要的话,Forgate Gate 输出就是 0 ,不然就还是原来存的值,Forgate Gate 的输出和 Inpute Gate 的输出加起来就得到新的 Memory Cell 存的值然后作为 Memory Cell 的输出,Output Gate 的工作原理和 Input Gate 的工作原理相同,决定了 Memory Cell 的输出能否被输出,可以的话最终的输出就是 Memory Cell 的输出,否则就是 0 。下面这个图就是具体的计算方法:
输入到整个单元的还是一个向量 x x x ,然后经过一个线性运算 z = x T w + b z=x^T w + b z=xTw+b 得到一个标量输出 z z z ,其中 w w w 是权重向量, b b b 是偏置。图中每个 Gate 的输入 z 、 z i 、 z o 、 z f z、z_i、z_o、z_f z、zi、zo、zf 的不同在于线性运算的 w w w 和 b b b 不同, x x x 都是一样的。
那么第一步就是将 z z z 经过一个激活函数 g ( z ) g(z) g(z) ,将 z i z_i zi 也经过一个激活函数 f ( z i ) f(z_i) f(zi) ,将他两的结果相乘得到 Input Gate 的输出 g ( z ) f ( z i ) g(z)f(z_i) g(z)f(zi) 。 g ( z ) g(z) g(z) 其实就是原来全连接神经网络中某一个神经元的输出,而 f ( z i ) f(z_i) f(zi) 则可以决定是否接受这个输入。
第二步就是记忆单元原来存储的值 c c c 输入到 Forgate Gate 中,与 Forget Gate 的输入 z f z_f zf 经过一个激活函数 f ( z f ) f(z_f) f(zf) 的结果相乘得到 Forget gate 的输出 c f ( z f ) cf(z_f) cf(zf) ,这个 f ( z f ) f(z_f) f(zf) 决定了原来存储的 c c c 能不能更新到下一次的存储 c ′ c' c′ 中,Forget Gate 的输出 c f ( z f ) cf(z_f) cf(zf) 和 Input Gate 的输出 g ( z ) f ( z i ) g(z)f(z_i) g(z)f(zi) 相加更新新的 Memory Cell 存储的值 c ′ = g ( z ) f ( z i ) + c f ( z j ) c'=g(z)f(z_i)+cf(z_j) c′=g(z)f(zi)+cf(zj) 。、
第三步是 c ′ c' c′ 再经过一个激活函数 h ( c ′ ) h(c') h(c′) 与Output Gate 的输入经过一个激活函数 f ( z o ) f(z_o) f(zo) 的结果相乘,得到了最后的输出结果 a = h ( z ′ ) f ( z o ) a=h(z')f(z_o) a=h(z′)f(zo) 。
上面的激活函数 f ( ) f() f() 通常使用 Sigmoid function ,对应打开或者关闭。容易引起歧义的是 Forgate Gate 那里当 f ( ) f() f() 输出 0 的时候才是忘掉原来记的东西。
上面所介绍的其实只是某一个神经元的工作原理,由 LSTM 构成的神经网络的结构其实是和全连接的神经网络是一样的,只是将原来的网络中的神经元换成了上面这种神经元。原来的输入构成的向量 x x x 作为上面的神经元的输入 x x x ,同一层的神经元的输出 a a a 组成一个向量输入到下一层去。就像下图这样,同一个颜色的线代表是同一组权重,那么原来两个输出,两个神经元的权重参数的个数是 2 × 2 2 \times 2 2×2,就变成了现在的 2 × 2 × 4 2\times 2 \times 4 2×2×4 ,翻了四倍,然后还需要再存储一个记忆单元向量,记为 c c c:
将上面这张图用向量表示得到下面这张图, x t x^t xt 是前面一层在第 t t t 个时刻时的输出, c t − 1 c^{t-1} ct−1 是在 t − 1 t-1 t−1 时刻更新后的该层的 Memory Cell 存储的值构成的向量。那么 z f 、 z i 、 z 、 z o z^f、z^i、z、z^o zf、zi、z、zo 分别是以 x t x^t xt 为输入的四种不同权重参数的线性运算的结果。(注意:下图有点歧义,或者说是有问题的,就是每个 z z z 的输出输入到不同神经元的值是不一样的,例如输入到最左边的 z z z 应该和输入到第二个神经元的 z z z 不是一样的,其他的 z i z^i zi 等也同理,从上面那张图输入到不同神经元的同一个门的颜色不同可以更好的理解这一点,而把 z z z 看出是向量后,输入到不同神经元里的值其实就是不同维度上的值)
具体来看某一层神经元的向量运算过程。 x t x^t xt 经过四组不同权重矩阵的线性运算得到向量 z f 、 z i 、 z 、 z o z^f、z^i、z、z^o zf、zi、z、zo ,接下来的计算如下图左半部分的图所示,该运算过程和上面说过的运算过程是一样的。
同一个神经元不同时刻的运算示意图如下,计算出的 c t c^t ct 会参与下一个时刻的运算得到 y t + 1 y^{t+1} yt+1 和 c t + 1 c^{t+1} ct+1 。
上面这个还不是 LSTM 的最终形态,真正的 LSTM 不仅会考虑上一时刻的 c t − 1 c^{t-1} ct−1 还会考虑上一时刻的输出 y t − 1 y^{t-1} yt−1 ,记为 h t − 1 h^{t-1} ht−1 作为输入。更有不只把 c t − 1 c^{t-1} ct−1 参与中间的运算,还作为输入参与到运算,被称为 peephole 。示意图如下所示:
多层的 LSTM 就会长成下面这样:
让我们跳过 LSTM 回到最初的 Simple RNN ,我们似乎还有一个问题没有解决,那就是怎么训练这么一个有记忆的神经网络。在前面的训练神经网络的方法中,我们采用的是 Gradient Descent (梯度下降) 的方法,梯度下降中需要用到的每个权重的梯度值需要使用 Backpropagation (反向传播) 来计算。那么在训练 RNN 的时候,我们同样采用的是梯度下降的方法,而不同的是在进行反向传播计算梯度值的时候需要考虑输入的顺序,使用的是 Backpropagation through time (BPTT) 方法。
具体的做法其实李宏毅老师在课上没有细讲,因为这个已经被集成在现有的工具中了,不需要自己动手实现,下面说的是我自己觉得应该更新的方法。在传统的神经网络中,输入与输入之间是独立的,例如最开始那个分类单词是 o t h e r 、 d e s t i n a t i o n 、 t i m e 、 d e p a r t u r e \rm other、destination、time、departure other、destination、time、departure 的例子中,每个词不会影响其他词的输出结果。而在 RNN 中单词与单词不是独立的,但是句子与句子是独立的。所以在考虑一句话的时候,首先会初始化记忆单元里的数值,比如初始化为 0 ,然后再进行前向传播,计算出最后的输出结果,结果的输出会是一个向量,与标签向量进行交叉熵就可以计算出在这个单词上分类的损失值。然后再进行反向传播,计算每个权重的梯度值,这个反向传播的过程和原来的神经网络的反向传播的过程是一样的,只是每层多了些输入和权重。那么这就为每个权重参数计算出了一个梯度值,同时记忆单元存储的内容也会被前向传播中各个层的输出重写。这个时候再输入第二个词,然后进行相同的过程,直至这句话所有的词都算完,那么就可以算出很多个梯度值和损失值,他们的和或者均值就可以作为这句话的梯度和损失值(具体是哪个由你的损失函数怎么定的决定,损失函数用的是均值就都是均值,否则就是和,我觉得一般采用均值)。然后输入第二句话的时候,需要重新初始化记忆单元,然后重复上面的内容。最后就能得到每句话的每个单词的梯度值和损失值,均值就是总的损失和梯度,然后就可以进行权重参数的更新。
通过以上的内容,你就可以取训练你的 RNN 了,但是不幸的是,你可能训练不出一个收敛的结果,你想要的训练过程可能是下图蓝线那样,但大多数时候确是绿线那样,并且这并不是有 bug 才这样,而是模型本身的特性造成的。
那么损失值曲线会这样跳来跳去呢?我们来从损失值与权重参数的曲面图形来观察。从图上可以看出,RNN 的训练有两种问题,第一种是他的损失值曲面要么非常平整,要么非常陡峭。平整会造成梯度消失的问题,也就是我不知道现在损失值下降的很慢是因为到了最小值点还是只是现在所在的面非常平,而陡峭造成的问题则是我可能会从一个较低面更新到一个更高的面,所以造成了损失值曲线的乱跳,更糟糕的是,如果刚好落在了交界处去计算梯度,那么计算出的梯度会非常大,再乘上一个学习率可能就飞出去了,这样根本没办法训练。那么曾经有人用的一个技巧就是设一个最大值,梯度大于这个值的时候就只更新最大值那么大来防止梯度飞的太远。
下面举一个最简单的 RNN 来理解梯度更新中的矛盾。下图是一个只有一个隐藏层且只有一个线性单元的 RNN ,且记忆单元的权重是 w w w ,其他都是 1 1 1 。那么有一个序列,第一个是 1 1 1 ,其他都是 0 0 0 ,长 1000 1000 1000 个单元,假设我们的第 1000 1000 1000 个输出的标签是 1 1 1 。那么最后一个的输出就是 y 1000 = w 999 y^{1000}=w^{999} y1000=w999 。如果当 w = 1 w=1 w=1 时,输出是 1 1 1 ,显然 w = 1 w=1 w=1 是我们的训练目标。如果某个时刻 w = 1.01 w=1.01 w=1.01 ,那么输出就会是 y 1000 ≈ 20000 y^{1000} \approx 20000 y1000≈20000 ,这个比 1 1 1 大很多,也就是说会期望给 w w w 一个很大的更新,因为梯度会很大,但由于稍微变一点结果就会变很多,为了让最后变得不多,我们就得让学习率很小。但是在 w = 0.99 w=0.99 w=0.99 和 w = 0.01 w=0.01 w=0.01 时,输出 y 1000 ≈ 0 y^{1000} \approx 0 y1000≈0 都近似于 0 0 0 ,那么如果 w w w 现在是在 w = 0.01 w=0.01 w=0.01 的话,算出来的梯度是非常小的,但我们又希望他能更新多一点,就得把学习率设的大一点。这样学习率在不同情况下期望设置的方向的不同就造成了矛盾,如果设的小了,那么当 w w w 很小时,则基本不会更新,也就无法收敛,造成梯度消失的情况,如果学习率设的比较大,那么当 w = 1.01 w=1.01 w=1.01 时,梯度更新的也很大,学习率也很大,更新的就会非常多,结果就是损失值飞出去了。
一个有用的技巧就是上面所说的设一个梯度更新的最大值,防止飞出去。这个技巧的存在也是有段时间只有使用这个技巧的那一个人能够训练出一个能 work 的 RNN ,其他人都训练不出来。另一个解决方法就是前面所说的 LSTM 。LSTM 最初是没有 Forgate Gate 的,他能够处理梯度消失的问题,但是处理不了梯度突变的问题。那么他是怎么解决梯度消失的问题呢?因为在 LSTM 中,记忆单元的存储是原来存的值加输入,是一个相加的形式,不存在上面的 w w w ,或者说 w w w 是恒等于 1 1 1 的。所以前面的输入对存储单元存储内容的影响永远不会消失(原来当 w w w 小于 1 1 1 的时候,前面的输入会不停被衰减而消失),所以能够处理梯度消失的问题。但是有了 Forgate Gate 的 LSTM 不就会有遗忘原来的存储的内容的可能吗,那这样在这之前的输入不就都不会影响后面的存储单元中的数值了吗。一种理解是,因此 Forgate Gate 的偏执会设的非常大,而使很难让这个遗忘的功能被启动。
LSTM 的一种变形是 GRU ,他少了一个门,把 Input Gate 和 Forgate Gate 的功能合起来了,他的思想就是新的不去,旧的不来。当输入门控制让新的值输入进来后,就把原来存的的忘了,当不给进时,就把原来存的作为输出参与到下一个门的运算。
还有一个有意思的结果是,在 Simple RNN 上,通常门的激活函数是使用 Sigmoid 函数的,如果使用 ReLU ,效果往往不好,不过这是在参数随机初始化的条件下的,如果初始化使用的是单位矩阵的话,使用 ReLU 激活函数能够得到更好的表现,甚至超过了 LSTM。
后面课程的部分讲的是 RNN 的应用,其中一些应用使用了 RNN 的变形形式,同时还有的结合了其他模型的思想,比如 GAN 、structed SVM 等,由于我看的那个 up 主的视频顺序有问题,其中所含的很多内容都是没听过,缺了很多知识,所以听起来很费劲,只领略到了一点点思想,况且每个应用的实现思想都很简略,所以后面部分的笔记不再给出。
建议如果也因课程顺序而难以接受课程内容的同学对照着 2017 版的课程顺序和 2020 版的顺序来进行学习。李宏毅老师的课程好像是每年在原来的基础上修改部分视频内容,添加一部分内容。很多网上所给的 2020 版课程都是往年课程视频的重新排序,所以回到最初的顺序观看是比较容易接受的。并且,北邮的陈光老师提供的视频较为可靠(B站昵称是:爱可可-爱生活)。