零基础入门深度学习(hanbingtao)


零基础入门深度学习(1) - 感知器 机器学习 深度学习入门 无论即将到来的是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序 员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?现在救命稻草来了,《零基础入门深度学习》系列文章旨 在讲帮助爱编程的你从零基础达到入门级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这是专门为程序员写 的文章。虽然文中会有很多公式你也许看不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序 员,所以我写的代码也不会很差)。 文章列表 零基础入门深度学习(1) - 感知器 零基础入门深度学习(2) - 线性单元和梯度下降 零基础入门深度学习(3) - 神经网络和反向传播算法 零基础入门深度学习(4) - 卷积神经网络 零基础入门深度学习(5) - 循环神经网络 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 零基础入门深度学习(7) - 递归神经网络 深度学习是啥 在人工智能领域,有一个方法叫机器学习。在机器学习这个方法里,有一类算法叫神经网络。神经网络如下图所示: 上图中每个圆圈都是一个神经元,每条线表示神经元之间的连接。我们可以看到,上面的神经元被分成了多层,层与层之间的神经元有连接,而 层内之间的神经元没有连接。最左边的层叫做输入层,这层负责接收输入数据;最右边的层叫输出层,我们可以从这层获取神经网络输出数据。 输入层和输出层之间的层叫做隐藏层。 隐藏层比较多(大于2)的神经网络叫做深度神经网络。而深度学习,就是使用深层架构(比如,深度神经网络)的机器学习方法。 那么深层网络和浅层网络相比有什么优势呢?简单来说深层网络能够表达力更强。事实上,一个仅有一个隐藏层的神经网络就能拟合任何一个函 数,但是它需要很多很多的神经元。而深层网络用少得多的神经元就能拟合同样的函数。也就是为了拟合一个函数,要么使用一个浅而宽的网 络,要么使用一个深而窄的网络。而后者往往更节约资源。 深层网络也有劣势,就是它不太容易训练。简单的说,你需要大量的数据,很多的技巧才能训练好一个深层网络。这是个手艺活。 感知器 看到这里,如果你还是一头雾水,那也是很正常的。为了理解神经网络,我们应该先理解神经网络的组成单元——神经元。神经元也叫做感知 器。感知器算法在上个世纪50-70年代很流行,也成功解决了很多问题。并且,感知器算法也是非常简单的。 感知器的定义 下图是一个感知器: 可以看到,一个感知器有如下组成部分: 输入权值 一个感知器可以接收多个输入 ,每个输入上有一个权值 ,此外还有一个偏置项 ,就是上 图中的 。 激活函数 感知器的激活函数可以有很多选择,比如我们可以选择下面这个阶跃函数 来作为激活函数: 输出 感知器的输出由下面这个公式来计算 如果看完上面的公式一下子就晕了,不要紧,我们用一个简单的例子来帮助理解。 例子:用感知器实现and函数 我们设计一个感知器,让它来实现and运算。程序员都知道,and是一个二元函数(带有两个参数 和 ),下面是它的真值表: 0 0 0 0 1 0 1 0 0 1 1 1 为了计算方便,我们用0表示false ,用1表示true 。这没什么难理解的,对于C语言程序员来说,这是天经地义的。 我们令 ,而激活函数 就是前面写出来的阶跃函数,这时,感知器就相当于and函数。不明白?我们验算一下: 输入上面真值表的第一行,即 ,那么根据公式(1),计算输出: ( , , . . . , ∣ ∈ R)x1 x2 xn xi ∈ Rwi b ∈ R w0 f f(z) = { 1 z > 0 0 otherwise y = f(w ∙ x + b) 公式(1) x1 x2 x1 x2 y = 0.5; = 0.5; b = −0.8w1 w2 f = 0; = 0x1 x2 y = f(w ∙ x + b) = f( + + b)w1x1 w2x2 = f(0.5 × 0 + 0.5 × 0 − 0.8) = f(−0.8) = 0 也就是当 都为0的时候, 为0,这就是真值表的第一行。读者可以自行验证上述真值表的第二、三、四行。 例子:用感知器实现or函数 同样,我们也可以用感知器来实现or运算。仅仅需要把偏置项 的值设置为-0.3就可以了。我们验算一下,下面是or运算的真值表: 0 0 0 0 1 1 1 0 1 1 1 1 我们来验算第二行,这时的输入是 ,带入公式(1): 也就是当 时, 为1,即or真值表第二行。读者可以自行验证其它行。 感知器还能做什么 事实上,感知器不仅仅能实现简单的布尔运算。它可以拟合任何的线性函数,任何线性分类或线性回归问题都可以用感知器来解决。前面的布尔 运算可以看作是二分类问题,即给定一个输入,输出0(属于分类0)或1(属于分类1)。如下面所示,and运算是一个线性分类问题,即可以用一 条直线把分类0(false,红叉表示)和分类1(true,绿点表示)分开。 然而,感知器却不能实现异或运算,如下图所示,异或运算不是线性的,你无法用一条直线把分类0和分类1分开。 感知器的训练 x1x2 y b x1 x2 y = 0; = 1x1 x2 y = f(w ∙ x + b) = f( + + b)w1x1 w2x2 = f(0.5 × 1 + 0.5 × 0 − 0.3) = f(0.2) = 1 = 0; = 1x1 x2 y 现在,你可能困惑前面的权重项和偏置项的值是如何获得的呢?这就要用到感知器训练算法:将权重项和偏置项初始化为0,然后,利用下面的感 知器规则迭代的修改 和 ,直到训练完成。 其中: 是与输入 对应的权重项, 是偏置项。事实上,可以把 看作是值永远为1的输入 所对应的权重。 是训练样本的实际值,一般称之为 label 。而 是感知器的输出值,它是根据公式(1)计算得出。 是一个称为学习速率的常数,其作用是控制每一步调整权的幅度。 每次从训练数据中取出一个样本的输入向量 ,使用感知器计算其输出 ,再根据上面的规则来调整权重。每处理一个样本就调整一次权重。经过 多轮迭代后(即全部的训练数据被反复处理多轮),就可以训练出感知器的权重,使之实现目标函数。 编程实战:实现感知器 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/perceptron.py (python2.7) 对于程序员来说,没有什么比亲自动手实现学得更快了,而且,很多时候一行代码抵得上千言万语。接下来我们就将实现一个感知器。 下面是一些说明: 使用python语言。python在机器学习领域用的很广泛,而且,写python程序真的很轻松。 面向对象编程。面向对象是特别好的管理复杂度的工具,应对复杂问题时,用面向对象设计方法很容易将复杂问题拆解为多个简单问题,从 而解救我们的大脑。 没有使用numpy。numpy实现了很多基础算法,对于实现机器学习算法来说是个必备的工具。但为了降低读者理解的难度,下面的代码只用 到了基本的python(省去您去学习numpy的时间)。 下面是感知器类的实现,非常简单。去掉注释只有27行,而且还包括为了美观(每行不超过60个字符)而增加的很多换行。 1. class Perceptron(object): 2. def __init__(self, input_num, activator): 3. ''' 4. 初始化感知器,设置输入参数的个数,以及激活函数。 5. 激活函数的类型为double -> double 6. ''' 7. self.activator = activator 8. # 权重向量初始化为0 9. self.weights = [0.0 for _ in range(input_num)] 10. # 偏置项初始化为0 11. self.bias = 0.0 12. 13. def __str__(self): 14. ''' 15. 打印学习到的权重、偏置项 16. ''' 17. return 'weights\t:%s\nbias\t:%f\n' % (self.weights, self.bias) 18. 19. 20. def predict(self, input_vec): 21. ''' 22. 输入向量,输出感知器的计算结果 23. ''' 24. # 把input_vec[x1,x2,x3...]和weights[w1,w2,w3,...]打包在一起 25. # 变成[(x1,w1),(x2,w2),(x3,w3),...] 26. # 然后利用map函数计算[x1*w1, x2*w2, x3*w3] 27. # 最后利用reduce求和 28. return self.activator( 29. reduce(lambda a, b: a + b, 30. map(lambda (x, w): x * w, 31. zip(input_vec, self.weights)) 32. , 0.0) + self.bias) 33. 34. def train(self, input_vecs, labels, iteration, rate): 35. ''' wi b wi b ← + Δwi wi ← b + Δb Δwi Δb = η(t − y)xi = η(t − y) wi xi b b xb t y η x y 36. 输入训练数据:一组向量、与每个向量对应的label;以及训练轮数、学习率 37. ''' 38. for i in range(iteration): 39. self._one_iteration(input_vecs, labels, rate) 40. 41. def _one_iteration(self, input_vecs, labels, rate): 42. ''' 43. 一次迭代,把所有的训练数据过一遍 44. ''' 45. # 把输入和输出打包在一起,成为样本的列表[(input_vec, label), ...] 46. # 而每个训练样本是(input_vec, label) 47. samples = zip(input_vecs, labels) 48. # 对每个样本,按照感知器规则更新权重 49. for (input_vec, label) in samples: 50. # 计算感知器在当前权重下的输出 51. output = self.predict(input_vec) 52. # 更新权重 53. self._update_weights(input_vec, output, label, rate) 54. 55. def _update_weights(self, input_vec, output, label, rate): 56. ''' 57. 按照感知器规则更新权重 58. ''' 59. # 把input_vec[x1,x2,x3,...]和weights[w1,w2,w3,...]打包在一起 60. # 变成[(x1,w1),(x2,w2),(x3,w3),...] 61. # 然后利用感知器规则更新权重 62. delta = label - output 63. self.weights = map( 64. lambda (x, w): w + rate * delta * x, 65. zip(input_vec, self.weights)) 66. # 更新bias 67. self.bias += rate * delta 接下来,我们利用这个感知器类去实现and函数。 1. def f(x): 2. ''' 3. 定义激活函数f 4. ''' 5. return 1 if x > 0 else 0 6. 7. 8. def get_training_dataset(): 9. ''' 10. 基于and真值表构建训练数据 11. ''' 12. # 构建训练数据 13. # 输入向量列表 14. input_vecs = [[1,1], [0,0], [1,0], [0,1]] 15. # 期望的输出列表,注意要与输入一一对应 16. # [1,1] -> 1, [0,0] -> 0, [1,0] -> 0, [0,1] -> 0 17. labels = [1, 0, 0, 0] 18. return input_vecs, labels 19. 20. 21. def train_and_perceptron(): 22. ''' 23. 使用and真值表训练感知器 24. ''' 25. # 创建感知器,输入参数个数为2(因为and是二元函数),激活函数为f 26. p = Perceptron(2, f) 27. # 训练,迭代10轮, 学习速率为0.1 28. input_vecs, labels = get_training_dataset() 29. p.train(input_vecs, labels, 10, 0.1) 30. #返回训练好的感知器 31. return p 32. 33. 34. if __name__ == '__main__': 35. # 训练and感知器 36. and_perception = train_and_perceptron() 37. # 打印训练获得的权重 38. print and_perception 39. # 测试 40. print '1 and 1 = %d' % and_perception.predict([1, 1]) 41. print '0 and 0 = %d' % and_perception.predict([0, 0]) 42. print '1 and 0 = %d' % and_perception.predict([1, 0]) 43. print '0 and 1 = %d' % and_perception.predict([0, 1]) 将上述程序保存为perceptron.py文件,通过命令行执行这个程序,其运行结果为: 神奇吧!感知器竟然完全实现了and函数。读者可以尝试一下利用感知器实现其它函数。 小结 终于看(写)到小结了...,大家都累了。对于零基础的你来说,走到这里应该已经很烧脑了吧。没关系,休息一下。值得高兴的是,你终于已经走 出了深度学习入门的第一步,这是巨大的进步;坏消息是,这仅仅是最简单的部分,后面还有无数艰难险阻等着你。不过,你学的困难往往意味 着别人学的也困难,掌握一门高门槛的技艺,进可糊口退可装逼,是很值得的。 下一篇文章,我们将讨论另外一种感知器:线性单元,并由此引出一种可能是最最重要的优化算法:梯度下降算法。 参考资料 1. Tom M. Mitchell, "机器学习", 曾华军等译, 机械工业出版社 零基础入门深度学习(2) - 线性单元和梯度下降 机器学习 深度学习入门 无论即将到来的是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序 员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?现在救命稻草来了,《零基础入门深度学习》系列文章旨 在讲帮助爱编程的你从零基础达到入门级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这是专门为程序员写 的文章。虽然文中会有很多公式你也许看不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序 员,所以我写的代码也不会很差)。 文章列表 零基础入门深度学习(1) - 感知器 零基础入门深度学习(2) - 线性单元和梯度下降 零基础入门深度学习(3) - 神经网络和反向传播算法 零基础入门深度学习(4) - 卷积神经网络 零基础入门深度学习(5) - 循环神经网络 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 零基础入门深度学习(7) - 递归神经网络 往期回顾 在上一篇文章中,我们已经学会了编写一个简单的感知器,并用它来实现一个线性分类器。你应该还记得用来训练感知器的『感知器规则』。然 而,我们并没有关心这个规则是怎么得到的。本文通过介绍另外一种『感知器』,也就是『线性单元』,来说明关于机器学习一些基本的概念, 比如模型、目标函数、优化算法等等。这些概念对于所有的机器学习算法来说都是通用的,掌握了这些概念,就掌握了机器学习的基本套路。 线性单元是啥 感知器有一个问题,当面对的数据集不是线性可分的时候,『感知器规则』可能无法收敛,这意味着我们永远也无法完成一个感知器的训练。为 了解决这个问题,我们使用一个可导的线性函数来替代感知器的阶跃函数,这种感知器就叫做线性单元。线性单元在面对线性不可分的数据集 时,会收敛到一个最佳的近似上。 为了简单起见,我们可以设置线性单元的激活函数 为 这样的线性单元如下图所示 对比此前我们讲过的感知器 f f(x) = x 这样替换了激活函数 之后,线性单元将返回一个实数值而不是0,1分类。因此线性单元用来解决回归问题而不是分类问题。 线性单元的模型 当我们说模型时,我们实际上在谈论根据输入 预测输出 的算法。比如, 可以是一个人的工作年限, 可以是他的月薪,我们可以用某种算法来 根据一个人的工作年限来预测他的收入。比如: 函数 叫做假设,而 、 是它的参数。我们假设参数 ,参数 ,如果一个人的工作年限是5年的话,我们的模型会预测他的 月薪为 你也许会说,这个模型太不靠谱了。是这样的,因为我们考虑的因素太少了,仅仅包含了工作年限。如果考虑更多的因素,比如所处的行业、公 司、职级等等,可能预测就会靠谱的多。我们把工作年限、行业、公司、职级这些信息,称之为特征。对于一个工作了5年,在IT行业,百度工 作,职级T6这样的人,我们可以用这样的一个特征向量来表示他 = (5, IT, 百度, T6)。 既然输入 变成了一个具备四个特征的向量,相对应的,仅仅一个参数 就不够用了,我们应该使用4个参数 ,每个特征对应一 个。这样,我们的模型就变成 其中, 对应工作年限, 对应行业, 对应公司, 对应职级。 为了书写和计算方便,我们可以令 等于 ,同时令 对应于特征 。由于 其实并不存在,我们可以令它的值永远为1。也就是说 这样上面的式子就可以写成 我们还可以把上式写成向量的形式 长成这种样子模型就叫做线性模型,因为输出 就是输入特征 的线性组合。 监督学习和无监督学习 接下来,我们需要关心的是这个模型如何训练,也就是参数 取什么值最合适。 机器学习有一类学习方法叫做监督学习,它是说为了训练一个模型,我们要提供这样一堆训练样本:每个训练样本既包括输入特征 ,也包括对应 的输出 ( 也叫做标记,label )。也就是说,我们要找到很多人,我们既知道他们的特征(工作年限,行业...),也知道他们的收入。我们用这样的样 本去训练模型,让模型既看到我们提出的每个问题(输入特征 ),也看到对应问题的答案(标记 )。当模型看到足够多的样本之后,它就能总结出其 中的一些规律。然后,就可以预测那些它没看过的输入所对应的答案了。 另外一类学习方法叫做无监督学习,这种方法的训练样本中只有 而没有 。模型可以总结出特征 的一些规律,但是无法知道其对应的答案 。 很多时候,既有 又有 的训练样本是很少的,大部分样本都只有 。比如在语音到文本(STT)的识别任务中, 是语音, 是这段语音对应的文本。 我们很容易获取大量的语音录音,然而把语音一段一段切分好并标注上对应文字则是非常费力气的事情。这种情况下,为了弥补带标注样本的不 f x y x y y = h(x) = w ∗ x + b h(x) w b w = 1000 b = 500 y = h(x) = 1000 ∗ 5 + 500 = 5500(元) x x w ,,,w1 w2 w3 w4 y = h(x) = ∗ + ∗ + ∗ + ∗ + bw1 x1 w2 x2 w3 x3 w4 x4 x1 x2 x3 x4 w0 b w0 x0 x0 b = ∗ 其中 = 1w0 x0 x0 y = h(x) = ∗ + ∗ + ∗ + ∗ + bw1 x1 w2 x2 w3 x3 w4 x4 = ∗ + ∗ + ∗ + ∗ + ∗w0 x0 w1 x1 w2 x2 w3 x3 w4 x4 y = h(x) = x (式1)wT y ,,,...x1 x2 x3 w x y y x y x y x y x y x x y 足,我们可以用无监督学习方法先做一些聚类,让模型总结出哪些音节是相似的,然后再用少量的带标注的训练样本,告诉模型其中一些音节对 应的文字。这样模型就可以把相似的音节都对应到相应文字上,完成模型的训练。 线性单元的目标函数 现在,让我们只考虑监督学习。 在监督学习下,对于一个样本,我们知道它的特征 ,以及标记 。同时,我们还可以根据模型 计算得到输出 。注意这里面我们用 表示训 练样本里面的标记,也就是实际值;用带上划线的 表示模型计算的出来的预测值。我们当然希望模型计算出来的 和 越接近越好。 数学上有很多方法来表示的 和 的接近程度,比如我们可以用 和 的差的平方的 来表示它们的接近程度 我们把 叫做单个样本的误差。至于为什么前面要乘 ,是为了后面计算方便。 训练数据中会有很多样本,比如 个,我们可以用训练数据中所有样本的误差的和,来表示模型的误差 ,也就是 上式的 表示第一个样本的误差, 表示第二个样本的误差......。 我们还可以把上面的式子写成和式的形式。使用和式,不光书写起来简单,逼格也跟着暴涨,一举两得。所以一定要写成下面这样 其中 (式2)中, 表示第 个训练样本的特征, 表示第 个样本的标记,我们也可以用元组 表示第 训练样本。 则是模型对第 个样本 的预测值。 我们当然希望对于一个训练数据集来说,误差最小越好,也就是(式2)的值越小越好。对于特定的训练数据集来说, 的值都是已知的, 所以(式2)其实是参数 的函数。 由此可见,模型的训练,实际上就是求取到合适的 ,使(式2)取得最小值。这在数学上称作优化问题,而 就是我们优化的目标,称之为目标 函数。 梯度下降优化算法 大学时我们学过怎样求函数的极值。函数 的极值点,就是它的导数 的那个点。因此我们可以通过解方程 ,求得函 数的极值点 。 不过对于计算机来说,它可不会解方程。但是它可以凭借强大的计算能力,一步一步的去把函数的极值点『试』出来。如下图所示: x y h(x) y¯ y y¯ y¯ y y¯ y y¯ y 1 2 e = (y −1 2 y¯)2 e 1 2 N E E = + + +. . . +e(1) e(2) e(3) e(n) e(1) e(2) E = + + +. . . +e(1) e(2) e(3) e(n) = ∑ i=1 n e(i) = ( − (式2)1 2 ∑ i=1 n y(i) y¯(i))2 y¯(i) = h()x(i) = wT x(i) x(i) i y(i) i (,)x(i) y(i) i y¯(i) i (,)x(i) y(i) w E(w) = ( −1 2 ∑ i=1 n y(i) y¯(i))2 = ( −1 2 ∑ i=1 n y(i) wTx(i))2 w E(w) y = f(x) (x) = 0f ′ (x) = 0f ′ (,)x0 y0 首先,我们随便选择一个点开始,比如上图的 点。接下来,每次迭代修改 的为 ,经过数次迭代后最终达到函数最小值点。 你可能要问了,为啥每次修改 的值,都能往函数最小值那个方向前进呢?这里的奥秘在于,我们每次都是向函数 的梯度的相反方向来 修改 。什么是梯度呢?翻开大学高数课的课本,我们会发现梯度是一个向量,它指向函数值上升最快的方向。显然,梯度的反方向当然就是函数 值下降最快的方向了。我们每次沿着梯度相反方向去修改 的值,当然就能走到函数的最小值附近。之所以是最小值附近而不是最小值那个点,是 因为我们每次移动的步长不会那么恰到好处,有可能最后一次迭代走远了越过了最小值那个点。步长的选择是门手艺,如果选择小了,那么就会 迭代很多轮才能走到最小值附近;如果选择大了,那可能就会越过最小值很远,收敛不到一个好的点上。 按照上面的讨论,我们就可以写出梯度下降算法的公式 其中, 是梯度算子, 就是指 的梯度。 是步长,也称作学习速率。 对于上一节列出的目标函数(式2) 梯度下降算法可以写成 聪明的你应该能想到,如果要求目标函数的最大值,那么我们就应该用梯度上升算法,它的参数修改规则是 下面,请先做几次深呼吸,让你的大脑补充足够的新鲜的氧气,我们要来求取 ,然后带入上式,就能得到线性单元的参数修改规则。 关于 的推导过程,我单独把它们放到一节中。您既可以选择慢慢看,也可以选择无视。在这里,您只需要知道,经过一大串推导,目标函 数 的梯度是 因此,线性单元的参数修改规则最后是这个样子 有了上面这个式子,我们就可以根据它来写出训练线性单元的代码了。 需要说明的是,如果每个样本有M个特征,则上式中的 都是M+1维向量(因为我们加上了一个恒为1的虚拟特征 ,参考前面的内容),而 是 标量。用高逼格的数学符号表示,就是 x0 x ,,,...x1 x2 x3 x y = f(x) x x = − η∇f(x)xnew xold ∇ ∇f(x) f(x) η E(w) = ( −1 2 ∑ i=1 n y(i) y¯(i))2 = − η∇E(w)wnew wold = + η∇E(w)wnew wold ∇E(w) ∇E(w) E(w) ∇E(w) = − ( − )∑ i=1 n y(i) y¯(i) x(i) = + η ( − ) (式3)wnew wold ∑ i=1 n y(i) y¯(i) x(i) x, w x0 y 为了让您看明白说的是啥,我吐血写下下面这个解释(写这种公式可累可累了)。因为 是M+1维列向量,所以(式3)可以写成 如果您还是没看明白,建议您也吐血再看一下大学时学过的《线性代数》吧。 的推导 这一节你尽可以跳过它,并不太会影响到全文的理解。当然如果你非要弄明白每个细节,那恭喜你骚年,机器学习的未来一定是属于你的。 首先,我们先做一个简单的前戏。我们知道函数的梯度的定义就是它相对于各个变量的偏导数,所以我们写下下面的式子 可接下来怎么办呢?我们知道和的导数等于导数的和,所以我们可以先把求和符号 里面的导数求出来,然后再把它们加在一起就行了,也就是 现在我们可以不管高大上的 了,先专心把里面的导数求出来。 我们知道, 是与 无关的常数,而 ,下面我们根据链式求导法则来求导(上大学时好像叫复合函数求导法则) 我们分别计算上式等号右边的两个偏导数 代入,我们求得 里面的偏导数是 最后代入 ,求得 x, w ∈ R(M+1) y ∈ R1 w, x = + η ( − ) ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ w0 w1 w2 ... wm ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ new ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ w0 w1 w2 ... wm ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ old ∑ i=1 n y(i) y¯(i) ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢ 1 x(i) 1 x(i) 2 ... x(i) m ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥ ∇E(w) ∇E(w) = E(w)∂ ∂w = ( −∂ ∂w 1 2 ∑ i=1 n y(i) y¯(i))2 ∑ = ( −∂ ∂w 1 2 ∑ i=1 n y(i) y¯(i))2 ( −1 2 ∑ i=1 n ∂ ∂w y(i) y¯(i))2 ∑ = ( −∂ ∂w y(i) y¯(i))2 ( − 2 + )∂ ∂w y(i)2 y¯(i)y(i) y¯(i)2 y w = xy¯ wT =∂E(w) ∂w ∂E()y¯ ∂y¯ ∂y¯ ∂w =∂E(w) ∂y¯ = =∂y¯ ∂w = ( − 2 + )∂ ∂y¯ y(i)2 y¯(i)y(i) y¯(i)2 − 2 + 2y(i) y¯(i) x∂ ∂w wT x ∑ = ( −∂ ∂w y(i) y¯(i))2 2(− + )xy(i) y¯(i) ∇E(w) 至此,大功告成。 随机梯度下降算法(Stochastic Gradient Descent, SGD) 如果我们根据(式3)来训练模型,那么我们每次更新 的迭代,要遍历训练数据中所有的样本进行计算,我们称这种算法叫做批梯度下降(Batch Gradient Descent) 。如果我们的样本非常大,比如数百万到数亿,那么计算量异常巨大。因此,实用的算法是SGD算法。在SGD算法中,每次 更新 的迭代,只计算一个样本。这样对于一个具有数百万样本的训练数据,完成一次遍历就会对 更新数百万次,效率大大提升。由于样本的噪 音和随机性,每次更新 并不一定按照减少 的方向。然而,虽然存在一定随机性,大量的更新总体上沿着减少 的方向前进的,因此最后也能收 敛到最小值附近。下图展示了SGD和BGD的区别 如上图,椭圆表示的是函数值的等高线,椭圆中心是函数的最小值点。红色是BGD的逼近曲线,而紫色是SGD的逼近曲线。我们可以看到BGD是 一直向着最低点前进的,而SGD明显躁动了许多,但总体上仍然是向最低点逼近的。 最后需要说明的是,SGD不仅仅效率高,而且随机性有时候反而是好事。今天的目标函数是一个『凸函数』,沿着梯度反方向就能找到全局唯一 的最小值。然而对于非凸函数来说,存在许多局部最小值。随机性有助于我们逃离某些很糟糕的局部最小值,从而获得一个更好的模型。 实现线性单元 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/linear_unit.py (python2.7) 接下来,让我们撸一把代码。 因为我们已经写了感知器的代码,因此我们先比较一下感知器模型和线性单元模型,看看哪些代码能够复用。 算法 感知器 线性单元 模型 训练规则 比较的结果令人震惊,原来除了激活函数 不同之外,两者的模型和训练规则是一样的(在上表中,线性单元的优化算法是SGD算法)。那么,我们 只需要把感知器的激活函数进行替换即可。感知器的代码请参考上一篇文章零基础入门深度学习(1) - 感知器,这里就不再重复了。对于一个养成 良好习惯的程序员来说,重复代码是不可忍受的。大家应该把代码保存在一个代码库中(比如git)。 1. from perceptron import Perceptron 2. 3. #定义激活函数f 4. f = lambda x: x 5. ∇E(w) = ( −1 2 ∑ i=1 n ∂ ∂w y(i) y¯(i))2 = 2(− + )x1 2 ∑ i=1 n y(i) y¯(i) = − ( − )x∑ i=1 n y(i) y¯(i) w w w w E E h(x) y = f( x)wT f(z) = { 1 z > 0 0 otherwise y = f( x)wT f(z) = z w ← w + η(y − )xy¯ w ← w + η(y − )xy¯ f 6. class LinearUnit(Perceptron): 7. def __init__(self, input_num): 8. '''初始化线性单元,设置输入参数的个数''' 9. Perceptron.__init__(self, input_num, f) 通过继承Perceptron,我们仅用几行代码就实现了线性单元。这再次证明了面向对象编程范式的强大。 接下来,我们用简单的数据进行一下测试。 1. def get_training_dataset(): 2. ''' 3. 捏造5个人的收入数据 4. ''' 5. # 构建训练数据 6. # 输入向量列表,每一项是工作年限 7. input_vecs = [[5], [3], [8], [1.4], [10.1]] 8. # 期望的输出列表,月薪,注意要与输入一一对应 9. labels = [5500, 2300, 7600, 1800, 11400] 10. return input_vecs, labels 11. 12. 13. def train_linear_unit(): 14. ''' 15. 使用数据训练线性单元 16. ''' 17. # 创建感知器,输入参数的特征数为1(工作年限) 18. lu = LinearUnit(1) 19. # 训练,迭代10轮, 学习速率为0.01 20. input_vecs, labels = get_training_dataset() 21. lu.train(input_vecs, labels, 10, 0.01) 22. #返回训练好的线性单元 23. return lu 24. 25. 26. if __name__ == '__main__': 27. '''训练线性单元''' 28. linear_unit = train_linear_unit() 29. # 打印训练获得的权重 30. print linear_unit 31. # 测试 32. print 'Work 3.4 years, monthly salary = %.2f' % linear_unit.predict([3.4]) 33. print 'Work 15 years, monthly salary = %.2f' % linear_unit.predict([15]) 34. print 'Work 1.5 years, monthly salary = %.2f' % linear_unit.predict([1.5]) 35. print 'Work 6.3 years, monthly salary = %.2f' % linear_unit.predict([6.3]) 程序运行结果如下图 拟合的直线如下图 小结 事实上,一个机器学习算法其实只有两部分 模型 从输入特征 预测输入 的那个函数 目标函数 目标函数取最小(最大)值时所对应的参数值,就是模型的参数的最优值。很多时候我们只能获得目标函数的局部最小(最大)值,因此 也只能得到模型参数的局部最优值。 因此,如果你想最简洁的介绍一个算法,列出这两个函数就行了。 接下来,你会用优化算法去求取目标函数的最小(最大)值。[随机]梯度{下降|上升}算法就是一个优化算法。针对同一个目标函数,不同的优化算法 会推导出不同的训练规则。我们后面还会讲其它的优化算法。 其实在机器学习中,算法往往并不是关键,真正的关键之处在于选取特征。选取特征需要我们人类对问题的深刻理解,经验、以及思考。而神经 网络算法的一个优势,就在于它能够自动学习到应该提取什么特征,从而使算法不再那么依赖人类,而这也是神经网络之所以吸引人的一个方 面。 现在,经过漫长的烧脑,你已经具备了学习神经网络的必备知识。下一篇文章,我们将介绍本系列文章的主角:神经网络,以及用来训练神经网 络的大名鼎鼎的算法:反向传播算法。至于现在,我们应该暂时忘记一切,尽情奖励自己一下吧。 x y h(x) 本想放个日料的,怕被说成不爱国,换成毛爷爷家的红烧肉吧:P 参考资料 1. Tom M. Mitchell, "机器学习", 曾华军等译, 机械工业出版社 零基础入门深度学习(3) - 神经网络和反向传播算法 机器学习 深度学习入门 无论即将到来的是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序 员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?现在救命稻草来了,《零基础入门深度学习》系列文章旨 在讲帮助爱编程的你从零基础达到入门级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这是专门为程序员写 的文章。虽然文中会有很多公式你也许看不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序 员,所以我写的代码也不会很差)。 文章列表 零基础入门深度学习(1) - 感知器 零基础入门深度学习(2) - 线性单元和梯度下降 零基础入门深度学习(3) - 神经网络和反向传播算法 零基础入门深度学习(4) - 卷积神经网络 零基础入门深度学习(5) - 循环神经网络 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 零基础入门深度学习(7) - 递归神经网络 往期回顾 在上一篇文章中,我们已经掌握了机器学习的基本套路,对模型、目标函数、优化算法这些概念有了一定程度的理解,而且已经会训练单个的感 知器或者线性单元了。在这篇文章中,我们将把这些单独的单元按照一定的规则相互连接在一起形成神经网络,从而奇迹般的获得了强大的学习 能力。我们还将介绍这种网络的训练算法:反向传播算法。最后,我们依然用代码实现一个神经网络。如果您能坚持到本文的结尾,将会看到我 们用自己实现的神经网络去识别手写数字。现在请做好准备,您即将双手触及到深度学习的大门。 神经元 神经元和感知器本质上是一样的,只不过我们说感知器的时候,它的激活函数是阶跃函数;而当我们说神经元时,激活函数往往选择为sigmoid函 数或tanh函数。如下图所示: 计算一个神经元的输出的方法和计算一个感知器的输出是一样的。假设神经元的输入是向量 ,权重向量是 (偏置项是 ),激活函数是sigmoid 函数,则其输出 : sigmoid函数的定义如下: 将其带入前面的式子,得到 sigmoid函数是一个非线性函数,值域是(0,1)。函数图像如下图所示 sigmoid函数的导数是: 可以看到,sigmoid函数的导数非常有趣,它可以用sigmoid函数自身来表示。这样,一旦计算出sigmoid函数的值,计算它的导数的值就非常方 便。 神经网络是啥 x⃗  w⃗  w0 y y = sigmoid( ⋅ )(式1)w⃗ T x⃗  sigmoid(x) = 1 1 + e−x y = 1 1 + e− ⋅w⃗ T x⃗  令y = sigmoid(x) 则 = y(1 − y)y′ 神经网络其实就是按照一定规则连接起来的多个神经元。上图展示了一个全连接(full connected, FC) 神经网络,通过观察上面的图,我们可以发 现它的规则包括: 神经元按照层来布局。最左边的层叫做输入层,负责接收输入数据;最右边的层叫输出层,我们可以从这层获取神经网络输出数据。输入层 和输出层之间的层叫做隐藏层,因为它们对于外部来说是不可见的。 同一层的神经元之间没有连接。 第N层的每个神经元和第N-1层的所有神经元相连(这就是full connected的含义),第N-1层神经元的输出就是第N层神经元的输入。 每个连接都有一个权值。 上面这些规则定义了全连接神经网络的结构。事实上还存在很多其它结构的神经网络,比如卷积神经网络(CNN)、循环神经网络(RNN),他们都具 有不同的连接规则。 计算神经网络的输出 神经网络实际上就是一个输入向量 到输出向量 的函数,即: 根据输入计算神经网络的输出,需要首先将输入向量 的每个元素 的值赋给神经网络的输入层的对应神经元,然后根据式1依次向前计算每一层 的每个神经元的值,直到最后一层输出层的所有神经元的值计算完毕。最后,将输出层每个神经元的值串在一起就得到了输出向量 。 接下来举一个例子来说明这个过程,我们先给神经网络的每个单元写上编号。 如上图,输入层有三个节点,我们将其依次编号为1、2、3;隐藏层的4个节点,编号依次为4、5、6、7;最后输出层的两个节点编号为8、9。因 为我们这个神经网络是全连接网络,所以可以看到每个节点都和上一层的所有节点有连接。比如,我们可以看到隐藏层的节点4,它和输入层的三 个节点1、2、3之间都有连接,其连接上的权重分别为 。那么,我们怎样计算节点4的输出值 呢? 为了计算节点4的输出值,我们必须先得到其所有上游节点(也就是节点1、2、3)的输出值。节点1、2、3是输入层的节点,所以,他们的输出 值就是输入向量 本身。按照上图画出的对应关系,可以看到节点1、2、3的输出值分别是 。我们要求输入向量的维度和输入层神经元 个数相同,而输入向量的某个元素对应到哪个输入节点是可以自由决定的,你偏非要把 赋值给节点2也是完全没有问题的,但这样除了把自己弄 晕之外,并没有什么价值。 一旦我们有了节点1、2、3的输出值,我们就可以根据式1计算节点4的输出值 : x⃗  y⃗  = ( )y⃗  fnetwork x⃗  x⃗  xi y⃗  ,,w41 w42 w43 a4 x⃗  ,,x1 x2 x3 x1 a4 上式的 是节点4的偏置项,图中没有画出来。而 分别为节点1、2、3到节点4连接的权重,在给权重 编号时,我们把目标节点 的编号 放在前面,把源节点的编号 放在后面。 同样,我们可以继续计算出节点5、6、7的输出值 。这样,隐藏层的4个节点的输出值就计算完成了,我们就可以接着计算输出层的节点 8的输出值 : 同理,我们还可以计算出 的值。这样输出层所有节点的输出值计算完毕,我们就得到了在输入向量 时,神经网络的输出向量 。这里我们也看到,输出向量的维度和输出层神经元个数相同。 神经网络的矩阵表示 神经网络的计算如果用矩阵来表示会很方便(当然逼格也更高),我们先来看看隐藏层的矩阵表示。 首先我们把隐藏层4个节点的计算依次排列出来: 接着,定义网络的输入向量 和隐藏层每个节点的权重向量 。令 代入到前面的一组式子,得到: 现在,我们把上述计算 的四个式子写到一个矩阵里面,每个式子作为矩阵的一行,就可以利用矩阵来表示它们的计算了。令 带入前面的一组式子,得到 在式2中, 是激活函数,在本例中是 函数; 是某一层的权重矩阵; 是某层的输入向量; 是某层的输出向量。式2说明神经网络的每 一层的作用实际上就是先将输入向量左乘一个数组进行线性变换,得到一个新的向量,然后再对这个向量逐元素应用一个激活函数。 a4 = sigmoid( ⋅ )w⃗ T x⃗  = sigmoid( + + + )w41x1 w42x2 w43x3 w4b w4b ,,w41 w42 w43 wji j i ,,a5 a6 a7 y1 y1 = sigmoid( ⋅ )w⃗ T a⃗  = sigmoid( + + + + )w84a4 w85a5 w86a6 w87a7 w8b y2 =x⃗  ⎡ ⎣ ⎢ x1 x2 x3 ⎤ ⎦ ⎥ = []y⃗  y1 y2 = sigmoid( + + + )a4 w41x1 w42x2 w43x3 w4b = sigmoid( + + + )a5 w51x1 w52x2 w53x3 w5b = sigmoid( + + + )a6 w61x1 w62x2 w63x3 w6b = sigmoid( + + + )a7 w71x1 w72x2 w73x3 w7b x⃗  wj → x⃗  w⃗ 4 w⃗ 5 w⃗ 6 w⃗ 7 f = ⎡ ⎣ ⎢⎢⎢ x1 x2 x3 1 ⎤ ⎦ ⎥⎥⎥ = [ , , , ]w41 w42 w43 w4b = [ , , , ]w51 w52 w53 w5b = [ , , , ]w61 w62 w63 w6b = [ , , , ]w71 w72 w73 w7b = sigmoid a4 a5 a6 a7 = f( ⋅ )w4 → x⃗  = f( ⋅ )w5 → x⃗  = f( ⋅ )w6 → x⃗  = f( ⋅ )w7 → x⃗  ,,,a4 a5 a6 a7 = , W = = , f( ) =a⃗  ⎡ ⎣ ⎢⎢⎢ a4 a5 a6 a7 ⎤ ⎦ ⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢ w⃗ 4 w⃗ 5 w⃗ 6 w⃗ 7 ⎤ ⎦ ⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢ ,,,w41 w42 w43 w4b ,,,w51 w52 w53 w5b ,,,w61 w62 w63 w6b ,,,w71 w72 w73 w7b ⎤ ⎦ ⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢ x1 x2 x3 . . . ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢ f()x1 f()x2 f()x3 . . . ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥ = f(W ⋅ )(式2)a⃗  x⃗  f sigmoid W x⃗  a⃗  每一层的算法都是一样的。比如,对于包含一个输入层,一个输出层和三个隐藏层的神经网络,我们假设其权重矩阵分别为 , 每个隐藏层的输出分别是 ,神经网络的输入为 ,神经网络的输入为 ,如下图所示: 则每一层的输出向量的计算可以表示为: 这就是神经网络输出值的计算方法。 神经网络的训练 现在,我们需要知道一个神经网络的每个连接上的权值是如何得到的。我们可以说神经网络是一个模型,那么这些权值就是模型的参数,也就是 模型要学习的东西。然而,一个神经网络的连接方式、网络的层数、每层的节点数这些参数,则不是学习出来的,而是人为事先设置的。对于这 些人为设置的参数,我们称之为超参数(Hyper-Parameters) 。 接下来,我们将要介绍神经网络的训练算法:反向传播算法。 反向传播算法(Back Propagation) 我们首先直观的介绍反向传播算法,最后再来介绍这个算法的推导。当然读者也可以完全跳过推导部分,因为即使不知道如何推导,也不影响你 写出来一个神经网络的训练代码。事实上,现在神经网络成熟的开源实现多如牛毛,除了练手之外,你可能都没有机会需要去写一个神经网络。 我们以监督学习为例来解释反向传播算法。在零基础入门深度学习(2) - 线性单元和梯度下降一文中我们介绍了什么是监督学习,如果忘记了可以 再看一下。另外,我们设神经元的激活函数 为 函数(不同激活函数的计算公式不同,详情见反向传播算法的推导一节)。 我们假设每个训练样本为 ,其中向量 是训练样本的特征,而 是样本的目标值。 首先,我们根据上一节介绍的算法,用样本的特征 ,计算出神经网络中每个隐藏层节点的输出 ,以及输出层每个节点的输出 。 然后,我们按照下面的方法计算出每个节点的误差项 : 对于输出层节点 , 其中, 是节点 的误差项, 是节点 的输出值, 是样本对应于节点 的目标值。举个例子,根据上图,对于输出层节点8来说,它的输出值是 ,而样本的目标值是 ,带入上面的公式得到节点8的误差项 应该是: ,,,W1 W2 W3 W4 ,,a⃗ 1 a⃗ 2 a⃗ 3 x⃗  y⃗  = f( ⋅ )a⃗ 1 W1 x⃗  = f( ⋅ )a⃗ 2 W2 a⃗ 1 = f( ⋅ )a⃗ 3 W3 a⃗ 2 = f( ⋅ )y⃗  W4 a⃗ 3 f sigmoid (,)x⃗  t ⃗  x⃗  t ⃗  x⃗  ai yi δi i = (1 − )( − ) (式3)δi yi yi ti yi δi i yi i ti i y1 t1 δ8 对于隐藏层节点, 其中, 是节点 的输出值, 是节点 到它的下一层节点 的连接的权重, 是节点 的下一层节点 的误差项。例如,对于隐藏层节点4来说, 计算方法如下: 最后,更新每个连接上的权值: 其中, 是节点 到节点 的权重, 是一个成为学习速率的常数, 是节点 的误差项, 是节点 传递给节点 的输入。例如,权重 的更新 方法如下: 类似的,权重 的更新方法如下: 偏置项的输入值永远为1。例如,节点4的偏置项 应该按照下面的方法计算: 我们已经介绍了神经网络每个节点误差项的计算和权重更新方法。显然,计算一个节点的误差项,需要先计算每个与其相连的下一层节点的误差 项。这就要求误差项的计算顺序必须是从输出层开始,然后反向依次计算每个隐藏层的误差项,直到与输入层相连的那个隐藏层。这就是反向传 播算法的名字的含义。当所有节点的误差项计算完毕后,我们就可以根据式5来更新所有的权重。 以上就是基本的反向传播算法,并不是很复杂,您弄清楚了么? 反向传播算法的推导 反向传播算法其实就是链式求导法则的应用。然而,这个如此简单且显而易见的方法,却是在Roseblatt提出感知器算法将近30年之后才被发明和 普及的。对此,Bengio这样回应道: 很多看似显而易见的想法只有在事后才变得显而易见。 接下来,我们用链式求导法则来推导反向传播算法,也就是上一小节的式3、式4、式5。 前方高能预警——接下来是数学公式重灾区,读者可以酌情阅读,不必强求。 按照机器学习的通用套路,我们先确定神经网络的目标函数,然后用随机梯度下降优化算法去求目标函数最小值时的参数值。 我们取网络所有输出层节点的误差平方和作为目标函数: 其中, 表示是样本 的误差。 然后,我们用文章零基础入门深度学习(2) - 线性单元和梯度下降中介绍的随机梯度下降算法对目标函数进行优化: 随机梯度下降算法也就是需要求出误差 对于每个权重 的偏导数(也就是梯度),怎么求呢? = (1 − )( − )δ8 y1 y1 t1 y1 = (1 − ) (式4)δi ai ai ∑ k∈outputs wkiδk ai i wki i k δk i k = (1 − )( + )δ4 a4 a4 w84δ8 w94δ9 ← + η (式5)wji wji δj xji wji i j η δj j xji i j 4w8 ← + ηw84 w84 δ8 a4 w41 ← + ηw41 w41 δ4 x1 w4b ← + ηw4b w4b δ4 ≡ ( −Ed 1 2 ∑ i∈outputs ti yi )2 Ed d ← − ηwji wji ∂Ed ∂wji Ed wji 观察上图,我们发现权重 仅能通过影响节点 的输入值影响网络的其它部分,设 是节点 的加权输入,即 是 的函数,而 是 的函数。根据链式求导法则,可以得到: 上式中, 是节点 传递给节点 的输入值,也就是节点 的输出值。 对于 的推导,需要区分输出层和隐藏层两种情况。 输出层权值训练 对于输出层来说, 仅能通过节点 的输出值 来影响网络其它部分,也就是说 是 的函数,而 是 的函数,其中 。所以我们可以再次使用链式求导法则: 考虑上式第一项: 考虑上式第二项: 将第一项和第二项带入,得到: 如果令 ,也就是一个节点的误差项 是网络误差对这个节点输入的偏导数的相反数。带入上式,得到: 上式就是式3。 将上述推导带入随机梯度下降公式,得到: wji j netj j netj = ⋅wj → xj → = ∑ i wjixji Ed netj netj wji ∂Ed ∂wji = ∂Ed ∂netj ∂netj ∂wji = ∂Ed ∂netj ∂∑i wjixji ∂wji = ∂Ed ∂netj xji xji i j i ∂Ed ∂netj netj j yj Ed yj yj netj = sigmoid(ne )yj tj ∂Ed ∂netj = ∂Ed ∂yj ∂yj ∂netj ∂Ed ∂yj = ( −∂ ∂yj 1 2 ∑ i∈outputs ti yi )2 = ( −∂ ∂yj 1 2 tj yj)2 = −( − )tj yj ∂yj ∂netj = ∂sigmoid(ne )tj ∂netj = (1 − )yj yj = −( − ) (1 − )∂Ed ∂netj tj yj yj yj = −δj ∂Ed ∂netj δ = ( − ) (1 − )δj tj yj yj yj 上式就是式5。 隐藏层权值训练 现在我们要推导出隐藏层的 。 首先,我们需要定义节点 的所有直接下游节点的集合 。例如,对于节点4来说,它的直接下游节点是节点8、节点9。可以看到 只能通过影响 再影响 。设 是节点 的下游节点的输入,则 是 的函数,而 是 的函数。因为 有 多个,我们应用全导数公式,可以做出如下推导: 因为 ,带入上式得到: 上式就是式4。 ——数学公式警报解除—— 至此,我们已经推导出了反向传播算法。需要注意的是,我们刚刚推导出的训练规则是根据激活函数是sigmoid函数、平方和误差、全连接网络、 随机梯度下降优化算法。如果激活函数不同、误差计算方式不同、网络连接结构不同、优化算法不同,则具体的训练规则也会不一样。但是无论 怎样,训练规则的推导方式都是一样的,应用链式求导法则进行推导即可。 神经网络的实现 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/bp.py (python2.7) 现在,我们要根据前面的算法,实现一个基本的全连接神经网络,这并不需要太多代码。我们在这里依然采用面向对象设计。 首先,我们先做一个基本的模型: wji ← − ηwji ∂Ed ∂wji = + η( − ) (1 − )wji tj yj yj yj xji = + ηwji δj xji ∂Ed ∂netj j Downstream(j) netj Downstream(j) Ed netk j Ed netk netk netj netk ∂Ed ∂netj = ∑ k∈Downstream(j) ∂Ed ∂netk ∂netk ∂netj = −∑ k∈Downstream(j) δk ∂netk ∂netj = −∑ k∈Downstream(j) δk ∂netk ∂aj ∂aj ∂netj = −∑ k∈Downstream(j) δkwkj ∂aj ∂netj = − (1 − )∑ k∈Downstream(j) δkwkjaj aj = − (1 − )aj aj ∑ k∈Downstream(j) δkwkj = −δj ∂Ed ∂netj = (1 − )δj aj aj ∑ k∈Downstream(j) δkwkj 如上图,可以分解出5个领域对象来实现神经网络: Network 神经网络对象,提供API接口。它由若干层对象组成以及连接对象组成。 Layer 层对象,由多个节点组成。 Node 节点对象计算和记录节点自身的信息(比如输出值 、误差项 等),以及与这个节点相关的上下游的连接。 Connection 每个连接对象都要记录该连接的权重。 Connections 仅仅作为Connection的集合对象,提供一些集合操作。 Node实现如下: 1. # 节点类,负责记录和维护节点自身信息以及与这个节点相关的上下游连接,实现输出值和误差项的计算。 2. class Node(object): 3. def __init__(self, layer_index, node_index): 4. ''' 5. 构造节点对象。 6. layer_index: 节点所属的层的编号 7. node_index: 节点的编号 8. ''' 9. self.layer_index = layer_index 10. self.node_index = node_index 11. self.downstream = [] 12. self.upstream = [] 13. self.output = 0 14. self.delta = 0 15. 16. def set_output(self, output): 17. ''' 18. 设置节点的输出值。如果节点属于输入层会用到这个函数。 19. ''' 20. self.output = output 21. 22. def append_downstream_connection(self, conn): 23. ''' 24. 添加一个到下游节点的连接 25. ''' 26. self.downstream.append(conn) 27. 28. def append_upstream_connection(self, conn): 29. ''' 30. 添加一个到上游节点的连接 31. ''' 32. self.upstream.append(conn) 33. 34. def calc_output(self): 35. ''' 36. 根据式1计算节点的输出 37. ''' 38. output = reduce(lambda ret, conn: ret + conn.upstream_node.output * conn.weight, self.upstream, 0) 39. self.output = sigmoid(output) 40. 41. def calc_hidden_layer_delta(self): 42. ''' 43. 节点属于隐藏层时,根据式4计算delta 44. ''' 45. downstream_delta = reduce( 46. lambda ret, conn: ret + conn.downstream_node.delta * conn.weight, 47. self.downstream, 0.0) 48. self.delta = self.output * (1 - self.output) * downstream_delta 49. 50. def calc_output_layer_delta(self, label): 51. ''' 52. 节点属于输出层时,根据式3计算delta 53. ''' 54. self.delta = self.output * (1 - self.output) * (label - self.output) 55. 56. def __str__(self): 57. ''' a δ 57. 58. 打印节点的信息 59. ''' 60. node_str = '%u-%u: output: %f delta: %f' % (self.layer_index, self.node_index, self.output, self.delta) 61. downstream_str = reduce(lambda ret, conn: ret + '\n\t' + str(conn), self.downstream, '') 62. upstream_str = reduce(lambda ret, conn: ret + '\n\t' + str(conn), self.upstream, '') 63. return node_str + '\n\tdownstream:' + downstream_str + '\n\tupstream:' + upstream_str ConstNode对象,为了实现一个输出恒为1的节点(计算偏置项 时需要) 1. class ConstNode(object): 2. def __init__(self, layer_index, node_index): 3. ''' 4. 构造节点对象。 5. layer_index: 节点所属的层的编号 6. node_index: 节点的编号 7. ''' 8. self.layer_index = layer_index 9. self.node_index = node_index 10. self.downstream = [] 11. self.output = 1 12. 13. def append_downstream_connection(self, conn): 14. ''' 15. 添加一个到下游节点的连接 16. ''' 17. self.downstream.append(conn) 18. 19. def calc_hidden_layer_delta(self): 20. ''' 21. 节点属于隐藏层时,根据式4计算delta 22. ''' 23. downstream_delta = reduce( 24. lambda ret, conn: ret + conn.downstream_node.delta * conn.weight, 25. self.downstream, 0.0) 26. self.delta = self.output * (1 - self.output) * downstream_delta 27. 28. def __str__(self): 29. ''' 30. 打印节点的信息 31. ''' 32. node_str = '%u-%u: output: 1' % (self.layer_index, self.node_index) 33. downstream_str = reduce(lambda ret, conn: ret + '\n\t' + str(conn), self.downstream, '') 34. return node_str + '\n\tdownstream:' + downstream_str Layer对象,负责初始化一层。此外,作为Node的集合对象,提供对Node集合的操作。 1. class Layer(object): 2. def __init__(self, layer_index, node_count): 3. ''' 4. 初始化一层 5. layer_index: 层编号 6. node_count: 层所包含的节点个数 7. ''' 8. self.layer_index = layer_index 9. self.nodes = [] 10. for i in range(node_count): 11. self.nodes.append(Node(layer_index, i)) 12. self.nodes.append(ConstNode(layer_index, node_count)) 13. 14. def set_output(self, data): 15. ''' 16. 设置层的输出。当层是输入层时会用到。 17. ''' 18. for i in range(len(data)): wb 19. self.nodes[i].set_output(data[i]) 20. 21. def calc_output(self): 22. ''' 23. 计算层的输出向量 24. ''' 25. for node in self.nodes[:-1]: 26. node.calc_output() 27. 28. def dump(self): 29. ''' 30. 打印层的信息 31. ''' 32. for node in self.nodes: 33. print node Connection对象,主要职责是记录连接的权重,以及这个连接所关联的上下游节点。 1. class Connection(object): 2. def __init__(self, upstream_node, downstream_node): 3. ''' 4. 初始化连接,权重初始化为是一个很小的随机数 5. upstream_node: 连接的上游节点 6. downstream_node: 连接的下游节点 7. ''' 8. self.upstream_node = upstream_node 9. self.downstream_node = downstream_node 10. self.weight = random.uniform(-0.1, 0.1) 11. self.gradient = 0.0 12. 13. def calc_gradient(self): 14. ''' 15. 计算梯度 16. ''' 17. self.gradient = self.downstream_node.delta * self.upstream_node.output 18. 19. def get_gradient(self): 20. ''' 21. 获取当前的梯度 22. ''' 23. return self.gradient 24. 25. def update_weight(self, rate): 26. ''' 27. 根据梯度下降算法更新权重 28. ''' 29. self.calc_gradient() 30. self.weight += rate * self.gradient 31. 32. def __str__(self): 33. ''' 34. 打印连接信息 35. ''' 36. return '(%u-%u) -> (%u-%u) = %f' % ( 37. self.upstream_node.layer_index, 38. self.upstream_node.node_index, 39. self.downstream_node.layer_index, 40. self.downstream_node.node_index, 41. self.weight) Connections对象,提供Connection集合操作。 1. class Connections(object): 2. def __init__(self): 3. self.connections = [] 4. 5. def add_connection(self, connection): 6. self.connections.append(connection) 7. 8. def dump(self): 9. for conn in self.connections: 10. print conn Network对象,提供API。 1. class Network(object): 2. def __init__(self, layers): 3. ''' 4. 初始化一个全连接神经网络 5. layers: 二维数组,描述神经网络每层节点数 6. ''' 7. self.connections = Connections() 8. self.layers = [] 9. layer_count = len(layers) 10. node_count = 0; 11. for i in range(layer_count): 12. self.layers.append(Layer(i, layers[i])) 13. for layer in range(layer_count - 1): 14. connections = [Connection(upstream_node, downstream_node) 15. for upstream_node in self.layers[layer].nodes 16. for downstream_node in self.layers[layer + 1].nodes[:-1]] 17. for conn in connections: 18. self.connections.add_connection(conn) 19. conn.downstream_node.append_upstream_connection(conn) 20. conn.upstream_node.append_downstream_connection(conn) 21. 22. 23. def train(self, labels, data_set, rate, iteration): 24. ''' 25. 训练神经网络 26. labels: 数组,训练样本标签。每个元素是一个样本的标签。 27. data_set: 二维数组,训练样本特征。每个元素是一个样本的特征。 28. ''' 29. for i in range(iteration): 30. for d in range(len(data_set)): 31. self.train_one_sample(labels[d], data_set[d], rate) 32. 33. def train_one_sample(self, label, sample, rate): 34. ''' 35. 内部函数,用一个样本训练网络 36. ''' 37. self.predict(sample) 38. self.calc_delta(label) 39. self.update_weight(rate) 40. 41. def calc_delta(self, label): 42. ''' 43. 内部函数,计算每个节点的delta 44. ''' 45. output_nodes = self.layers[-1].nodes 46. for i in range(len(label)): 47. output_nodes[i].calc_output_layer_delta(label[i]) 48. for layer in self.layers[-2::-1]: 49. for node in layer.nodes: 50. node.calc_hidden_layer_delta() 51. 52. def update_weight(self, rate): 53. ''' 54. 内部函数,更新每个连接权重 55. ''' 56. for layer in self.layers[:-1]: 57. for node in layer.nodes: 58. for conn in node.downstream: 59. conn.update_weight(rate) 60. 61. def calc_gradient(self): 62. ''' 63. 内部函数,计算每个连接的梯度 64. ''' 65. for layer in self.layers[:-1]: 66. for node in layer.nodes: 67. for conn in node.downstream: 68. conn.calc_gradient() 69. 70. def get_gradient(self, label, sample): 71. ''' 72. 获得网络在一个样本下,每个连接上的梯度 73. label: 样本标签 74. sample: 样本输入 75. ''' 76. self.predict(sample) 77. self.calc_delta(label) 78. self.calc_gradient() 79. 80. def predict(self, sample): 81. ''' 82. 根据输入的样本预测输出值 83. sample: 数组,样本的特征,也就是网络的输入向量 84. ''' 85. self.layers[0].set_output(sample) 86. for i in range(1, len(self.layers)): 87. self.layers[i].calc_output() 88. return map(lambda node: node.output, self.layers[-1].nodes[:-1]) 89. 90. def dump(self): 91. ''' 92. 打印网络信息 93. ''' 94. for layer in self.layers: 95. layer.dump() 至此,实现了一个基本的全连接神经网络。可以看到,同神经网络的强大学习能力相比,其实现还算是很容易的。 梯度检查 怎么保证自己写的神经网络没有BUG呢?事实上这是一个非常重要的问题。一方面,千辛万苦想到一个算法,结果效果不理想,那么是算法本身 错了还是代码实现错了呢?定位这种问题肯定要花费大量的时间和精力。另一方面,由于神经网络的复杂性,我们几乎无法事先知道神经网络的 输入和输出,因此类似TDD(测试驱动开发)这样的开发方法似乎也不可行。 办法还是有滴,就是利用梯度检查来确认程序是否正确。梯度检查的思路如下: 对于梯度下降算法: 来说,这里关键之处在于 的计算一定要正确,而它是 对 的偏导数。而根据导数的定义: 对于任意 的导数值,我们都可以用等式右边来近似计算。我们把 看做是 的函数,即 ,那么根据导数定义, 应该等于: 如果把 设置为一个很小的数(比如 ),那么上式可以写成: ← − ηwji wji ∂Ed ∂wji ∂Ed ∂wji Ed wji (θ) =f ′ lim ϵ−>0 f(θ + ϵ) − f(θ − ϵ) 2ϵ θ Ed wji ()Ed wji ∂ ( )Ed wji ∂wji =∂ ( )Ed wji ∂wji lim ϵ−>0 f( + ϵ) − f( − ϵ)wji wji 2ϵ ϵ 10−4 我们就可以利用式6,来计算梯度 的值,然后同我们神经网络代码中计算出来的梯度值进行比较。如果两者的差别非常的小,那么就说明我们 的代码是正确的。 下面是梯度检查的代码。如果我们想检查参数 的梯度是否正确,我们需要以下几个步骤: 1. 首先使用一个样本 对神经网络进行训练,这样就能获得每个权重的梯度。 2. 将 加上一个很小的值(),重新计算神经网络在这个样本 下的 。 3. 将 减上一个很小的值(),重新计算神经网络在这个样本 下的 。 4. 根据式6计算出期望的梯度值,和第一步获得的梯度值进行比较,它们应该几乎想等(至少4位有效数字相同)。 当然,我们可以重复上面的过程,对每个权重 都进行检查。也可以使用多个样本重复检查。 1. def gradient_check(network, sample_feature, sample_label): 2. ''' 3. 梯度检查 4. network: 神经网络对象 5. sample_feature: 样本的特征 6. sample_label: 样本的标签 7. ''' 8. # 计算网络误差 9. network_error = lambda vec1, vec2: \ 10. 0.5 * reduce(lambda a, b: a + b, 11. map(lambda v: (v[0] - v[1]) * (v[0] - v[1]), 12. zip(vec1, vec2))) 13. 14. # 获取网络在当前样本下每个连接的梯度 15. network.get_gradient(sample_feature, sample_label) 16. 17. # 对每个权重做梯度检查 18. for conn in network.connections.connections: 19. # 获取指定连接的梯度 20. actual_gradient = conn.get_gradient() 21. 22. # 增加一个很小的值,计算网络的误差 23. epsilon = 0.0001 24. conn.weight += epsilon 25. error1 = network_error(network.predict(sample_feature), sample_label) 26. 27. # 减去一个很小的值,计算网络的误差 28. conn.weight -= 2 * epsilon # 刚才加过了一次,因此这里需要减去2倍 29. error2 = network_error(network.predict(sample_feature), sample_label) 30. 31. # 根据式6计算期望的梯度值 32. expected_gradient = (error2 - error1) / (2 * epsilon) 33. 34. # 打印 35. print 'expected gradient: \t%f\nactual gradient: \t%f' % ( 36. expected_gradient, actual_gradient) 至此,会推导、会实现、会抓BUG,你已经摸到深度学习的大门了。接下来还需要不断的实践,我们用刚刚写过的神经网络去识别手写数字。 神经网络实战——手写数字识别 针对这个任务,我们采用业界非常流行的MNIST数据集。MNIST大约有60000个手写字母的训练样本,我们使用它训练我们的神经网络,然后再 用训练好的网络去识别手写数字。 手写数字识别是个比较简单的任务,数字只可能是0-9中的一个,这是个10分类问题。 超参数的确定 我们首先需要确定网络的层数和每层的节点数。关于第一个问题,实际上并没有什么理论化的方法,大家都是根据经验来拍,如果没有经验的话 就随便拍一个。然后,你可以多试几个值,训练不同层数的神经网络,看看哪个效果最好就用哪个。嗯,现在你可能明白为什么说深度学习是个 手艺活了,有些手艺很让人无语,而有些手艺还是很有技术含量的。 ≈ (式6)∂ ( )Ed wji ∂wji f( + ϵ) − f( − ϵ)wji wji 2ϵ ∂Ed ∂wji wji d wji 10−4 d Ed+ wji 10−4 d Ed− wji 不过,有些基本道理我们还是明白的,我们知道网络层数越多越好,也知道层数越多训练难度越大。对于全连接网络,隐藏层最好不要超过三 层。那么,我们可以先试试仅有一个隐藏层的神经网络效果怎么样。毕竟模型小的话,训练起来也快些(刚开始玩模型的时候,都希望快点看到结 果)。 输入层节点数是确定的。因为MNIST数据集每个训练数据是28*28的图片,共784个像素,因此,输入层节点数应该是784,每个像素对应一个输 入节点。 输出层节点数也是确定的。因为是10分类,我们可以用10个节点,每个节点对应一个分类。输出层10个节点中,输出最大值的那个节点对应的分 类,就是模型的预测结果。 隐藏层节点数量是不好确定的,从1到100万都可以。下面有几个经验公式: 因此,我们可以先根据上面的公式设置一个隐藏层节点数。如果有时间,我们可以设置不同的节点数,分别训练,看看哪个效果最好就用哪个。 我们先拍一个,设隐藏层节点数为300吧。 对于3层 的全连接网络,总共有 个参数!神经网络之所以强大,是它提供了一种 非常简单的方法去实现大量的参数。目前百亿参数、千亿样本的超大规模神经网络也是有的。因为MNIST只有6万个训练样本,参数太多了很容易 过拟合,效果反而不好。 模型的训练和评估 MNIST数据集包含10000个测试样本。我们先用60000个训练样本训练我们的网络,然后再用测试样本对网络进行测试,计算识别错误率: 我们每训练10轮,评估一次准确率。当准确率开始下降时(出现了过拟合)终止训练。 代码实现 首先,我们需要把MNIST数据集处理为神经网络能够接受的形式。MNIST训练集的文件格式可以参考官方网站,这里不在赘述。每个训练样本是 一个28*28的图像,我们按照行优先,把它转化为一个784维的向量。每个标签是0-9的值,我们将其转换为一个10维的one-hot向量:如果标签值 为 ,我们就把向量的第 维(从0开始编号)设置为0.9,而其它维设置为0.1。例如,向量[0.1,0.1,0.9,0.1,0.1,0.1,0.1,0.1,0.1,0.1]表示值2。 下面是处理MNIST数据的代码: 1. #!/usr/bin/env python 2. # -*- coding: UTF-8 -*- 3. 4. import struct 5. from bp import * 6. from datetime import datetime 7. 8. 9. # 数据加载器基类 10. class Loader(object): 11. def __init__(self, path, count): 12. ''' 13. 初始化加载器 14. path: 数据文件路径 15. count: 文件中的样本个数 16. ''' 17. self.path = path 18. self.count = count 19. 20. def get_file_content(self): 21. ''' 22. 读取文件内容 23. ''' m = + αn + l− −−−√ m = lo ng2 m = nl−−√ m : 隐藏层节点数 n : 输入层节点数 l : 输出层节点数 α : 1到10之间的常数 784 ∗ 300 ∗ 10 300 ∗ (784 + 1) + 10 ∗ (300 + 1) = 238510 错误率 = 错误预测样本数 总样本数 n n 24. f = open(self.path, 'rb') 25. content = f.read() 26. f.close() 27. return content 28. 29. def to_int(self, byte): 30. ''' 31. 将unsigned byte字符转换为整数 32. ''' 33. return struct.unpack('B', byte)[0] 34. 35. 36. # 图像数据加载器 37. class ImageLoader(Loader): 38. def get_picture(self, content, index): 39. ''' 40. 内部函数,从文件中获取图像 41. ''' 42. start = index * 28 * 28 + 16 43. picture = [] 44. for i in range(28): 45. picture.append([]) 46. for j in range(28): 47. picture[i].append( 48. self.to_int(content[start + i * 28 + j])) 49. return picture 50. 51. def get_one_sample(self, picture): 52. ''' 53. 内部函数,将图像转化为样本的输入向量 54. ''' 55. sample = [] 56. for i in range(28): 57. for j in range(28): 58. sample.append(picture[i][j]) 59. return sample 60. 61. def load(self): 62. ''' 63. 加载数据文件,获得全部样本的输入向量 64. ''' 65. content = self.get_file_content() 66. data_set = [] 67. for index in range(self.count): 68. data_set.append( 69. self.get_one_sample( 70. self.get_picture(content, index))) 71. return data_set 72. 73. 74. # 标签数据加载器 75. class LabelLoader(Loader): 76. def load(self): 77. ''' 78. 加载数据文件,获得全部样本的标签向量 79. ''' 80. content = self.get_file_content() 81. labels = [] 82. for index in range(self.count): 83. labels.append(self.norm(content[index + 8])) 84. return labels 85. 86. def norm(self, label): 87. ''' 88. 内部函数,将一个值转换为10维标签向量 89. ''' 90. label_vec = [] 91. label_value = self.to_int(label) 92. for i in range(10): 93. if i == label_value: 94. label_vec.append(0.9) 95. else: 96. label_vec.append(0.1) 97. return label_vec 98. 99. 100. def get_training_data_set(): 101. ''' 102. 获得训练数据集 103. ''' 104. image_loader = ImageLoader('train-images-idx3-ubyte', 60000) 105. label_loader = LabelLoader('train-labels-idx1-ubyte', 60000) 106. return image_loader.load(), label_loader.load() 107. 108. 109. def get_test_data_set(): 110. ''' 111. 获得测试数据集 112. ''' 113. image_loader = ImageLoader('t10k-images-idx3-ubyte', 10000) 114. label_loader = LabelLoader('t10k-labels-idx1-ubyte', 10000) 115. return image_loader.load(), label_loader.load() 网络的输出是一个10维向量,这个向量第 个(从0开始编号)元素的值最大,那么 就是网络的识别结果。下面是代码实现: 1. def get_result(vec): 2. max_value_index = 0 3. max_value = 0 4. for i in range(len(vec)): 5. if vec[i] > max_value: 6. max_value = vec[i] 7. max_value_index = i 8. return max_value_index 我们使用错误率来对网络进行评估,下面是代码实现: 1. def evaluate(network, test_data_set, test_labels): 2. error = 0 3. total = len(test_data_set) 4. 5. for i in range(total): 6. label = get_result(test_labels[i]) 7. predict = get_result(network.predict(test_data_set[i])) 8. if label != predict: 9. error += 1 10. return float(error) / float(total) 最后实现我们的训练策略:每训练10轮,评估一次准确率,当准确率开始下降时终止训练。下面是代码实现: 1. def train_and_evaluate(): 2. last_error_ratio = 1.0 3. epoch = 0 4. train_data_set, train_labels = get_training_data_set() 5. test_data_set, test_labels = get_test_data_set() 6. network = Network([784, 300, 10]) 7. while True: 8. epoch += 1 9. network.train(train_labels, train_data_set, 0.3, 1) 10. print '%s epoch %d finished' % (now(), epoch) 11. if epoch % 10 == 0: n n 12. error_ratio = evaluate(network, test_data_set, test_labels) 13. print '%s after epoch %d, error ratio is %f' % (now(), epoch, error_ratio) 14. if error_ratio > last_error_ratio: 15. break 16. else: 17. last_error_ratio = error_ratio 18. 19. 20. if __name__ == '__main__': 21. train_and_evaluate() 在我的机器上测试了一下,1个epoch大约需要9000多秒,所以要对代码做很多的性能优化工作(比如用向量化编程)。训练要很久很久,可以把 它上传到服务器上,在tmux的session里面去运行。为了防止异常终止导致前功尽弃,我们每训练10轮,就把获得参数值保存在磁盘上,以便后续 可以恢复。(代码略) 向量化编程 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/fc.py (python2.7) 在经历了漫长的训练之后,我们可能会想到,肯定有更好的办法!是的,程序员们,现在我们需要告别面向对象编程了,转而去使用另外一种更 适合深度学习算法的编程方式:向量化编程。主要有两个原因:一个是我们事实上并不需要真的去定义Node、Connection这样的对象,直接把数 学计算实现了就可以了;另一个原因,是底层算法库会针对向量运算做优化(甚至有专用的硬件,比如GPU),程序效率会提升很多。所以,在 深度学习的世界里,我们总会想法设法的把计算表达为向量的形式。我相信优秀的程序员不会把自己拘泥于某种(自己熟悉的)编程范式上,而 会去学习并使用最为合适的范式。 下面,我们用向量化编程的方法,重新实现前面的全连接神经网络。 首先,我们需要把所有的计算都表达为向量的形式。对于全连接神经网络来说,主要有三个计算公式。 前向计算,我们发现式2已经是向量化的表达了: 上式中的 表示sigmoid函数。 反向计算,我们需要把式3和式4使用向量来表示: 在式8中, 表示第l层的误差项; 表示矩阵 的转置。 我们还需要权重数组W和偏置项b的梯度计算的向量化表示。也就是需要把式5使用向量化表示: 其对应的向量化表示为: 更新偏置项的向量化表示为: 现在,我们根据上面几个公式,重新实现一个类:FullConnectedLayer。它实现了全连接层的前向和后向计算: 1. # 全连接层实现类 2. class FullConnectedLayer(object): 3. def __init__(self, input_size, output_size, 4. activator): 5. ''' 6. 构造函数 7. input_size: 本层输入向量的维度 8. output_size: 本层输出向量的维度 9. activator: 激活函数 10. ''' = σ(W ⋅ )(式2)a⃗  x⃗  σ = (1 − )( − ) (式7)δ ⃗  y⃗  y⃗  t ⃗  y⃗  = (1 − ) (式8)δ(l) → a⃗ (l) a⃗ (l) W T δ(l+1) δ(l) W T W ← + η (式5)wji wji δj xji W ← W + η (式9)δ ⃗ x⃗ T ← + η (式10)b⃗  b⃗  δ ⃗  11. self.input_size = input_size 12. self.output_size = output_size 13. self.activator = activator 14. # 权重数组W 15. self.W = np.random.uniform(-0.1, 0.1, 16. (output_size, input_size)) 17. # 偏置项b 18. self.b = np.zeros((output_size, 1)) 19. # 输出向量 20. self.output = np.zeros((output_size, 1)) 21. 22. def forward(self, input_array): 23. ''' 24. 前向计算 25. input_array: 输入向量,维度必须等于input_size 26. ''' 27. # 式2 28. self.input = input_array 29. self.output = self.activator.forward( 30. np.dot(self.W, input_array) + self.b) 31. 32. def backward(self, delta_array): 33. ''' 34. 反向计算W和b的梯度 35. delta_array: 从上一层传递过来的误差项 36. ''' 37. # 式8 38. self.delta = self.activator.backward(self.input) * np.dot( 39. self.W.T, delta_array) 40. self.W_grad = np.dot(delta_array, self.input.T) 41. self.b_grad = delta_array 42. 43. def update(self, learning_rate): 44. ''' 45. 使用梯度下降算法更新权重 46. ''' 47. self.W += learning_rate * self.W_grad 48. self.b += learning_rate * self.b_grad 上面这个类一举取代了原先的Layer、Node、Connection等类,不但代码更加容易理解,而且运行速度也快了几百倍。 现在,我们对Network类稍作修改,使之用到FullConnectedLayer: 1. # Sigmoid激活函数类 2. class SigmoidActivator(object): 3. def forward(self, weighted_input): 4. return 1.0 / (1.0 + np.exp(-weighted_input)) 5. 6. def backward(self, output): 7. return output * (1 - output) 8. 9. 10. # 神经网络类 11. class Network(object): 12. def __init__(self, layers): 13. ''' 14. 构造函数 15. ''' 16. self.layers = [] 17. for i in range(len(layers) - 1): 18. self.layers.append( 19. FullConnectedLayer( 20. layers[i], layers[i+1], 21. SigmoidActivator() 22. ) 23. ) 24. 25. def predict(self, sample): 26. ''' 27. 使用神经网络实现预测 28. sample: 输入样本 29. ''' 30. output = sample 31. for layer in self.layers: 32. layer.forward(output) 33. output = layer.output 34. return output 35. 36. def train(self, labels, data_set, rate, epoch): 37. ''' 38. 训练函数 39. labels: 样本标签 40. data_set: 输入样本 41. rate: 学习速率 42. epoch: 训练轮数 43. ''' 44. for i in range(epoch): 45. for d in range(len(data_set)): 46. self.train_one_sample(labels[d], 47. data_set[d], rate) 48. 49. def train_one_sample(self, label, sample, rate): 50. self.predict(sample) 51. self.calc_gradient(label) 52. self.update_weight(rate) 53. 54. def calc_gradient(self, label): 55. delta = self.layers[-1].activator.backward( 56. self.layers[-1].output 57. ) * (label - self.layers[-1].output) 58. for layer in self.layers[::-1]: 59. layer.backward(delta) 60. delta = layer.delta 61. return delta 62. 63. def update_weight(self, rate): 64. for layer in self.layers: 65. layer.update(rate) 现在,Network类也清爽多了,用我们的新代码再次训练一下MNIST数据集吧。 小结 至此,你已经完成了又一次漫长的学习之旅。你现在应该已经明白了神经网络的基本原理,高兴的话,你甚至有能力去动手实现一个,并用它解 决一些问题。如果感到困难也不要气馁,这篇文章是一个重要的分水岭,如果你完全弄明白了的话,在真正的『小白』和装腔作势的『大牛』面 前吹吹牛是完全没有问题的。 作为深度学习入门的系列文章,本文也是上半场的结束。在这个半场,你掌握了机器学习、神经网络的基本概念,并且有能力去动手解决一些简 单的问题(例如手写数字识别,如果用传统的观点来看,其实这些问题也不简单)。而且,一旦掌握基本概念,后面的学习就容易多了。 在下半场,我们讲介绍更多『深度』学习的内容,我们已经讲了神经网络(Neutrol Network),但是并没有讲深度神经网络(Deep Neutrol Network)。Deep会带来更加强大的能力,同时也带来更多的问题。如果不理解这些问题和它们的解决方案,也不能说你入门了『深度』学习。 目前业界有很多开源的神经网络实现,它们的功能也要强大的多,因此你并不需要事必躬亲的去实现自己的神经网络。我们在上半场不断的从头 发明轮子,是为了让你明白神经网络的基本原理,这样你就能非常迅速的掌握这些工具。在下半场的文章中,我们改变了策略:不会再去从头开 始去实现,而是尽可能应用现有的工具。 下一篇文章,我们介绍不同结构的神经网络,比如鼎鼎大名的卷积神经网络,它在图像和语音领域已然创造了诸多奇迹,在自然语言处理领域的 研究也如火如荼。某种意义上说,它的成功大大提升了人们对于深度学习的信心。 好了,同学们累了吧,奉上美图一张,放松一下心情! 参考资料 1. Tom M. Mitchell, "机器学习", 曾华军等译, 机械工业出版社 2. CS 224N / Ling 284, Neural Networks for Named Entity Recognition 3. LeCun et al. Gradient-Based Learning Applied to Document Recognition 1998 零基础入门深度学习(4) - 卷积神经网络 机器学习 深度学习入门 无论即将到来的是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序 员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?现在救命稻草来了,《零基础入门深度学习》系列文章旨 在讲帮助爱编程的你从零基础达到入门级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这是专门为程序员写 的文章。虽然文中会有很多公式你也许看不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序 员,所以我写的代码也不会很差)。 文章列表 零基础入门深度学习(1) - 感知器 零基础入门深度学习(2) - 线性单元和梯度下降 零基础入门深度学习(3) - 神经网络和反向传播算法 零基础入门深度学习(4) - 卷积神经网络 零基础入门深度学习(5) - 循环神经网络 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 零基础入门深度学习(7) - 递归神经网络 往期回顾 在前面的文章中,我们介绍了全连接神经网络,以及它的训练和使用。我们用它来识别了手写数字,然而,这种结构的网络对于图像识别任务来 说并不是很合适。本文将要介绍一种更适合图像、语音识别任务的神经网络结构——卷积神经网络(Convolutional Neural Network, CNN)。说卷积 神经网络是最重要的一种神经网络也不为过,它在最近几年大放异彩,几乎所有图像、语音识别领域的重要突破都是卷积神经网络取得的,比如 谷歌的GoogleNet、微软的ResNet等,打败李世石的AlphaGo也用到了这种网络。本文将详细介绍卷积神经网络以及它的训练算法,以及动手实 现一个简单的卷积神经网络。 一个新的激活函数——Relu 最近几年卷积神经网络中,激活函数往往不选择sigmoid或tanh函数,而是选择relu函数。Relu函数的定义是: Relu函数图像如下图所示: Relu函数作为激活函数,有下面几大优势: 速度快 和sigmoid函数需要计算指数和倒数相比,relu函数其实就是一个max(0,x),计算代价小很多。 减轻梯度消失问题 回忆一下计算梯度的公式 。其中, 是sigmoid函数的导数。在使用反向传播算法进行梯度计算时,每经过一层 sigmoid神经元,梯度就要乘上一个 。从下图可以看出, 函数最大值是1/4。因此,乘一个 会导致梯度越来越小,这对于深层网络的训 练是个很大的问题。而relu函数的导数是1,不会导致梯度变小。当然,激活函数仅仅是导致梯度减小的一个因素,但无论如何在这方面relu 的表现强于sigmoid。使用relu激活函数可以让你训练更深的网络。 稀疏性 通过对大脑的研究发现,大脑在工作的时候只有大约5%的神经元是激活的,而采用sigmoid激活函数的人工神经网络,其激活率大约 是50%。有论文声称人工神经网络在15%-30%的激活率时是比较理想的。因为relu函数在输入小于0时是完全不激活的,因此可以获得一个更 低的激活率。 全连接网络 VS 卷积网络 全连接神经网络之所以不太适合图像识别任务,主要有以下几个方面的问题: 参数数量太多 考虑一个输入1000*1000像素的图片(一百万像素,现在已经不能算大图了),输入层有1000*1000=100万节点。假设第一个隐 藏层有100个节点(这个数量并不多),那么仅这一层就有(1000*1000+1)*100=1亿参数,这实在是太多了!我们看到图像只扩大一点,参数数 量就会多很多,因此它的扩展性很差。 没有利用像素之间的位置信息 对于图像识别任务来说,每个像素和其周围像素的联系是比较紧密的,和离得很远的像素的联系可能就很小 了。如果一个神经元和上一层所有神经元相连,那么就相当于对于一个像素来说,把图像的所有像素都等同看待,这不符合前面的假设。当 我们完成每个连接权重的学习之后,最终可能会发现,有大量的权重,它们的值都是很小的(也就是这些连接其实无关紧要)。努力学习大量并 不重要的权重,这样的学习必将是非常低效的。 f(x) = max(0, x) ∇ = δxσ′ σ′ σ′ σ′ σ′ 网络层数限制 我们知道网络层数越多其表达能力越强,但是通过梯度下降方法训练深度全连接神经网络很困难,因为全连接神经网络的梯度 很难传递超过3层。因此,我们不可能得到一个很深的全连接神经网络,也就限制了它的能力。 那么,卷积神经网络又是怎样解决这个问题的呢?主要有三个思路: 局部连接 这个是最容易想到的,每个神经元不再和上一层的所有神经元相连,而只和一小部分神经元相连。这样就减少了很多参数。 权值共享 一组连接可以共享同一个权重,而不是每个连接有一个不同的权重,这样又减少了很多参数。 下采样 可以使用Pooling来减少每层的样本数,进一步减少参数数量,同时还可以提升模型的鲁棒性。 对于图像识别任务来说,卷积神经网络通过尽可能保留重要的参数,去掉大量不重要的参数,来达到更好的学习效果。 接下来,我们将详述卷积神经网络到底是何方神圣。 卷积神经网络是啥 首先,我们先获取一个感性认识,下图是一个卷积神经网络的示意图: 网络架构 如图1所示,一个卷积神经网络由若干卷积层、Pooling 层、全连接层组成。你可以构建各种不同的卷积神经网络,它的常用架构模式为: INPUT -> [[CONV]*N -> POOL?]*M -> [FC]*K 也就是N个卷积层叠加,然后(可选)叠加一个Pooling层,重复这个结构M次,最后叠加K个全连接层。 对于图1展示的卷积神经网络: INPUT -> CONV -> POOL -> CONV -> POOL -> FC -> FC 按照上述模式可以表示为: INPUT -> [[CONV]*1 -> POOL]*2 -> [FC]*2 也就是:N=1, M=2, K=2。 三维的层结构 从图1我们可以发现卷积神经网络的层结构和全连接神经网络的层结构有很大不同。全连接神经网络每层的神经元是按照一维排列的,也就是排成 一条线的样子;而卷积神经网络每层的神经元是按照三维排列的,也就是排成一个长方体的样子,有宽度、高度和深度。 对于图1展示的神经网络,我们看到输入层的宽度和高度对应于输入图像的宽度和高度,而它的深度为1。接着,第一个卷积层对这幅图像进行了 卷积操作(后面我们会讲如何计算卷积),得到了三个Feature Map。这里的"3"可能是让很多初学者迷惑的地方,实际上,就是这个卷积层包含三个 Filter,也就是三套参数,每个Filter都可以把原始输入图像卷积得到一个Feature Map,三个Filter就可以得到三个Feature Map。至于一个卷积层 可以有多少个Filter,那是可以自由设定的。也就是说,卷积层的Filter个数也是一个超参数。我们可以把Feature Map可以看做是通过卷积变换提 取到的图像特征,三个Filter就对原始图像提取出三组不同的特征,也就是得到了三个Feature Map,也称做三个通道(channel) 。 继续观察图1,在第一个卷积层之后,Pooling层对三个Feature Map做了下采样(后面我们会讲如何计算下采样),得到了三个更小的Feature Map。接着,是第二个卷积层,它有5个Filter。每个Fitler都把前面下采样之后的3个**Feature Map 卷积在一起,得到一个新的Feature Map 。这 样,5个Filter 就得到了5个Feature Map 。接着,是第二个Pooling ,继续对5个Feature Map 进行下采样**,得到了5个更小的Feature Map。 图1所示网络的最后两层是全连接层。第一个全连接层的每个神经元,和上一层5个Feature Map中的每个神经元相连,第二个全连接层(也就是输 出层)的每个神经元,则和第一个全连接层的每个神经元相连,这样得到了整个网络的输出。 至此,我们对卷积神经网络有了最基本的感性认识。接下来,我们将介绍卷积神经网络中各种层的计算和训练。 卷积神经网络输出值的计算 卷积层输出值的计算 我们用一个简单的例子来讲述如何计算卷积,然后,我们抽象出卷积层的一些重要概念和计算方法。 假设有一个5*5的图像,使用一个3*3的filter进行卷积,想得到一个3*3的Feature Map,如下所示: 为了清楚的描述卷积计算过程,我们首先对图像的每个像素进行编号,用 表示图像的第 行第 列元素;对filter的每个权重进行编号,用 表示第 行第 列权重,用 表示filter的偏置项;对Feature Map的每个元素进行编号,用 表示Feature Map的第 行第 列元素;用 表示激活 函数(这个例子选择relu 函数作为激活函数)。然后,使用下列公式计算卷积: 例如,对于Feature Map左上角元素 来说,其卷积计算方法为: 计算结果如下图所示: 接下来,Feature Map的元素 的卷积计算方法为: 计算结果如下图所示: xi,j i j wm,n m n wb ai,j i j f = f( + ) (式1)ai,j ∑ m=0 2 ∑ n=0 2 wm,nxi+m,j+n wb a0,0 a0,0 = f( + )∑ m=0 2 ∑ n=0 2 wm,nxm+0,n+0 wb = relu(+++++++++)w0,0x0,0 w0,1x0,1 w0,2x0,2 w1,0x1,0 w1,1x1,1 w1,2x1,2 w2,0x2,0 w2,1x2,1 w2,2x2,2 wb = relu(1 + 0 + 1 + 0 + 1 + 0 + 0 + 0 + 1 + 0) = relu(4) = 4 a0,1 a0,1 = f( + )∑ m=0 2 ∑ n=0 2 wm,nxm+0,n+1 wb = relu(+++++++++)w0,0x0,1 w0,1x0,2 w0,2x0,3 w1,0x1,1 w1,1x1,2 w1,2x1,3 w2,0x2,1 w2,1x2,3 w2,2x2,3 wb = relu(1 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 1 + 0) = relu(3) = 3 可以依次计算出Feature Map中所有元素的值。下面的动画显示了整个Feature Map的计算过程: 上面的计算过程中,步幅(stride)为1。步幅可以设为大于1的数。例如,当步幅为2时,Feature Map计算如下: 我们注意到,当步幅设置为2的时候,Feature Map就变成2*2了。这说明图像大小、步幅和卷积后的Feature Map大小是有关系的。事实上,它们 满足下面的关系: 在上面两个公式中, 是卷积后Feature Map的宽度; 是卷积前图像的宽度; 是filter的宽度; 是Zero Padding 数量,Zero Padding 是指 在原始图像周围补几圈0,如果 的值是1,那么就补1圈0; 是步幅; 是卷积后Feature Map的高度; 是卷积前图像的宽度。式2和式3本 质上是一样的。 以前面的例子来说,图像宽度 ,filter宽度 ,Zero Padding ,步幅 ,则 说明Feature Map宽度是2。同样,我们也可以计算出Feature Map高度也是2。 前面我们已经讲了深度为1的卷积层的计算方法,如果深度大于1怎么计算呢?其实也是类似的。如果卷积前的图像深度为D,那么相应的filter的深 度也必须为D。我们扩展一下式1,得到了深度大于1的卷积计算公式: 在式4中,D是深度;F是filter的大小(宽度或高度,两者相同); 表示filter的第 层第 行第 列权重; 表示图像的第 层第 行第 列像 素;其它的符号含义和式1是相同的,不再赘述。 我们前面还曾提到,每个卷积层可以有多个filter。每个filter和原始图像进行卷积后,都可以得到一个Feature Map。因此,卷积后Feature Map的 深度(个数)和卷积层的filter个数是相同的。 下面的动画显示了包含两个filter的卷积层的计算。我们可以看到7*7*3输入,经过两个3*3*3filter的卷积(步幅为2),得到了3*3*2的输出。另外我们 也会看到下图的Zero padding 是1,也就是在输入元素的周围补了一圈0。Zero padding 对于图像边缘部分的特征提取是很有帮助的。 W2 H2 = ( − F + 2P)/S + 1 (式2)W1 = ( − F + 2P)/S + 1 (式3)H1 W2 W1 F P P S H2 H1 = 5W1 F = 3 P = 0 S = 2 W2 = ( − F + 2P)/S + 1W1 = (5 − 3 + 0)/2 + 1 = 2 = f( + ) (式4)ai,j ∑ d=0 D−1 ∑ m=0 F−1 ∑ n=0 F−1 wd,m,nxd,i+m,j+n wb wd,m,n d m n ad,i,j d i j 以上就是卷积层的计算方法。这里面体现了局部连接和权值共享:每层神经元只和上一层部分神经元相连(卷积计算规则),且filter的权值对于上一 层所有神经元都是一样的。对于包含两个3*3*3的fitler的卷积层来说,其参数数量仅有(3*3*3+1)*2=56个,且参数数量与上一层神经元个数无关。 与全连接神经网络相比,其参数数量大大减少了。 用卷积公式来表达卷积层计算 不想了解太多数学细节的读者可以跳过这一节,不影响对全文的理解。 式4的表达很是繁冗,最好能简化一下。就像利用矩阵可以简化表达全连接神经网络的计算一样,我们利用卷积公式可以简化卷积神经网络的表 达。 下面我们介绍二维卷积公式。 设矩阵 , ,其行、列数分别为 、 、 、 ,则二维卷积公式如下: 且 , 满足条件 。 我们可以把上式写成 如果我们按照式5来计算卷积,我们可以发现矩阵A实际上是filter,而矩阵B是待卷积的输入,位置关系也有所不同: A B ma na mb nb Cs,t = ∑ 0 −1ma ∑ 0 −1na Am,nBs−m,t−n s t 0 ≤ s < + − 1, 0 ≤ t < + − 1ma mb na nb C = A ∗ B (式5) 从上图可以看到,A左上角的值 与B对应区块中右下角的值 相乘,而不是与左上角的 相乘。因此,数学中的卷积和卷积神经网络中的 『卷积』还是有区别的,为了避免混淆,我们把卷积神经网络中的『卷积』操作叫做互相关(cross-correlation) 操作。 卷积和互相关操作是可以转化的。首先,我们把矩阵A翻转180度,然后再交换A和B的位置(即把B放在左边而把A放在右边。卷积满足交换率, 这个操作不会导致结果变化),那么卷积就变成了互相关。 如果我们不去考虑两者这么一点点的区别,我们可以把式5代入到式4: 其中, 是卷积层输出的feature map。同式4相比,式6就简单多了。然而,这种简洁写法只适合步长为1的情况。 Pooling 层输出值的计算 Pooling层主要的作用是下采样,通过去掉Feature Map中不重要的样本,进一步减少参数数量。Pooling的方法很多,最常用的是Max Pooling 。 Max Pooling 实际上就是在n*n的样本中取最大值,作为采样后的样本值。下图是2*2 max pooling: 除了Max Pooing 之外,常用的还有Mean Pooling ——取各样本的平均值。 对于深度为D的Feature Map,各层独立做Pooling,因此Pooling后的深度仍然为D。 全连接层 全连接层输出值的计算和上一篇文章零基础入门深度学习(3) - 神经网络和反向传播算法讲过的全连接神经网络是一样的,这里就不再赘述了。 卷积神经网络的训练 和全连接神经网络相比,卷积神经网络的训练要复杂一些。但训练的原理是一样的:利用链式求导计算损失函数对每个权重的偏导数(梯度), 然后根据梯度下降公式更新权重。训练算法依然是反向传播算法。 我们先回忆一下上一篇文章零基础入门深度学习(3) - 神经网络和反向传播算法介绍的反向传播算法,整个算法分为三个步骤: 1. 前向计算每个神经元的输出值 ( 表示网络的第 个神经元,以下同); 2. 反向计算每个神经元的误差项 , 在有的文献中也叫做敏感度(sensitivity)。它实际上是网络的损失函数 对神经元加权输入 的偏导 数,即 ; a0,0 b1,1 b0,0 A = f( ∗ + ) (式6)∑ d=0 D−1 Xd Wd wb A aj j j δj δj Ed netj =δj ∂Ed ∂netj 3. 计算每个神经元连接权重 的梯度( 表示从神经元 连接到神经元 的权重),公式为 ,其中, 表示神经元 的输出。 最后,根据梯度下降法则更新每个权重即可。 对于卷积神经网络,由于涉及到局部连接、下采样的等操作,影响到了第二步误差项 的具体计算方法,而权值共享影响了第三步权重 的梯度的 计算方法。接下来,我们分别介绍卷积层和Pooling层的训练算法。 卷积层的训练 对于卷积层,我们先来看看上面的第二步,即如何将误差项 传递到上一层;然后再来看看第三步,即如何计算filter每个权值 的梯度。 卷积层误差项的传递 最简单情况下误差项的传递 我们先来考虑步长为1、输入的深度为1、filter个数为1的最简单的情况。 假设输入的大小为3*3,filter大小为2*2,按步长为1卷积,我们将得到2*2的feature map 。如下图所示: 在上图中,为了描述方便,我们为每个元素都进行了编号。用 表示第 层第 行第 列的误差项;用 表示filter第 行第 列权重,用 表示filter的偏置项;用 表示第 层第 行第 列神经元的输出;用 表示第 行神经元的加权输入;用 表示第 层第 行第 列 的误差项;用 表示第 层的激活函数。它们之间的关系如下: 上式中, 、 、 都是数组, 是由 组成的数组, 表示卷积操作。 在这里,我们假设第 中的每个 值都已经算好,我们要做的是计算第 层每个神经元的误差项 。 根据链式求导法则: 我们先求第一项 。我们先来看几个特例,然后从中总结出一般性的规律。 例1,计算 , 仅与 的计算有关: 因此: wji wji i j =∂Ed ∂wji aiδj ai i δ w δ w δl−1 i,j l − 1 j j wm,n m n wb al−1 i,j l − 1 i j netl−1 i,j l − 1 δl i,j l j j f l−1 l − 1 netl al−1 i,j = conv( , ) +W l al−1 wb = (ne )f l−1 tl−1 i,j netl W l al−1 W l wm,n conv l δl l − 1 δl−1 δl−1 i,j = ∂Ed ∂netl−1 i,j = ∂Ed ∂al−1 i,j ∂al−1 i,j ∂netl−1 i,j ∂Ed ∂al−1i,j ∂Ed ∂al−11,1 al−1 1,1 netl 1,1 ne = + + + +tj 1,1 w1,1al−1 1,1 w1,2al−1 1,2 w2,1al−1 2,1 w2,2al−1 2,2 wb 例2,计算 , 与 和 的计算都有关: 因此: 例3,计算 , 与 、 、 和 的计算都有关: 因此: 从上面三个例子,我们发挥一下想象力,不难发现,计算 ,相当于把第 层的sensitive map周围补一圈0,在与180度翻转后的filter进行 cross-correlation ,就能得到想要结果,如下图所示: 因为卷积相当于将filter旋转180度的cross-correlation ,因此上图的计算可以用卷积公式完美的表达: 上式中的 表示第 层的filter的权重数组。也可以把上式的卷积展开,写成求和的形式: 现在,我们再求第二项 。因为 ∂Ed ∂al−1 1,1 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂al−1 1,1 = δl 1,1w1,1 ∂Ed ∂al−11,2 al−1 1,2 netl 1,1 netl 1,2 ne = + + + +tj 1,1 w1,1al−1 1,1 w1,2al−1 1,2 w2,1al−1 2,1 w2,2al−1 2,2 wb ne = + + + +tj 1,2 w1,1al−1 1,2 w1,2al−1 1,3 w2,1al−1 2,2 w2,2al−1 2,3 wb ∂Ed ∂al−1 1,2 = +∂Ed ∂netl 1,1 ∂netl 1,1 ∂al−1 1,2 ∂Ed ∂netl 1,2 ∂netl 1,2 ∂al−1 1,2 = +δl 1,1w1,2 δl 1,2w1,1 ∂Ed ∂al−12,2 al−1 2,2 netl 1,1 netl 1,2 netl 2,1 netl 2,2 ne = + + + +tj 1,1 w1,1al−1 1,1 w1,2al−1 1,2 w2,1al−1 2,1 w2,2al−1 2,2 wb ne = + + + +tj 1,2 w1,1al−1 1,2 w1,2al−1 1,3 w2,1al−1 2,2 w2,2al−1 2,3 wb ne = + + + +tj 2,1 w1,1al−1 2,1 w1,2al−1 2,2 w2,1al−1 3,1 w2,2al−1 3,2 wb ne = + + + +tj 2,2 w1,1al−1 2,2 w1,2al−1 2,3 w2,1al−1 3,2 w2,2al−1 3,3 wb ∂Ed ∂al−1 2,2 = + + +∂Ed ∂netl 1,1 ∂netl 1,1 ∂al−1 2,2 ∂Ed ∂netl 1,2 ∂netl 1,2 ∂al−1 2,2 ∂Ed ∂netl 2,1 ∂netl 2,1 ∂al−1 2,2 ∂Ed ∂netl 2,2 ∂netl 2,2 ∂al−1 2,2 = + + +δl 1,1w2,2 δl 1,2w2,1 δl 2,1w1,2 δl 2,2w1,1 ∂Ed ∂al−1 l = ∗∂Ed ∂al δl W l W l l =∂Ed ∂al i,j ∑ m ∑ n wlm,nδl i+m,j+n ∂al−1i,j ∂netl−1i,j = f(ne )al−1 i,j tl−1 i,j 所以这一项极其简单,仅求激活函数 的导数就行了。 将第一项和第二项组合起来,我们得到最终的公式: 也可以将式7写成卷积的形式: 其中,符号 表示element-wise product ,即将矩阵中每个对应元素相乘。注意式8中的 、 、 都是矩阵。 以上就是步长为1、输入的深度为1、filter个数为1的最简单的情况,卷积层误差项传递的算法。下面我们来推导一下步长为S的情况。 卷积步长为S时的误差传递 我们先来看看步长为S与步长为1的差别。 如上图,上面是步长为1时的卷积结果,下面是步长为2时的卷积结果。我们可以看出,因为步长为2,得到的feature map跳过了步长为1时相应的 部分。因此,当我们反向计算误差项时,我们可以对步长为S的sensitivity map相应的位置进行补0,将其『还原』成步长为1时的sensitivity map,再用式8进行求解。 输入层深度为D时的误差传递 f = (ne ) ∂al−1 i,j ∂netl−1 i,j f ′ tl−1 i,j δl−1 i,j = ∂Ed ∂netl−1 i,j = ∂Ed ∂al−1 i,j ∂al−1 i,j ∂netl−1 i,j = (ne )(式7)∑ m ∑ n wlm,nδl i+m,j+nf ′ tl−1 i,j = ∗ ∘ (ne )(式8)δl−1 δl W l f ′ tl−1 ∘ δl−1 δl netl−1 当输入深度为D时,filter的深度也必须为D, 层的 通道只与filter的 通道的权重进行计算。因此,反向计算误差项时,我们可以使用式8, 用filter的第 通道权重对第 层sensitivity map进行卷积,得到第 层 通道的sensitivity map。如下图所示: filter 数量为N时的误差传递 filter数量为N时,输出层的深度也为N,第 个filter卷积产生输出层的第 个feature map。由于第 层每个加权输入 都同时影响了第 层所 有feature map的输出值,因此,反向计算误差项时,需要使用全导数公式。也就是,我们先使用第 个filter对第 层相应的第 个sensitivity map进 行卷积,得到一组N个 层的偏sensitivity map。依次用每个filter做这种卷积,就得到D组偏sensitivity map。最后在各组之间将N个偏 sensitivity map 按元素相加,得到最终的N个 层的sensitivity map: 以上就是卷积层误差项传递的算法,如果读者还有所困惑,可以参考后面的代码实现来理解。 卷积层filter 权重梯度的计算 我们要在得到第 层sensitivity map的情况下,计算filter的权重的梯度,由于卷积层是权重共享的,因此梯度的计算稍有不同。 l − 1 di di di l l − 1 di i i l − 1 netl−1 d,i,j l d l d l − 1 l − 1 = ∗ ∘ (ne )(式9)δl−1 ∑ d=0 D δl d W l d f ′ tl−1 l 如上图所示, 是第 层的输出, 是第 层filter的权重, 是第 层的sensitivity map。我们的任务是计算 的梯度,即 。 为了计算偏导数,我们需要考察权重 对 的影响。权重项 通过影响 的值,进而影响 。我们仍然通过几个具体的例子来看权重项 对 的影响,然后再从中总结出规律。 例1,计算 : 从上面的公式看出,由于权值共享,权值 对所有的 都有影响。 是 、 、 ...的函数,而 、 、 ...又 是 的函数,根据全导数公式,计算 就是要把每个偏导数都加起来: 例2,计算 : 通过查看 与 的关系,我们很容易得到: 实际上,每个权重项都是类似的,我们不一一举例了。现在,是我们再次发挥想象力的时候,我们发现计算 规律是: 也就是用sensitivity map作为卷积核,在input上进行cross-correlation ,如下图所示: 最后,我们来看一看偏置项的梯度 。通过查看前面的公式,我们很容易发现: 也就是偏置项的梯度就是sensitivity map所有误差项之和。 对于步长为S的卷积层,处理方法与传递**误差项*是一样的,首先将sensitivity map『还原』成步长为1时的sensitivity map,再用上面的方法进行 计算。 al i,j l − 1 wi,j l δl i,j l wi,j ∂Ed ∂wi,j wi,j Ed wi,j netl i,j Ed wi,j netl i,j ∂Ed ∂w1,1 ne = + + + +tj 1,1 w1,1al−1 1,1 w1,2al−1 1,2 w2,1al−1 2,1 w2,2al−1 2,2 wb ne = + + + +tj 1,2 w1,1al−1 1,2 w1,2al−1 1,3 w2,1al−1 2,2 w2,2al−1 2,3 wb ne = + + + +tj 2,1 w1,1al−1 2,1 w1,2al−1 2,2 w2,1al−1 3,1 w2,2al−1 3,2 wb ne = + + + +tj 2,2 w1,1al−1 2,2 w1,2al−1 2,3 w2,1al−1 3,2 w2,2al−1 3,3 wb w1,1 netl i,j Ed netl 1,1 netl 1,2 netl 2,1 netl 1,1 netl 1,2 netl 2,1 w1,1 ∂Ed ∂w1,1 ∂Ed ∂w1,1 = + + +∂Ed ∂netl 1,1 ∂netl 1,1 ∂w1,1 ∂Ed ∂netl 1,2 ∂netl 1,2 ∂w1,1 ∂Ed ∂netl 2,1 ∂netl 2,1 ∂w1,1 ∂Ed ∂netl 2,2 ∂netl 2,2 ∂w1,1 = + + +δl 1,1al−1 1,1 δl 1,2al−1 1,2 δl 2,1al−1 2,1 δl 2,2al−1 2,2 ∂Ed ∂w1,2 w1,2 netl i,j = + + +∂Ed ∂w1,2 δl 1,1al−1 1,2 δl 1,2al−1 1,3 δl 2,1al−1 2,2 δl 2,2al−1 2,3 ∂Ed ∂wi,j =∂Ed ∂wi,j ∑ m ∑ n δm,n al−1 i+m,j+n ∂Ed ∂wb ∂Ed ∂wb = + + +∂Ed ∂netl 1,1 ∂netl 1,1 ∂wb ∂Ed ∂netl 1,2 ∂netl 1,2 ∂wb ∂Ed ∂netl 2,1 ∂netl 2,1 ∂wb ∂Ed ∂netl 2,2 ∂netl 2,2 ∂wb = + + +δl 1,1 δl 1,2 δl 2,1 δl 2,2 = ∑ i ∑ j δl i,j 获得了所有的梯度之后,就是根据梯度下降算法来更新每个权重。这在前面的文章中已经反复写过,这里就不再重复了。 至此,我们已经解决了卷积层的训练问题,接下来我们看一看Pooling层的训练。 Pooling 层的训练 无论max pooling还是mean pooling,都没有需要学习的参数。因此,在卷积神经网络的训练中,Pooling层需要做的仅仅是将误差项传递到上一 层,而没有梯度的计算。 Max Pooling 误差项的传递 如下图,假设第 层大小为4*4,pooling filter大小为2*2,步长为2,这样,max pooling之后,第 层大小为2*2。假设第 层的 值都已经计算完 毕,我们现在的任务是计算第 层的 值。 我们用 表示第 层的加权输入;用 表示第 层的加权输入。我们先来考察一个具体的例子,然后再总结一般性的规律。对于max pooling: 也就是说,只有区块中最大的 才会对 的值产生影响。我们假设最大的值是 ,则上式相当于: 那么,我们不难求得下面几个偏导数: 因此: l − 1 l l δ l − 1 δ netl−1 i,j l − 1 netl i,j l ne = max(ne , ne , ne , ne )tl 1,1 tl−1 1,1 tl−1 1,2 tl−1 2,1 tl−1 2,2 netl−1 i,j netl i,j netl−1 1,1 ne = netl 1,1 tl−1 1,1 = 1 ∂netl 1,1 ∂netl−1 1,1 = 0 ∂netl 1,1 ∂netl−1 1,2 = 0 ∂netl 1,1 ∂netl−1 2,1 = 0 ∂netl 1,1 ∂netl−1 2,2 而: 现在,我们发现了规律:对于max pooling,下一层的误差项的值会原封不动的传递到上一层对应区块中的最大值所对应的神经元,而其他神经元 的误差项的值都是0。如下图所示(假设 、 、 、 为所在区块中的最大输出值): Mean Pooling 误差项的传递 我们还是用前面屡试不爽的套路,先研究一个特殊的情形,再扩展为一般规律。 δl−1 1,1 = ∂Ed ∂netl−1 1,1 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 1,1 = δl 1,1 δl−1 1,2 δl−1 2,1 δl−1 1,1 = ∂Ed ∂netl−1 1,2 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 1,2 = 0 = ∂Ed ∂netl−1 2,1 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 2,1 = 0 = ∂Ed ∂netl−1 2,2 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 2,2 = 0 al−1 1,1 al−1 1,4 al−1 4,1 al−1 4,4 如上图,我们先来考虑计算 。我们先来看看 如何影响 。 根据上式,我们一眼就能看出来: 所以,根据链式求导法则,我们不难算出: 同样,我们可以算出 、 、 : δl−1 1,1 netl−1 1,1 netl 1,1 ne = (ne + ne + ne + ne )tj 1,1 1 4 tl−1 1,1 tl−1 1,2 tl−1 2,1 tl−1 2,2 = ∂netl 1,1 ∂netl−1 1,1 1 4 = ∂netl 1,1 ∂netl−1 1,2 1 4 = ∂netl 1,1 ∂netl−1 2,1 1 4 = ∂netl 1,1 ∂netl−1 2,2 1 4 δl−1 1,1 = ∂Ed ∂netl−1 1,1 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 1,1 = 1 4 δl 1,1 δl−1 1,2 δl−1 2,1 δl−1 2,2 现在,我们发现了规律:对于mean pooling,下一层的误差项的值会平均分配到上一层对应区块中的所有神经元。如下图所示: 上面这个算法可以表达为高大上的克罗内克积(Kronecker product) 的形式,有兴趣的读者可以研究一下。 其中, 是pooling层filter的大小, 、 都是矩阵。 至此,我们已经把卷积层、Pooling 层的训练算法介绍完毕,加上上一篇文章讲的全连接层训练算法,您应该已经具备了编写卷积神经网络代码所 需要的知识。为了加深对知识的理解,接下来,我们将展示如何实现一个简单的卷积神经网络。 卷积神经网络的实现 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/cnn.py (python2.7) 现在,我们亲自动手实现一个卷积神经网络,以便巩固我们所学的知识。 首先,我们要改变一下代码的架构,『层』成为了我们最核心的组件。这是因为卷积神经网络有不同的层,而每种层的算法都在对应的类中实 现。 δl−1 1,2 δl−1 2,1 δl−1 2,2 = ∂Ed ∂netl−1 1,2 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 1,2 = 1 4 δl 1,1 = ∂Ed ∂netl−1 2,1 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 2,1 = 1 4 δl 1,1 = ∂Ed ∂netl−1 2,2 = ∂Ed ∂netl 1,1 ∂netl 1,1 ∂netl−1 2,2 = 1 4 δl 1,1 = ⊗ (δl−1 δl 1 n2 )n×n n δl−1 δl 这次,我们用到了在python中编写算法经常会用到的numpy 包。为了使用numpy ,我们需要先将numpy 导入: 1. import numpy as np 卷积层的实现 卷积层初始化 我们用ConvLayer 类来实现一个卷积层。下面的代码是初始化一个卷积层,可以在构造函数中设置卷积层的超参数。 1. class ConvLayer(object): 2. def __init__(self, input_width, input_height, 3. channel_number, filter_width, 4. filter_height, filter_number, 5. zero_padding, stride, activator, 6. learning_rate): 7. self.input_width = input_width 8. self.input_height = input_height 9. self.channel_number = channel_number 10. self.filter_width = filter_width 11. self.filter_height = filter_height 12. self.filter_number = filter_number 13. self.zero_padding = zero_padding 14. self.stride = stride 15. self.output_width = \ 16. ConvLayer.calculate_output_size( 17. self.input_width, filter_width, zero_padding, 18. stride) 19. self.output_height = \ 20. ConvLayer.calculate_output_size( 21. self.input_height, filter_height, zero_padding, 22. stride) 23. self.output_array = np.zeros((self.filter_number, 24. self.output_height, self.output_width)) 25. self.filters = [] 26. for i in range(filter_number): 27. self.filters.append(Filter(filter_width, 28. filter_height, self.channel_number)) 29. self.activator = activator 30. self.learning_rate = learning_rate calculate_output_size 函数用来确定卷积层输出的大小,其实现如下: 1. @staticmethod 2. def calculate_output_size(input_size, 3. filter_size, zero_padding, stride): 4. return (input_size - filter_size + 5. 2 * zero_padding) / stride + 1 Filter 类保存了卷积层的参数以及梯度,并且实现了用梯度下降算法来更新参数。 1. class Filter(object): 2. def __init__(self, width, height, depth): 3. self.weights = np.random.uniform(-1e-4, 1e-4, 4. (depth, height, width)) 5. self.bias = 0 6. self.weights_grad = np.zeros( 7. self.weights.shape) 8. self.bias_grad = 0 9. 10. def __repr__(self): 11. return 'filter weights:\n%s\nbias:\n%s' % ( 12. repr(self.weights), repr(self.bias)) 13. 14. def get_weights(self): 15. return self.weights 16. 17. def get_bias(self): 18. return self.bias 19. 20. def update(self, learning_rate): 21. self.weights -= learning_rate * self.weights_grad 22. self.bias -= learning_rate * self.bias_grad 我们对参数的初始化采用了常用的策略,即:权重随机初始化为一个很小的值,而偏置项初始化为0。 Activator 类实现了激活函数,其中,forward 方法实现了前向计算,而backward 方法则是计算导数。比如,relu函数的实现如下: 1. class ReluActivator(object): 2. def forward(self, weighted_input): 3. #return weighted_input 4. return max(0, weighted_input) 5. 6. def backward(self, output): 7. return 1 if output > 0 else 0 卷积层前向计算的实现 ConvLayer 类的forward 方法实现了卷积层的前向计算(即计算根据输入来计算卷积层的输出),下面是代码实现: 1. def forward(self, input_array): 2. ''' 3. 计算卷积层的输出 4. 输出结果保存在self.output_array 5. ''' 6. self.input_array = input_array 7. self.padded_input_array = padding(input_array, 8. self.zero_padding) 9. for f in range(self.filter_number): 10. filter = self.filters[f] 11. conv(self.padded_input_array, 12. filter.get_weights(), self.output_array[f], 13. self.stride, filter.get_bias()) 14. element_wise_op(self.output_array, 15. self.activator.forward) 上面的代码里面包含了几个工具函数。element_wise_op 函数实现了对numpy 数组进行按元素操作,并将返回值写回到数组中,代码如下: 1. # 对numpy数组进行element wise操作 2. def element_wise_op(array, op): 3. for i in np.nditer(array, 4. op_flags=['readwrite']): 5. i[...] = op(i) conv 函数实现了2维和3维数组的卷积,代码如下: 1. def conv(input_array, 2. kernel_array, 3. output_array, 4. stride, bias): 5. ''' 6. 计算卷积,自动适配输入为2D和3D的情况 7. ''' 8. channel_number = input_array.ndim 9. output_width = output_array.shape[1] 10. output_height = output_array.shape[0] 11. kernel_width = kernel_array.shape[-1] 12. kernel_height = kernel_array.shape[-2] 13. for i in range(output_height): 14. for j in range(output_width): 15. output_array[i][j] = ( 16. get_patch(input_array, i, j, kernel_width, 17. kernel_height, stride) * kernel_array 18. ).sum() + bias padding 函数实现了zero padding操作: 1. # 为数组增加Zero padding 2. def padding(input_array, zp): 3. ''' 4. 为数组增加Zero padding,自动适配输入为2D和3D的情况 5. ''' 6. if zp == 0: 7. return input_array 8. else: 9. if input_array.ndim == 3: 10. input_width = input_array.shape[2] 11. input_height = input_array.shape[1] 12. input_depth = input_array.shape[0] 13. padded_array = np.zeros(( 14. input_depth, 15. input_height + 2 * zp, 16. input_width + 2 * zp)) 17. padded_array[:, 18. zp : zp + input_height, 19. zp : zp + input_width] = input_array 20. return padded_array 21. elif input_array.ndim == 2: 22. input_width = input_array.shape[1] 23. input_height = input_array.shape[0] 24. padded_array = np.zeros(( 25. input_height + 2 * zp, 26. input_width + 2 * zp)) 27. padded_array[zp : zp + input_height, 28. zp : zp + input_width] = input_array 29. return padded_array 卷积层反向传播算法的实现 现在,是介绍卷积层核心算法的时候了。我们知道反向传播算法需要完成几个任务: 1. 将误差项传递到上一层。 2. 计算每个参数的梯度。 3. 更新参数。 以下代码都是在ConvLayer 类中实现。我们先来看看将误差项传递到上一层的代码实现。 1. def bp_sensitivity_map(self, sensitivity_array, 2. activator): 3. ''' 4. 计算传递到上一层的sensitivity map 5. sensitivity_array: 本层的sensitivity map 6. activator: 上一层的激活函数 7. ''' 8. # 处理卷积步长,对原始sensitivity map进行扩展 9. expanded_array = self.expand_sensitivity_map( 10. sensitivity_array) 11. # full卷积,对sensitivitiy map进行zero padding 12. # 虽然原始输入的zero padding单元也会获得残差 13. # 但这个残差不需要继续向上传递,因此就不计算了 14. expanded_width = expanded_array.shape[2] 15. zp = (self.input_width + 16. self.filter_width - 1 - expanded_width) / 2 17. padded_array = padding(expanded_array, zp) 18. # 初始化delta_array,用于保存传递到上一层的 19. # sensitivity map 20. self.delta_array = self.create_delta_array() 21. # 对于具有多个filter的卷积层来说,最终传递到上一层的 22. # sensitivity map相当于所有的filter的 23. # sensitivity map之和 24. for f in range(self.filter_number): 25. filter = self.filters[f] 26. # 将filter权重翻转180度 27. flipped_weights = np.array(map( 28. lambda i: np.rot90(i, 2), 29. filter.get_weights())) 30. # 计算与一个filter对应的delta_array 31. delta_array = self.create_delta_array() 32. for d in range(delta_array.shape[0]): 33. conv(padded_array[f], flipped_weights[d], 34. delta_array[d], 1, 0) 35. self.delta_array += delta_array 36. # 将计算结果与激活函数的偏导数做element-wise乘法操作 37. derivative_array = np.array(self.input_array) 38. element_wise_op(derivative_array, 39. activator.backward) 40. self.delta_array *= derivative_array expand_sensitivity_map 方法就是将步长为S的sensitivity map『还原』为步长为1的sensitivity map,代码如下: 1. def expand_sensitivity_map(self, sensitivity_array): 2. depth = sensitivity_array.shape[0] 3. # 确定扩展后sensitivity map的大小 4. # 计算stride为1时sensitivity map的大小 5. expanded_width = (self.input_width - 6. self.filter_width + 2 * self.zero_padding + 1) 7. expanded_height = (self.input_height - 8. self.filter_height + 2 * self.zero_padding + 1) 9. # 构建新的sensitivity_map 10. expand_array = np.zeros((depth, expanded_height, 11. expanded_width)) 12. # 从原始sensitivity map拷贝误差值 13. for i in range(self.output_height): 14. for j in range(self.output_width): 15. i_pos = i * self.stride 16. j_pos = j * self.stride 17. expand_array[:,i_pos,j_pos] = \ 18. sensitivity_array[:,i,j] 19. return expand_array create_delta_array 是创建用来保存传递到上一层的sensitivity map的数组。 1. def create_delta_array(self): 2. return np.zeros((self.channel_number, 3. self.input_height, self.input_width)) 接下来,是计算梯度的代码。 1. def bp_gradient(self, sensitivity_array): 2. # 处理卷积步长,对原始sensitivity map进行扩展 3. expanded_array = self.expand_sensitivity_map( 4. sensitivity_array) 5. for f in range(self.filter_number): 6. # 计算每个权重的梯度 7. filter = self.filters[f] 8. for d in range(filter.weights.shape[0]): 9. conv(self.padded_input_array[d], 10. expanded_array[f], 11. filter.weights_grad[d], 1, 0) 12. # 计算偏置项的梯度 13. filter.bias_grad = expanded_array[f].sum() 最后,是按照梯度下降算法更新参数的代码,这部分非常简单。 1. def update(self): 2. ''' 3. 按照梯度下降,更新权重 4. ''' 5. for filter in self.filters: 6. filter.update(self.learning_rate) 卷积层的梯度检查 为了验证我们的公式推导和代码实现的正确性,我们必须要对卷积层进行梯度检查。下面是代吗实现: 1. def init_test(): 2. a = np.array( 3. [[[0,1,1,0,2], 4. [2,2,2,2,1], 5. [1,0,0,2,0], 6. [0,1,1,0,0], 7. [1,2,0,0,2]], 8. [[1,0,2,2,0], 9. [0,0,0,2,0], 10. [1,2,1,2,1], 11. [1,0,0,0,0], 12. [1,2,1,1,1]], 13. [[2,1,2,0,0], 14. [1,0,0,1,0], 15. [0,2,1,0,1], 16. [0,1,2,2,2], 17. [2,1,0,0,1]]]) 18. b = np.array( 19. [[[0,1,1], 20. [2,2,2], 21. [1,0,0]], 22. [[1,0,2], 23. [0,0,0], 24. [1,2,1]]]) 25. cl = ConvLayer(5,5,3,3,3,2,1,2,IdentityActivator(),0.001) 26. cl.filters[0].weights = np.array( 27. [[[-1,1,0], 28. [0,1,0], 29. [0,1,1]], 30. [[-1,-1,0], 31. [0,0,0], 32. [0,-1,0]], 33. [[0,0,-1], 34. [0,1,0], 35. [1,-1,-1]]], dtype=np.float64) 36. cl.filters[0].bias=1 37. cl.filters[1].weights = np.array( 38. [[[1,1,-1], 39. [-1,-1,1], 40. [0,-1,1]], 41. [[0,1,0], 42. [-1,0,-1], 43. [-1,1,0]], 44. [[-1,0,0], 45. [-1,0,1], 46. [-1,0,0]]], dtype=np.float64) 47. return a, b, cl 48. 49. 50. def gradient_check(): 51. ''' 52. 梯度检查 53. ''' 54. # 设计一个误差函数,取所有节点输出项之和 55. error_function = lambda o: o.sum() 56. 57. # 计算forward值 58. a, b, cl = init_test() 59. cl.forward(a) 60. 61. # 求取sensitivity map,是一个全1数组 62. sensitivity_array = np.ones(cl.output_array.shape, 63. dtype=np.float64) 64. # 计算梯度 65. cl.backward(a, sensitivity_array, 66. IdentityActivator()) 67. # 检查梯度 68. epsilon = 10e-4 69. for d in range(cl.filters[0].weights_grad.shape[0]): 70. for i in range(cl.filters[0].weights_grad.shape[1]): 71. for j in range(cl.filters[0].weights_grad.shape[2]): 72. cl.filters[0].weights[d,i,j] += epsilon 73. cl.forward(a) 74. err1 = error_function(cl.output_array) 75. cl.filters[0].weights[d,i,j] -= 2*epsilon 76. cl.forward(a) 77. err2 = error_function(cl.output_array) 78. expect_grad = (err1 - err2) / (2 * epsilon) 79. cl.filters[0].weights[d,i,j] += epsilon 80. print 'weights(%d,%d,%d): expected - actural %f - %f' % ( 81. d, i, j, expect_grad, cl.filters[0].weights_grad[d,i,j]) 上面代码值得思考的地方在于,传递给卷积层的sensitivity map是全1数组,留给读者自己推导一下为什么是这样(提示:激活函数选择了identity 函数: )。读者如果还有困惑,请写在文章评论中,我会回复。 运行上面梯度检查的代码,我们得到的输出如下,期望的梯度和实际计算出的梯度一致,这证明我们的算法推导和代码实现确实是正确的。 f(x) = x 以上就是卷积层的实现。 Max Pooling 层的实现 max pooling层的实现相对简单,我们直接贴出全部代码如下: 1. class MaxPoolingLayer(object): 2. def __init__(self, input_width, input_height, 3. channel_number, filter_width, 4. filter_height, stride): 5. self.input_width = input_width 6. self.input_height = input_height 7. self.channel_number = channel_number 8. self.filter_width = filter_width 9. self.filter_height = filter_height 10. self.stride = stride 11. self.output_width = (input_width - 12. filter_width) / self.stride + 1 13. self.output_height = (input_height - 14. filter_height) / self.stride + 1 15. self.output_array = np.zeros((self.channel_number, 16. self.output_height, self.output_width)) 17. 18. def forward(self, input_array): 19. for d in range(self.channel_number): 20. for i in range(self.output_height): 21. for j in range(self.output_width): 22. self.output_array[d,i,j] = ( 23. get_patch(input_array[d], i, j, 24. self.filter_width, 25. self.filter_height, 26. self.stride).max()) 27. 28. def backward(self, input_array, sensitivity_array): 29. self.delta_array = np.zeros(input_array.shape) 30. for d in range(self.channel_number): 31. for i in range(self.output_height): 32. for j in range(self.output_width): 33. patch_array = get_patch( 34. input_array[d], i, j, 35. self.filter_width, 36. self.filter_height, 37. self.stride) 38. k, l = get_max_index(patch_array) 39. self.delta_array[d, 40. i * self.stride + k, 41. j * self.stride + l] = \ 42. sensitivity_array[d,i,j] 全连接层的实现和上一篇文章类似,在此就不再赘述了。至此,你已经拥有了实现了一个简单的卷积神经网络所需要的基本组件。对于卷积神经 网络,现在有很多优秀的开源实现,因此我们并不需要真的自己去实现一个。贴出这些代码的目的是为了让我们更好的了解卷积神经网络的基本 原理。 卷积神经网络的应用 MNIST手写数字识别 LeNet-5是实现手写数字识别的卷积神经网络,在MNIST测试集上,它取得了0.8%的错误率。LeNet-5的结构如下: 关于LeNet-5的详细介绍,网上的资料很多,因此就不再重复了。感兴趣的读者可以尝试用我们自己实现的卷积神经网络代码去构造并训练LeNet- 5(当然代码会更复杂一些)。 小节 由于卷积神经网络的复杂性,我们写出了整个系列目前为止最长的一篇文章,相信读者也和作者一样累的要死。卷积神经网络是深度学习最重要 的工具(我犹豫要不要写上『之一』呢),付出一些辛苦去理解它也是值得的。如果您真正理解了本文的内容,相当于迈过了入门深度学习最重 要的一到门槛。在下一篇文章中,我们介绍深度学习另外一种非常重要的工具:循环神经网络,届时我们的系列文章也将完成过半。每篇文章都 是一个过滤器,对于坚持到这里的读者们,入门深度学习曙光已现,加油。 参考资料 1. CS231n Convolutional Neural Networks for Visual Recognition 2. ReLu (Rectified Linear Units) 激活函数 3. Jake Bouvrie, Notes on Convolutional Neural Networks, 2006 4. Ian Goodfellow, Yoshua Bengio, Aaron Courville, Deep Learning, MIT Press, 2016 零基础入门深度学习(5) - 循环神经网络 机器学习 深度学习入门 无论即将到来的是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序 员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?现在救命稻草来了,《零基础入门深度学习》系列文章旨 在讲帮助爱编程的你从零基础达到入门级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这是专门为程序员写 的文章。虽然文中会有很多公式你也许看不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序 员,所以我写的代码也不会很差)。 文章列表 零基础入门深度学习(1) - 感知器 零基础入门深度学习(2) - 线性单元和梯度下降 零基础入门深度学习(3) - 神经网络和反向传播算法 零基础入门深度学习(4) - 卷积神经网络 零基础入门深度学习(5) - 循环神经网络 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 零基础入门深度学习(7) - 递归神经网络 往期回顾 在前面的文章系列文章中,我们介绍了全连接神经网络和卷积神经网络,以及它们的训练和使用。他们都只能单独的取处理一个个的输入,前一 个输入和后一个输入是完全没有关系的。但是,某些任务需要能够更好的处理序列的信息,即前面的输入和后面的输入是有关系的。比如,当我 们在理解一句话意思时,孤立的理解这句话的每个词是不够的,我们需要处理这些词连接起来的整个序列;当我们处理视频的时候,我们也不能 只单独的去分析每一帧,而要分析这些帧连接起来的整个序列。这时,就需要用到深度学习领域中另一类非常重要神经网络:循环神经网络 (Recurrent Neural Network) 。RNN种类很多,也比较绕脑子。不过读者不用担心,本文将一如既往的对复杂的东西剥茧抽丝,帮助您理解RNNs 以及它的训练算法,并动手实现一个循环神经网络。 语言模型 RNN是在自然语言处理领域中最先被用起来的,比如,RNN可以为语言模型来建模。那么,什么是语言模型呢? 我们可以和电脑玩一个游戏,我们写出一个句子前面的一些词,然后,让电脑帮我们写下接下来的一个词。比如下面这句: 我昨天上学迟到了,老师批评了____。 我们给电脑展示了这句话前面这些词,然后,让电脑写下接下来的一个词。在这个例子中,接下来的这个词最有可能是『我』,而不太可能是 『小明』,甚至是『吃饭』。 语言模型就是这样的东西:给定一个一句话前面的部分,预测接下来最有可能的一个词是什么。 语言模型是对一种语言的特征进行建模,它有很多很多用处。比如在语音转文本(STT)的应用中,声学模型输出的结果,往往是若干个可能的候选 词,这时候就需要语言模型来从这些候选词中选择一个最可能的。当然,它同样也可以用在图像到文本的识别中(OCR)。 使用RNN之前,语言模型主要是采用N-Gram。N可以是一个自然数,比如2或者3。它的含义是,假设一个词出现的概率只与前面N个词相关。我 们以2-Gram为例。首先,对前面的一句话进行切词: 我 昨天 上学 迟到 了 ,老师 批评 了 ____。 如果用2-Gram进行建模,那么电脑在预测的时候,只会看到前面的『了』,然后,电脑会在语料库中,搜索『了』后面最可能的一个词。不管最 后电脑选的是不是『我』,我们都知道这个模型是不靠谱的,因为『了』前面说了那么一大堆实际上是没有用到的。如果是3-Gram模型呢,会搜 索『批评了』后面最可能的词,感觉上比2-Gram靠谱了不少,但还是远远不够的。因为这句话最关键的信息『我』,远在9个词之前! 现在读者可能会想,可以提升继续提升N的值呀,比如4-Gram、5-Gram.......。实际上,这个想法是没有实用性的。因为我们想处理任意长度的句 子,N设为多少都不合适;另外,模型的大小和N的关系是指数级的,4-Gram模型就会占用海量的存储空间。 所以,该轮到RNN出场了,RNN理论上可以往前看(往后看)任意多个词。 循环神经网络是啥 循环神经网络种类繁多,我们先从最简单的基本循环神经网络开始吧。 基本循环神经网络 下图是一个简单的循环神经网络如,它由输入层、一个隐藏层和一个输出层组成: 纳尼?!相信第一次看到这个玩意的读者内心和我一样是崩溃的。因为循环神经网络实在是太难画出来了,网上所有大神们都不得不用了这种抽 象艺术手法。不过,静下心来仔细看看的话,其实也是很好理解的。如果把上面有W的那个带箭头的圈去掉,它就变成了最普通的全连接神经网 络。x是一个向量,它表示输入层的值(这里面没有画出来表示神经元节点的圆圈);s是一个向量,它表示隐藏层的值(这里隐藏层面画了一个 节点,你也可以想象这一层其实是多个节点,节点数与向量s的维度相同);U是输入层到隐藏层的权重矩阵(读者可以回到第三篇文章零基础入 门深度学习(3) - 神经网络和反向传播算法,看看我们是怎样用矩阵来表示全连接神经网络的计算的);o也是一个向量,它表示输出层的值;V是 隐藏层到输出层的权重矩阵。那么,现在我们来看看W是什么。循环神经网络的隐藏层的值s不仅仅取决于当前这次的输入x,还取决于上一次隐 藏层的值s。权重矩阵 W就是隐藏层上一次的值作为这一次的输入的权重。 如果我们把上面的图展开,循环神经网络也可以画成下面这个样子: 现在看上去就比较清楚了,这个网络在t时刻接收到输入 之后,隐藏层的值是 ,输出值是 。关键一点是, 的值不仅仅取决于 ,还取决于 。我们可以用下面的公式来表示循环神经网络的计算方法: 式1是输出层的计算公式,输出层是一个全连接层,也就是它的每个节点都和隐藏层的每个节点相连。V是输出层的权重矩阵,g是激活函数。式2 是隐藏层的计算公式,它是循环层。U是输入x的权重矩阵,W是上一次的值 作为这一次的输入的权重矩阵,f是激活函数。 从上面的公式我们可以看出,循环层和全连接层的区别就是循环层多了一个权重矩阵 W。 如果反复把式2带入到式1,我们将得到: 从上面可以看出,循环神经网络的输出值 ,是受前面历次输入值 、 、 、 、...影响的,这就是为什么循环神经网络可以往前看任 意多个输入值的原因。 双向循环神经网络 对于语言模型来说,很多时候光看前面的词是不够的,比如下面这句话: 我的手机坏了,我打算____一部新手机。 可以想象,如果我们只看横线前面的词,手机坏了,那么我是打算修一修?换一部新的?还是大哭一场?这些都是无法确定的。但如果我们也看 到了横线后面的词是『一部新手机』,那么,横线上的词填『买』的概率就大得多了。 在上一小节中的基本循环神经网络是无法对此进行建模的,因此,我们需要双向循环神经网络,如下图所示: 当遇到这种从未来穿越回来的场景时,难免处于懵逼的状态。不过我们还是可以用屡试不爽的老办法:先分析一个特殊场景,然后再总结一般规 律。我们先考虑上图中, 的计算。 xt st ot st xt st−1 ot st = g(V ) (式1)st = f(U + W )(式2)xt st−1 st−1 ot = g(V )st = V f(U + W )xt st−1 = V f(U + Wf(U + W ))xt xt−1 st−2 = V f(U + Wf(U + Wf(U + W )))xt xt−1 xt−2 st−3 = V f(U + Wf(U + Wf(U + Wf(U +. . . ))))xt xt−1 xt−2 xt−3 ot xt xt−1 xt−2 xt−3 y2 从上图可以看出,双向卷积神经网络的隐藏层要保存两个值,一个A参与正向计算,另一个值A'参与反向计算。最终的输出值 取决于 和 。 其计算方法为: 和 则分别计算: 现在,我们已经可以看出一般的规律:正向计算时,隐藏层的值 与 有关;反向计算时,隐藏层的值 与 有关;最终的输出取决于正向和 反向计算的加和。现在,我们仿照式1和式2,写出双向循环神经网络的计算方法: 从上面三个公式我们可以看到,正向计算和反向计算不共享权重,也就是说U和U'、W和W'、V和V'都是不同的权重矩阵。 深度循环神经网络 前面我们介绍的循环神经网络只有一个隐藏层,我们当然也可以堆叠两个以上的隐藏层,这样就得到了深度循环神经网络。如下图所示: 我们把第i个隐藏层的值表示为 、 ,则深度循环神经网络的计算方式可以表示为: 循环神经网络的训练 循环神经网络的训练算法:BPTT BPTT算法是针对循环层的训练算法,它的基本原理和BP算法是一样的,也包含同样的三个步骤: 1. 前向计算每个神经元的输出值; y2 A2 A′ 2 = g(V + )y2 A2 V ′A′ 2 A2 A′ 2 A2 A′ 2 = f(W + U )A1 x2 = f( + )W ′A′ 3 U ′x2 st st−1 s′ t s′ t+1 ot st s′ t = g(V + )st V ′s′ t = f(U + W )xt st−1 = f( + )U ′xt W ′s′ t+1 s(i) t s′(i) t ot s(i) t s′(i) t ... s(1) t s′(1) t = g( + )V (i)s(i) t V ′(i)s′(i) t = f( + )U (i)s(i−1) t W (i)st−1 = f( + )U ′(i)s′(i−1) t W ′(i)s′ t+1 = f( + )U (1)xt W (1)st−1 = f( + )U ′(1)xt W ′(1)s′ t+1 2. 反向计算每个神经元的误差项 值,它是误差函数E对神经元j的加权输入 的偏导数; 3. 计算每个权重的梯度。 最后再用随机梯度下降算法更新权重。 循环层如下图所示: 前向计算 使用前面的式2对循环层进行前向计算: 注意,上面的 、 、 都是向量,用黑体字母表示;而U、V是矩阵,用大写字母表示。向量的下标表示时刻,例如, 表示在t时刻向量s的 值。 我们假设输入向量x的维度是m,输出向量s的维度是n,则矩阵U的维度是 ,矩阵W的维度是 。下面是上式展开成矩阵的样子,看起 来更直观一些: 在这里我们用手写体字母表示向量的一个元素,它的下标表示它是这个向量的第几个元素,它的上标表示第几个时刻。例如, 表示向量s的第j个 元素在t时刻的值。 表示输入层第i个神经元到循环层第j个神经元的权重。 表示循环层第t-1时刻的第i个神经元到循环层第t个时刻的第j个神经 元的权重。 误差项的计算 BTPP算法将第l层t时刻的误差项 值沿两个方向传播,一个方向是其传递到上一层网络,得到 ,这部分只和权重矩阵U有关;另一个是方向是 将其沿时间线传递到初始 时刻,得到 ,这部分只和权重矩阵W有关。 我们用向量 表示神经元在t时刻的加权输入,因为: 因此: 我们用a表示列向量,用 表示行向量。上式的第一项是向量函数对向量求导,其结果为Jacobian矩阵: 同理,上式第二项也是一个Jacobian矩阵: δj netj = f(U + W )st xt st−1 st xt st−1 st n × m n × n = f( + ) ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ st 1 st 2 . . stn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ ...u11u12 u1m ...u21u22 u2m . . ...un1un2 unm ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ x1 x2 . . xm ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ ...w11w12 w1n ...w21w22 w2n . . ...wn1wn2 wnn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ st−1 1 st−1 2 . . st−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ st j uji wji δl t δl−1 t t1 δl 1 nett nett st−1 = U + Wxt st−1 = f()nett−1 ∂nett ∂nett−1 = ∂nett ∂st−1 ∂st−1 ∂nett−1 aT ∂nett ∂st−1 = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ∂nett1 ∂st−11 ∂nett2 ∂st−11 ∂nettn ∂st−11 ∂nett1 ∂st−12 ∂nett2 ∂st−12 . . ∂nettn ∂st−12 ... ... ... ∂nett1 ∂st−1n ∂nett2 ∂st−1n ∂nettn ∂st−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ w11 w21 wn1 w12 w22 . . wn2 ... ... ... w1n w2n wnn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ = W 其中,diag[a]表示根据向量a创建一个对角矩阵,即 最后,将两项合在一起,可得: 上式描述了将 沿时间往前传递一个时刻的规律,有了这个规律,我们就可以求得任意时刻k的误差项 : 式3就是将误差项沿时间反向传播的算法。 循环层将误差项反向传递到上一层网络,与普通的全连接层是完全一样的,这在前面的文章零基础入门深度学习(3) - 神经网络和反向传播算法中 已经详细讲过了,在此仅简要描述一下。 循环层的加权输入 与上一层的加权输入 关系如下: 上式中 是第l层神经元的加权输入(假设第l层是循环层); 是第l-1层神经元的加权输入; 是第l-1层神经元的输出; 是第l-1层的激 活函数。 所以, ∂st−1 ∂nett−1 = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ∂st−11 ∂nett−11 ∂st−12 ∂nett−11 ∂st−1n ∂nett−11 ∂st−11 ∂nett−12 ∂st−12 ∂nett−12 . . ∂st−1n ∂nett−12 ... ... ... ∂st−11 ∂nett−1n ∂st−12 ∂nett−1n ∂st−1n ∂nett−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ (ne )f ′ tt−1 1 0 0 0 (ne )f ′ tt−1 2 . . 0 ... ... ... 0 0 (ne )f ′ tt−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ = diag[ ( )]f ′ nett−1 diag(a) = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ a1 0 0 0 a2 . . 0 ... ... ... 0 0 an ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ∂nett ∂nett−1 = ∂nett ∂st−1 ∂st−1 ∂nett−1 = Wdiag[ ( )]f ′ nett−1 = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ (ne )w11f ′ tt−1 1 (ne )w21f ′ tt−1 1 (ne )wn1f ′ tt−1 1 (ne )w12f ′ tt−1 2 (ne )w22f ′ tt−1 2 . . (ne )wn2f ′ tt−1 2 ... ... ... f(ne )w1n tt−1n f(ne )w2n tt−1n (ne )wnnf ′ tt−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ δ δk =δT k = = = = ∂E ∂netk ∂E ∂nett ∂nett ∂netk ...∂E ∂nett ∂nett ∂nett−1 ∂nett−1 ∂nett−2 ∂netk+1 ∂netk Wdiag[ ( )]Wdiag[ ( )]. . . Wdiag[ ( )]f ′ nett−1 f ′ nett−2 f ′ netk δl t Wdiag[ ( )] (式3)δT t ∏ i=k t−1 f ′ neti netl netl−1 =netl t =al−1 t U + Wal−1 t st−1 ()f l−1 netl−1 t netl t netl−1 t al−1 t f l−1 =∂netl t ∂netl−1 t = ∂netl ∂al−1 t ∂al−1 t ∂netl−1 t Udiag[ ( )]f ′l−1 netl−1 t 式4就是将误差项传递到上一层算法。 权重梯度的计算 现在,我们终于来到了BPTT算法的最后一步:计算每个权重的梯度。 首先,我们计算误差函数E对权重矩阵W的梯度 。 上图展示了我们到目前为止,在前两步中已经计算得到的量,包括每个时刻t 循环层的输出值 ,以及误差项 。 回忆一下我们在文章零基础入门深度学习(3) - 神经网络和反向传播算法介绍的全连接网络的权重梯度计算算法:只要知道了任意一个时刻的误差 项 ,以及上一个时刻循环层的输出值 ,就可以按照下面的公式求出权重矩阵在t时刻的梯度 : 在式5中, 表示t时刻误差项向量的第i个分量; 表示t-1时刻循环层第i个神经元的输出值。 我们下面可以简单推导一下式5。 我们知道: 因为对W求导与 无关,我们不再考虑。现在,我们考虑对权重项 求导。通过观察上式我们可以看到 只与 有关,所以: ( =δl−1 t )T = = ∂E ∂netl−1 t ∂E ∂netl t ∂netl t ∂netl−1 t ( Udiag[ ( )] (式4)δl t)T f ′l−1 netl−1 t ∂E ∂W st δt δt st−1 E∇Wt E = (式5)∇Wt ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ δt 1 st−1 1 δt 2 st−1 1 . . δtnst−1 1 δt 1 st−1 2 δt 2 st−1 2 δtnst−1 2 ... ... ... δt 1 st−1n δt 2 st−1n δtnst−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ δt i st−1 i =nett = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ nett 1 nett 2 . . nettn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ = U + Wxt st−1 U +xt ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ w11 w21 . . wn1 w12 w22 wn2 ... ... ... w1n w2n wnn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ st−1 1 st−1 2 . . st−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ U +xt ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ + . . .w11st−1 1 w12st−1 2 w1nst−1n + . . .w21st−1 1 w22st−1 2 w2nst−1n . . + . . .wn1st−1 1 wn2st−1 2 wnnst−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ Uxt wji wji nett j =∂E ∂wji = ∂E ∂nett j ∂nett j ∂wji δt j st−1 i 按照上面的规律就可以生成式5里面的矩阵。 我们已经求得了权重矩阵W在t时刻的梯度 ,最终的梯度 是各个时刻的梯度之和: 式6就是计算循环层权重矩阵W的梯度的公式。 ----------数学公式超高能预警---------- 前面已经介绍了 的计算方法,看上去还是比较直观的。然而,读者也许会困惑,为什么最终的梯度是各个时刻的梯度之和呢?我们前面只 是直接用了这个结论,实际上这里面是有道理的,只是这个数学推导比较绕脑子。感兴趣的同学可以仔细阅读接下来这一段,它用到了矩阵对矩 阵求导、张量与向量相乘运算的一些法则。 我们还是从这个式子开始: 因为 与W完全无关,我们把它看做常量。现在,考虑第一个式子加号右边的部分,因为W和 都是W的函数,因此我们要用到大学里 面都学过的导数乘法运算: 因此,上面第一个式子写成: 我们最终需要计算的是 : 我们先计算式7加号左边的部分。 是矩阵对矩阵求导,其结果是一个四维张量(tensor) ,如下所示: E∇Wt E∇W E =∇W = E∑ i=1 t ∇Wi +. . . + (式6) ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ δt 1 st−1 1 δt 2 st−1 1 . . δtnst−1 1 δt 1 st−1 2 δt 2 st−1 2 δtnst−1 2 ... ... ... δt 1 st−1n δt 2 st−1n δtnst−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ δ1 1 s0 1 δ1 2 s0 1 . . δ1ns0 1 δ1 1 s0 2 δ1 2 s0 2 δ1ns0 2 ... ... ... δ1 1 s0n δ1 2 s0n δ1ns0n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ E∇W = U + Wf()nett xt nett−1 Uxt f()nett−1 (uv = v + u)′ u′ v′ = f( ) + W∂nett ∂W ∂W ∂W nett−1 ∂f()nett−1 ∂W E∇W E =∇W = = ∂E ∂W ∂E ∂nett ∂nett ∂W f( ) + W (式7)δT t ∂W ∂W nett−1 δT t ∂f()nett−1 ∂W ∂W ∂W 接下来,我们知道 ,它是一个列向量。我们让上面的四维张量与这个向量相乘,得到了一个三维张量,再左乘行向量 ,最终 得到一个矩阵: 接下来,我们计算式7加号右边的部分: =∂W ∂W = = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢ ∂w11 ∂W ∂w21 ∂W . . ∂wn1 ∂W ∂w12 ∂W ∂w22 ∂W ∂wn2 ∂W ... ... ... ∂w1n ∂W ∂w2n ∂W ∂wnn ∂W ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢ ∂w11 ∂w11 ∂w11 ∂w21 . . ∂w11 ∂wn1 ∂w11 ∂w12 ∂w11 ∂w22 ∂w11 ∂wn2 ... ... ... ∂w11 ∂1n ∂w11 ∂2n ∂w11 ∂nn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥ . . ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢ ∂w12 ∂w11 ∂w12 ∂w21 . . ∂w12 ∂wn1 ∂w12 ∂w12 ∂w12 ∂w22 ∂w12 ∂wn2 ... ... ... ∂w12 ∂1n ∂w12 ∂2n ∂w12 ∂nn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥ ... ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ 1 0 . . 0 0 0 0 ... ... ... 0 0 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ . . ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ 0 0 . . 0 1 0 0 ... ... ... 0 0 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ... ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ = f()st−1 nett−1 δT t f( ) =δT t ∂W ∂W nett−1 = = = = = δT t ∂W ∂W st−1 δT t ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ 1 0 . . 0 0 0 0 ... ... ... 0 0 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ . . ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ 0 0 . . 0 1 0 0 ... ... ... 0 0 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ... ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ st−1 1 st−1 2 . . st−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ δT t ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ st−1 1 0 . . 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ . . ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ st−1 2 0 . . 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ... ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ [ ]δt 1 δt 2 ... δtn ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ st−1 1 0 . . 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ . . ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ st−1 2 0 . . 0 ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ... ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ δt 1 st−1 1 δt 2 st−1 1 . . δtnst−1 1 δt 1 st−1 2 δt 2 st−1 2 δtnst−1 2 ... ... ... δt 1 st−1n δt 2 st−1n δtnst−1n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ E∇Wt 于是,我们得到了如下递推公式: 这样,我们就证明了:最终的梯度 是各个时刻的梯度之和。 ----------数学公式超高能预警解除---------- 同权重矩阵W类似,我们可以得到权重矩阵U的计算方法。 式8是误差函数在t时刻对权重矩阵U的梯度。和权重矩阵W一样,最终的梯度也是各个时刻的梯度之和: 具体的证明这里就不再赘述了,感兴趣的读者可以练习推导一下。 RNN的梯度爆炸和消失问题 不幸的是,实践中前面介绍的几种RNNs并不能很好的处理较长的序列。一个主要的原因是,RNN在训练中很容易发生梯度爆炸和梯度消失,这导 致训练时梯度不能在较长序列中一直传递下去,从而使RNN无法捕捉到长距离的影响。 为什么RNN会产生梯度爆炸和消失问题呢?我们接下来将详细分析一下原因。我们根据式3可得: 上式的 定义为矩阵的模的上界。因为上式是一个指数函数,如果t-k很大的话(也就是向前看很远的时候),会导致对应的误差项的值增长或缩小 的非常快,这样就会导致相应的梯度爆炸和梯度消失问题(取决于 大于1还是小于1)。 通常来说,梯度爆炸更容易处理一些。因为梯度爆炸的时候,我们的程序会收到NaN错误。我们也可以设置一个梯度阈值,当梯度超过这个阈值 的时候可以直接截取。 梯度消失更难检测,而且也更难处理一些。总的来说,我们有三种方法应对梯度消失问题: 1. 合理的初始化权重值。初始化权重,使每个神经元尽可能不要取极大或极小值,以躲开梯度消失的区域。 2. 使用relu代替sigmoid和tanh作为激活函数。原理请参考上一篇文章零基础入门深度学习(4) - 卷积神经网络的激活函数一节。 W =δT t ∂f()nett−1 ∂W = = = WδT t ∂f()nett−1 ∂nett−1 ∂nett−1 ∂W W ()δT t f ′ nett−1 ∂nett−1 ∂W δT t ∂nett ∂nett−1 ∂nett−1 ∂W δT t−1 ∂nett−1 ∂W E =∇W = = = = = ∂E ∂W ∂E ∂nett ∂nett ∂W E +∇Wt δT t−1 ∂nett−1 ∂W E + E +∇Wt ∇Wt−1 δT t−2 ∂nett−2 ∂W E + E+. . . + E∇Wt ∇Wt−1 ∇W1 E∑ k=1 t ∇Wk E∇W E = (式8)∇Ut ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢ δt 1 xt 1 δt 2 xt 1 . . δtnxt 1 δt 1 xt 2 δt 2 xt 2 δtnxt 2 ... ... ... δt 1 xtm δt 2 xtm δtnxtm ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥ E = E∇U ∑ i=1 t ∇Ui =δT k ∥ ∥ ⩽δT k ⩽ Wdiag[ ( )]δT t ∏ i=k t−1 f ′ neti ∥ ∥ ∥W∥∥diag[ ( )]∥δT t ∏ i=k t−1 f ′ neti ∥ ∥(δT t βWβf)t−k β β 3. 使用其他结构的RNNs,比如长短时记忆网络(LTSM)和Gated Recurrent Unit(GRU),这是最流行的做法。我们将在以后的文章中介绍 这两种网络。 RNN的应用举例——基于RNN的语言模型 现在,我们介绍一下基于RNN语言模型。我们首先把词依次输入到循环神经网络中,每输入一个词,循环神经网络就输出截止到目前为止,下一 个最可能的词。例如,当我们依次输入: 我 昨天 上学 迟到 了 神经网络的输出如下图所示: 其中,s和e是两个特殊的词,分别表示一个序列的开始和结束。 向量化 我们知道,神经网络的输入和输出都是向量,为了让语言模型能够被神经网络处理,我们必须把词表达为向量的形式,这样神经网络才能处理 它。 神经网络的输入是词,我们可以用下面的步骤对输入进行向量化: 1. 建立一个包含所有词的词典,每个词在词典里面有一个唯一的编号。 2. 任意一个词都可以用一个N维的one-hot向量来表示。其中,N是词典中包含的词的个数。假设一个词在词典中的编号是i,v是表示这个词的向 量, 是向量的第j个元素,则: 上面这个公式的含义,可以用下面的图来直观的表示: 使用这种向量化方法,我们就得到了一个高维、稀疏的向量(稀疏是指绝大部分元素的值都是0)。处理这样的向量会导致我们的神经网络有很多 的参数,带来庞大的计算量。因此,往往会需要使用一些降维方法,将高维的稀疏向量转变为低维的稠密向量。不过这个话题我们就不再这篇文 章中讨论了。 语言模型要求的输出是下一个最可能的词,我们可以让循环神经网络计算计算词典中每个词是下一个词的概率,这样,概率最大的词就是下一个 最可能的词。因此,神经网络的输出向量也是一个N维向量,向量中的每个元素对应着词典中相应的词是下一个词的概率。如下图所示: vj = {vj 1 j = i 0 j ≠ i Softmax 层 前面提到,语言模型是对下一个词出现的概率进行建模。那么,怎样让神经网络输出概率呢?方法就是用softmax层作为神经网络的输出层。 我们先来看一下softmax函数的定义: 这个公式看起来可能很晕,我们举一个例子。Softmax层如下图所示: 从上图我们可以看到,softmax layer的输入是一个向量,输出也是一个向量,两个向量的维度是一样的(在这个例子里面是4)。输入向量x=[1 2 3 4]经过softmax层之后,经过上面的softmax函数计算,转变为输出向量y=[0.03 0.09 0.24 0.64]。计算过程为: 我们来看看输出向量y的特征: 1. 每一项为取值为0-1之间的正数; 2. 所有项的总和是1。 我们不难发现,这些特征和概率的特征是一样的,因此我们可以把它们看做是概率。对于语言模型来说,我们可以认为模型预测下一个词是词典 中第一个词的概率是0.03,是词典中第二个词的概率是0.09,以此类推。 语言模型的训练 可以使用监督学习的方法对语言模型进行训练,首先,需要准备训练数据集。接下来,我们介绍怎样把语料 我 昨天 上学 迟到 了 转换成语言模型的训练数据集。 g( ) =zi ezi ∑k ezk y1 y2 y3 y4 = ex1 ∑k exk = e1 + + +e1 e2 e3 e4 = 0.03 = e2 + + +e1 e2 e3 e4 = 0.09 = e3 + + +e1 e2 e3 e4 = 0.24 = e4 + + +e1 e2 e3 e4 = 0.64 首先,我们获取输入-标签对: 输入 标签 s 我 我 昨天 昨天 上学 上学 迟到 迟到 了 了 e 然后,使用前面介绍过的向量化方法,对输入x和标签y进行向量化。这里面有意思的是,对标签y进行向量化,其结果也是一个one-hot向量。例 如,我们对标签『我』进行向量化,得到的向量中,只有第2019个元素的值是1,其他位置的元素的值都是0。它的含义就是下一个词是『我』的 概率是1,是其它词的概率都是0。 最后,我们使用交叉熵误差函数作为优化目标,对模型进行优化。 在实际工程中,我们可以使用大量的语料来对模型进行训练,获取训练数据和训练的方法都是相同的。 交叉熵误差 一般来说,当神经网络的输出层是softmax层时,对应的误差函数E通常选择交叉熵误差函数,其定义如下: 在上式中,N是训练样本的个数,向量 是样本的标记,向量 是网络的输出。标记 是一个one-hot向量,例如 ,如果网络的 输出 ,那么,交叉熵误差是(假设只有一个训练样本,即N=1): 我们当然可以选择其他函数作为我们的误差函数,比如最小平方误差函数(MSE)。不过对概率进行建模时,选择交叉熵误差函数更make sense。 具体原因,感兴趣的读者请阅读参考文献7。 RNN的实现 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/rnn.py (python2.7) 为了加深我们对前面介绍的知识的理解,我们来动手实现一个RNN层。我们复用了上一篇文章零基础入门深度学习(4) - 卷积神经网络中的一些代 码,所以先把它们导入进来。 1. import numpy as np 2. from cnn import ReluActivator, IdentityActivator, element_wise_op 我们用RecurrentLayer类来实现一个循环层。下面的代码是初始化一个循环层,可以在构造函数中设置卷积层的超参数。我们注意到,循环层有 两个权重数组,U和W。 1. class RecurrentLayer(object): 2. def __init__(self, input_width, state_width, 3. activator, learning_rate): 4. 5. self.input_width = input_width 6. self.state_width = state_width 7. self.activator = activator 8. self.learning_rate = learning_rate 9. self.times = 0 # 当前时刻初始化为t0 L(y, o) = − log1 N ∑ n∈N yn on yn on yn = [1, 0, 0, 0]y1 o = [0.03, 0.09, 0.24, 0.64] L = − log1 N ∑ n∈N yn on = − logy1 o1 = −(1 ∗ log0.03 + 0 ∗ log0.09 + 0 ∗ log0.24 + 0 ∗ log0.64) = 3.51 10. self.state_list = [] # 保存各个时刻的state 11. self.state_list.append(np.zeros( 12. (state_width, 1))) # 初始化s0 13. self.U = np.random.uniform(-1e-4, 1e-4, 14. (state_width, input_width)) # 初始化U 15. self.W = np.random.uniform(-1e-4, 1e-4, 16. (state_width, state_width)) # 初始化W 在forward方法中,实现循环层的前向计算,这部分比较简单。 1. def forward(self, input_array): 2. ''' 3. 根据『式2』进行前向计算 4. ''' 5. self.times += 1 6. state = (np.dot(self.U, input_array) + 7. np.dot(self.W, self.state_list[-1])) 8. element_wise_op(state, self.activator.forward) 9. self.state_list.append(state) 在backword方法中,实现BPTT算法。 1. def backward(self, sensitivity_array, 2. activator): 3. ''' 4. 实现BPTT算法 5. ''' 6. self.calc_delta(sensitivity_array, activator) 7. self.calc_gradient() 8. 9. def calc_delta(self, sensitivity_array, activator): 10. self.delta_list = [] # 用来保存各个时刻的误差项 11. for i in range(self.times): 12. self.delta_list.append(np.zeros( 13. (self.state_width, 1))) 14. self.delta_list.append(sensitivity_array) 15. # 迭代计算每个时刻的误差项 16. for k in range(self.times - 1, 0, -1): 17. self.calc_delta_k(k, activator) 18. 19. def calc_delta_k(self, k, activator): 20. ''' 21. 根据k+1时刻的delta计算k时刻的delta 22. ''' 23. state = self.state_list[k+1].copy() 24. element_wise_op(self.state_list[k+1], 25. activator.backward) 26. self.delta_list[k] = np.dot( 27. np.dot(self.delta_list[k+1].T, self.W), 28. np.diag(state[:,0])).T 29. 30. def calc_gradient(self): 31. self.gradient_list = [] # 保存各个时刻的权重梯度 32. for t in range(self.times + 1): 33. self.gradient_list.append(np.zeros( 34. (self.state_width, self.state_width))) 35. for t in range(self.times, 0, -1): 36. self.calc_gradient_t(t) 37. # 实际的梯度是各个时刻梯度之和 38. self.gradient = reduce( 39. lambda a, b: a + b, self.gradient_list, 40. self.gradient_list[0]) # [0]被初始化为0且没有被修改过 41. 42. def calc_gradient_t(self, t): 43. ''' 44. 计算每个时刻t权重的梯度 45. ''' 46. gradient = np.dot(self.delta_list[t], 47. self.state_list[t-1].T) 48. self.gradient_list[t] = gradient 有意思的是,BPTT算法虽然数学推导的过程很麻烦,但是写成代码却并不复杂。 在update方法中,实现梯度下降算法。 1. def update(self): 2. ''' 3. 按照梯度下降,更新权重 4. ''' 5. self.W -= self.learning_rate * self.gradient 上面的代码不包含权重U的更新。这部分实际上和全连接神经网络是一样的,留给感兴趣的读者自己来完成吧。 循环层是一个带状态的层,每次forword都会改变循环层的内部状态,这给梯度检查带来了麻烦。因此,我们需要一个reset_state方法,来重置循 环层的内部状态。 1. def reset_state(self): 2. self.times = 0 # 当前时刻初始化为t0 3. self.state_list = [] # 保存各个时刻的state 4. self.state_list.append(np.zeros( 5. (self.state_width, 1))) # 初始化s0 最后,是梯度检查的代码。 1. def gradient_check(): 2. ''' 3. 梯度检查 4. ''' 5. # 设计一个误差函数,取所有节点输出项之和 6. error_function = lambda o: o.sum() 7. 8. rl = RecurrentLayer(3, 2, IdentityActivator(), 1e-3) 9. 10. # 计算forward值 11. x, d = data_set() 12. rl.forward(x[0]) 13. rl.forward(x[1]) 14. 15. # 求取sensitivity map 16. sensitivity_array = np.ones(rl.state_list[-1].shape, 17. dtype=np.float64) 18. # 计算梯度 19. rl.backward(sensitivity_array, IdentityActivator()) 20. 21. # 检查梯度 22. epsilon = 10e-4 23. for i in range(rl.W.shape[0]): 24. for j in range(rl.W.shape[1]): 25. rl.W[i,j] += epsilon 26. rl.reset_state() 27. rl.forward(x[0]) 28. rl.forward(x[1]) 29. err1 = error_function(rl.state_list[-1]) 30. rl.W[i,j] -= 2*epsilon 31. rl.reset_state() 32. rl.forward(x[0]) 33. rl.forward(x[1]) 34. err2 = error_function(rl.state_list[-1]) 35. expect_grad = (err1 - err2) / (2 * epsilon) 36. rl.W[i,j] += epsilon 37. print 'weights(%d,%d): expected - actural %f - %f' % ( 38. i, j, expect_grad, rl.gradient[i,j]) 需要注意,每次计算error之前,都要调用reset_state方法重置循环层的内部状态。下面是梯度检查的结果,没问题! 小节 至此,我们讲完了基本的循环神经网络、它的训练算法:BPTT,以及在语言模型上的应用。RNN比较烧脑,相信拿下前几篇文章的读者们搞定这 篇文章也不在话下吧!然而,循环神经网络这个话题并没有完结。我们在前面说到过,基本的循环神经网络存在梯度爆炸和梯度消失问题,并不 能真正的处理好长距离的依赖(虽然有一些技巧可以减轻这些问题)。事实上,真正得到广泛的应用的是循环神经网络的一个变体:长短时记忆 网络。它内部有一些特殊的结构,可以很好的处理长距离的依赖,我们将在下一篇文章中详细的介绍它。现在,让我们稍事休息,准备挑战更为 烧脑的长短时记忆网络吧。 参考资料 1. RECURRENT NEURAL NETWORKS TUTORIAL 2. Understanding LSTM Networks 3. The Unreasonable Effectiveness of Recurrent Neural Networks 4. Attention and Augmented Recurrent Neural Networks 5. On the difficulty of training recurrent neural networks, Bengio et al. 6. Recurrent neural network based language model, Mikolov et al. 7. Neural Network Classification, Categorical Data, Softmax Activation, and Cross Entropy Error, McCaffrey 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 机器学习 深度学习入门 无论即将到来的是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序 员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?现在救命稻草来了,《零基础入门深度学习》系列文章旨 在讲帮助爱编程的你从零基础达到入门级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这是专门为程序员写 的文章。虽然文中会有很多公式你也许看不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序 员,所以我写的代码也不会很差)。 文章列表 零基础入门深度学习(1) - 感知器 零基础入门深度学习(2) - 线性单元和梯度下降 零基础入门深度学习(3) - 神经网络和反向传播算法 零基础入门深度学习(4) - 卷积神经网络 零基础入门深度学习(5) - 循环神经网络 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 零基础入门深度学习(7) - 递归神经网络 往期回顾 在上一篇文章中,我们介绍了循环神经网络以及它的训练算法。我们也介绍了循环神经网络很难训练的原因,这导致了它在实际应用中,很难处 理长距离的依赖。在本文中,我们将介绍一种改进之后的循环神经网络:长短时记忆网络(Long Short T erm Memory Network, LSTM) ,它成功 的解决了原始循环神经网络的缺陷,成为当前最流行的RNN,在语音识别、图片描述、自然语言处理等许多领域中成功应用。但不幸的一面是, LSTM的结构很复杂,因此,我们需要花上一些力气,才能把LSTM以及它的训练算法弄明白。在搞清楚LSTM之后,我们再介绍一种LSTM的变 体:GRU (Gated Recurrent Unit) 。 它的结构比LSTM简单,而效果却和LSTM一样好,因此,它正在逐渐流行起来。最后,我们仍然会动手实 现一个LSTM。 长短时记忆网络是啥 我们首先了解一下长短时记忆网络产生的背景。回顾一下零基础入门深度学习(5) - 循环神经网络中推导的,误差项沿时间反向传播的公式: 我们可以根据下面的不等式,来获取 的模的上界(模可以看做对 中每一项值的大小的度量): 我们可以看到,误差项 从t时刻传递到k时刻,其值的上界是 的指数函数。 分别是对角矩阵 和矩阵W模的上界。显然, 除非 乘积的值位于1附近,否则,当t-k很大时(也就是误差传递很多个时刻时),整个式子的值就会变得极小(当 乘积小于1)或者极 大(当 乘积大于1),前者就是梯度消失,后者就是梯度爆炸。虽然科学家们搞出了很多技巧(比如怎样初始化权重),让 的值尽可能 贴近于1,终究还是难以抵挡指数函数的威力。 梯度消失到底意味着什么?在零基础入门深度学习(5) - 循环神经网络中我们已证明,权重数组W最终的梯度是各个时刻的梯度之和,即: =δT k diag[ ( )]WδT t ∏ i=k t−1 f ′ neti δT k δT k ∥ ∥ ⩽δT k ⩽ ∥ ∥ ∥diag[ ( )]∥∥W∥δT t ∏ i=k t−1 f ′ neti ∥ ∥(δT t βfβW)t−k δ βfβw βfβw diag[ ( )]f ′ neti βfβw βfβw βfβw βfβw 假设某轮训练中,各时刻的梯度以及最终的梯度之和如下图: 我们就可以看到,从上图的t-3时刻开始,梯度已经几乎减少到0了。那么,从这个时刻开始再往之前走,得到的梯度(几乎为零)就不会对最终的 梯度值有任何贡献,这就相当于无论t-3时刻之前的网络状态h是什么,在训练中都不会对权重数组W的更新产生影响,也就是网络事实上已经忽略 了t-3时刻之前的状态。这就是原始RNN无法处理长距离依赖的原因。 既然找到了问题的原因,那么我们就能解决它。从问题的定位到解决,科学家们大概花了7、8年时间。终于有一天,Hochreiter和Schmidhuber两 位科学家发明出长短时记忆网络,一举解决这个问题。 其实,长短时记忆网络的思路比较简单。原始RNN的隐藏层只有一个状态,即h,它对于短期的输入非常敏感。那么,假如我们再增加一个状态, 即c,让它来保存长期的状态,那么问题不就解决了么?如下图所示: 新增加的状态c,称为单元状态(cell state) 。我们把上图按照时间维度展开: 上图仅仅是一个示意图,我们可以看出,在t时刻,LSTM的输入有三个:当前时刻网络的输入值 、上一时刻LSTM的输出值 、以及上一时 刻的单元状态 ;LSTM的输出有两个:当前时刻LSTM输出值 、和当前时刻的单元状态 。注意 、 、 都是向量。 LSTM的关键,就是怎样控制长期状态c。在这里,LSTM的思路是使用三个控制开关。第一个开关,负责控制继续保存长期状态c;第二个开关, 负责控制把即时状态输入到长期状态c;第三个开关,负责控制是否把长期状态c作为当前的LSTM的输出。三个开关的作用如下图所示: E∇W = E∑ k=1 t ∇Wk = E + E + E+. . . + E∇Wt ∇Wt−1 ∇Wt−2 ∇W1 xt ht−1 ct−1 ht ct x h c 接下来,我们要描述一下,输出h和单元状态c的具体计算方法。 长短时记忆网络的前向计算 前面描述的开关是怎样在算法中实现的呢?这就用到了门(gate)的概念。门实际上就是一层全连接层,它的输入是一个向量,输出是一个0到1 之间的实数向量。假设W是门的权重向量, 是偏置项,那么门可以表示为: 门的使用,就是用门的输出向量按元素乘以我们需要控制的那个向量。因为门的输出是0到1之间的实数向量,那么,当门输出为0时,任何向量与 之相乘都会得到0向量,这就相当于啥都不能通过;输出为1时,任何向量与之相乘都不会有任何改变,这就相当于啥都可以通过。因为 (也就是 sigmoid函数)的值域是(0,1),所以门的状态都是半开半闭的。 LSTM用两个门来控制单元状态c的内容,一个是遗忘门(forget gate ),它决定了上一时刻的单元状态 有多少保留到当前时刻 ;另一个是 输入门(input gate ),它决定了当前时刻网络的输入 有多少保存到单元状态 。LSTM用输出门(output gate )来控制单元状态 有多少输 出到LSTM的当前输出值 。 我们先来看一下遗忘门: 上式中, 是遗忘门的权重矩阵, 表示把两个向量连接成一个更长的向量, 是遗忘门的偏置项, 是sigmoid函数。如果输入的维度 是 ,隐藏层的维度是 ,单元状态的维度是 (通常 ),则遗忘门的权重矩阵 维度是 。事实上,权重矩阵 都 是两个矩阵拼接而成的:一个是 ,它对应着输入项 ,其维度为 ;一个是 ,它对应着输入项 ,其维度为 。 可 以写为: 下图显示了遗忘门的计算: 接下来看看输入门: b g(x) = σ(Wx + b) σ ct−1 ct xt ct ct ht = σ( ⋅ [ , ] + ) (式1)ft Wf ht−1 xt bf Wf [,]ht−1 xt bf σ dx dh dc =dc dh Wf × ( + )dc dh dx Wf Wfh ht−1 ×dc dh Wfx xt ×dc dx Wf [][]Wf ht−1 xt = [ ] []Wfh Wfx ht−1 xt = +Wfhht−1 Wfxxt 上式中, 是输入门的权重矩阵, 是输入门的偏置项。下图表示了输入门的计算: 接下来,我们计算用于描述当前输入的单元状态 ,它是根据上一次的输出和本次输入来计算的: 下图是 的计算: 现在,我们计算当前时刻的单元状态 。它是由上一次的单元状态 按元素乘以遗忘门 ,再用当前输入的单元状态 按元素乘以输入门 , 再将两个积加和产生的: 符号 表示按元素乘。下图是 的计算: = σ( ⋅ [ , ] + ) (式2)it Wi ht−1 xt bi Wi bi c~ t = tanh( ⋅ [ , ] + ) (式3)c~ t Wc ht−1 xt bc c~ t ct ct−1 ft c~ t it = ∘ + ∘ (式4)ct ft ct−1 it c~ t ∘ ct 这样,我们就把LSTM关于当前的记忆 和长期的记忆 组合在一起,形成了新的单元状态 。由于遗忘门的控制,它可以保存很久很久之前的 信息,由于输入门的控制,它又可以避免当前无关紧要的内容进入记忆。下面,我们要看看输出门,它控制了长期记忆对当前输出的影响: 下图表示输出门的计算: LSTM最终的输出,是由输出门和单元状态共同确定的: 下图表示LSTM最终输出的计算: 式1到式6就是LSTM前向计算的全部公式。至此,我们就把LSTM前向计算讲完了。 长短时记忆网络的训练 熟悉我们这个系列文章的同学都清楚,训练部分往往比前向计算部分复杂多了。LSTM的前向计算都这么复杂,那么,可想而知,它的训练算法一 定是非常非常复杂的。现在只有做几次深呼吸,再一头扎进公式海洋吧。 LSTM训练算法框架 LSTM的训练算法仍然是反向传播算法,对于这个算法,我们已经非常熟悉了。主要有下面三个步骤: 1. 前向计算每个神经元的输出值,对于LSTM来说,即 、 、 、 、 五个向量的值。计算方法已经在上一节中描述过了。 2. 反向计算每个神经元的误差项 值。与循环神经网络一样,LSTM误差项的反向传播也是包括两个方向:一个是沿时间的反向传播,即从当前t 时刻开始,计算每个时刻的误差项;一个是将误差项向上一层传播。 3. 根据相应的误差项,计算每个权重的梯度。 关于公式和符号的说明 首先,我们对推导中用到的一些公式、符号做一下必要的说明。 接下来的推导中,我们设定gate的激活函数为sigmoid函数,输出的激活函数为tanh函数。他们的导数分别为: c~ t ct−1 ct = σ( ⋅ [ , ] + ) (式5)ot Wo ht−1 xt bo = ∘ tanh( ) (式6)ht ot ct ft it ct ot ht δ 从上面可以看出,sigmoid和tanh函数的导数都是原函数的函数。这样,我们一旦计算原函数的值,就可以用它来计算出导数的值。 LSTM需要学习的参数共有8组,分别是:遗忘门的权重矩阵 和偏置项 、输入门的权重矩阵 和偏置项 、输出门的权重矩阵 和偏置项 ,以及计算单元状态的权重矩阵 和偏置项 。因为权重矩阵的两部分在反向传播中使用不同的公式,因此在后续的推导中,权重矩阵 、 、 、 都将被写为分开的两个矩阵: 、 、 、 、 、 、 、 。 我们解释一下按元素乘 符号。当 作用于两个向量时,运算如下: 当 作用于一个向量和一个矩阵时,运算如下: 当 作用于两个矩阵时,两个矩阵对应位置的元素相乘。按元素乘可以在某些情况下简化矩阵和向量运算。例如,当一个对角矩阵右乘一个矩阵 时,相当于用对角矩阵的对角线组成的向量按元素乘那个矩阵: 当一个行向量右乘一个对角矩阵时,相当于这个行向量按元素乘那个矩阵对角线组成的向量: 上面这两点,在我们后续推导中会多次用到。 在t时刻,LSTM的输出值为 。我们定义t时刻的误差项 为: 注意,和前面几篇文章不同,我们这里假设误差项是损失函数对输出值的导数,而不是对加权输入 的导数。因为LSTM有四个加权输入,分别 对应 、 、 、 ,我们希望往上一层传递一个误差项而不是四个。但我们仍然需要定义出这四个加权输入,以及他们对应的误差项。 σ(z) (z)σ′ tanh(z) (z)tanh′ = y = 1 1 + e−z = y(1 − y) = y = −ez e−z +ez e−z = 1 − y2 Wf bf Wi bi Wo bo Wc bc Wf Wi Wc Wo Wfh Wfx Wih Wix Woh Wox Wch Wcx ∘ ∘ a ∘ b = ∘ = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ a1 a2 a3 ... an ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ b1 b2 b3 ... bn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ a1b1 a2b2 a3b3 ... anbn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ∘ a ∘ X = ∘ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ a1 a2 a3 ... an ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ x11 x21 x31 xn1 x12 x22 x32 xn2 x13 x23 x33 ... xn3 ... ... ... ... x1n x2n x3n xnn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ a1x11 a2x21 a3x31 anxn1 a1x12 a2x22 a3x32 anxn2 a1x13 a2x23 a3x33 ... anxn3 ... ... ... ... a1x1n a2x2n a3x3n anxnn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ ∘ diag[a]X = a ∘ X diag[b] = a ∘ baT ht δt δt =def ∂E ∂ht netl t ft it ct ot 误差项沿时间的反向传递 沿时间反向传递误差项,就是要计算出t-1时刻的误差项 。 我们知道, 是一个Jacobian矩阵。如果隐藏层h的维度是N的话,那么它就是一个 矩阵。为了求出它,我们列出 的计算公式,即前 面的式6和式4: 显然, 、 、 、 都是 的函数,那么,利用全导数公式可得: 下面,我们要把式7中的每个偏导数都求出来。根据式6,我们可以求出: 根据式4,我们可以求出: 因为: netf,t neti,t net ,tc~ neto,t δf,t δi,t δ ,tc~ δo,t = [ , ] +Wf ht−1 xt bf = + +Wfhht−1 Wfxxt bf = [ , ] +Wi ht−1 xt bi = + +Wihht−1 Wixxt bi = [ , ] +Wc ht−1 xt bc = + +Wchht−1 Wcxxt bc = [ , ] +Wo ht−1 xt bo = + +Wohht−1 Woxxt bo =def ∂E ∂netf,t =def ∂E ∂neti,t =def ∂E ∂net ,tc~ =def ∂E ∂neto,t δt−1 δT t−1 = ∂E ∂ht−1 = ∂E ∂ht ∂ht ∂ht−1 = δT t ∂ht ∂ht−1 ∂ht ∂ht−1 N × N ht ht ct = ∘ tanh( )ot ct = ∘ + ∘ft ct−1 it c~ t ot ft it c~t ht−1 δT t ∂ht ∂ht−1 = + + +δT t ∂ht ∂ot ∂ot ∂neto,t ∂neto,t ∂ht−1 δT t ∂ht ∂ct ∂ct ∂ft ∂ft ∂netf,t ∂netf,t ∂ht−1 δT t ∂ht ∂ct ∂ct ∂it ∂it ∂neti,t ∂neti,t ∂ht−1 δT t ∂ht ∂ct ∂ct ∂c~ t ∂c~ t ∂net ,tc~ ∂net ,tc~ ∂ht−1 = + + + (式7)δT o,t ∂neto,t ∂ht−1 δT f,t ∂netf,t ∂ht−1 δT i,t ∂neti,t ∂ht−1 δT ,tc~ ∂net ,tc~ ∂ht−1 ∂ht ∂ot ∂ht ∂ct = diag[tanh( )]ct = diag[ ∘ (1 − tanh( )]ot ct)2 ∂ct ∂ft ∂ct ∂it ∂ct ∂c~ t = diag[]ct−1 = diag[]c~ t = diag[]it 我们很容易得出: 将上述偏导数带入到式7,我们得到: 根据 、 、 、 的定义,可知: 式8到式12就是将误差沿时间反向传播一个时刻的公式。有了它,我们可以写出将误差项向前传递到任意k时刻的公式: 将误差项传递到上一层 我们假设当前为第l层,定义l-1层的误差项是误差函数对l-1层加权输入的导数,即: 本次LSTM的输入 由下面的公式计算: 上式中, 表示第l-1层的激活函数。 因为 、 、 、 都是 的函数, 又是 的函数,因此,要求出E对 的导数,就需要使用全导数公式: ot neto,t ft netf,t it neti,t c~ t net ,tc~ = σ()neto,t = + +Wohht−1 Woxxt bo = σ()netf,t = + +Wfhht−1 Wfxxt bf = σ()neti,t = + +Wihht−1 Wixxt bi = tanh( )net ,tc~ = + +Wchht−1 Wcxxt bc ∂ot ∂neto,t ∂neto,t ∂ht−1 ∂ft ∂netf,t ∂netf,t ∂ht−1 ∂it ∂neti,t ∂neti,t ∂ht−1 ∂c~ t ∂net ,tc~ ∂net ,tc~ ∂ht−1 = diag[ ∘ (1 − )]ot ot = Woh = diag[ ∘ (1 − )]ft ft = Wfh = diag[ ∘ (1 − )]it it = Wih = diag[1 − ]c~2 t = Wch δt−1 = + + +δT o,t ∂neto,t ∂ht−1 δT f,t ∂netf,t ∂ht−1 δT i,t ∂neti,t ∂ht−1 δT ,tc~ ∂net ,tc~ ∂ht−1 = + + + (式8)δT o,tWoh δT f,tWfh δT i,tWih δT ,tc~ Wch δo,t δf,t δi,t δ ,tc~ δT o,t δT f,t δT i,t δT ,tc~ = ∘ tanh( ) ∘ ∘ (1 − ) (式9)δT t ct ot ot = ∘ ∘ (1 − tanh( ) ∘ ∘ ∘ (1 − ) (式10)δT t ot ct)2 ct−1 ft ft = ∘ ∘ (1 − tanh( ) ∘ ∘ ∘ (1 − ) (式11)δT t ot ct)2 c~ t it it = ∘ ∘ (1 − tanh( ) ∘ ∘ (1 − ) (式12)δT t ot ct)2 it c~2 = + + + (式13)δT k ∏ j=k t−1 δT o,j Woh δT f,jWfh δT i,jWih δT ,jc~ Wch δl−1 t =def ∂E netl−1 t xt = ( )xl t f l−1 netl−1 t f l−1 netl f,t netl i,t netl ,tc~ netl o,t xt xt netl−1 t netl−1 t 式14就是将误差传递到上一层的公式。 权重梯度的计算 对于 、 、 、 的权重梯度,我们知道它的梯度是各个时刻梯度之和(证明过程请参考文章零基础入门深度学习(5) - 循环神经网 络),我们首先求出它们在t时刻的梯度,然后再求出他们最终的梯度。 我们已经求得了误差项 、 、 、 ,很容易求出t时刻的 、的 、的 、的 : 将各个时刻的梯度加在一起,就能得到最终的梯度: 对于偏置项 、 、 、 的梯度,也是将各个时刻的梯度加在一起。下面是各个时刻的偏置项梯度: 下面是最终的偏置项梯度,即将各个时刻的偏置项梯度加在一起: ∂E ∂netl−1 t = + + +∂E ∂netl f,t ∂netl f,t ∂xl t ∂xl t ∂netl−1 t ∂E ∂netl i,t ∂netl i,t ∂xl t ∂xl t ∂netl−1 t ∂E ∂netl ,tc~ ∂netl ,tc~ ∂xl t ∂xl t ∂netl−1 t ∂E ∂netl o,t ∂netl o,t ∂xl t ∂xl t ∂netl−1 t = ∘( )+ ∘( )+ ∘( )+ ∘( )δT f,tWfx f ′ netl−1 t δT i,tWix f ′ netl−1 t δT ,tc~ Wcx f ′ netl−1 t δT o,tWox f ′ netl−1 t = ( + + + ) ∘ ( ) (式14)δT f,tWfx δT i,tWix δT ,tc~ Wcx δT o,tWox f ′ netl−1 t Wfh Wih Wch Woh δo,t δf,t δi,t δ ,tc~ Woh Wih Wfh Wch ∂E ∂Woh,t ∂E ∂Wfh,t ∂E ∂Wih,t ∂E ∂Wch,t = ∂E ∂neto,t ∂neto,t ∂Woh,t = δo,thT t−1 = ∂E ∂netf,t ∂netf,t ∂Wfh,t = δf,thT t−1 = ∂E ∂neti,t ∂neti,t ∂Wih,t = δi,thT t−1 = ∂E ∂net ,tc~ ∂net ,tc~ ∂Wch,t = δ ,tc~ hT t−1 ∂E ∂Woh ∂E ∂Wfh ∂E ∂Wih ∂E ∂Wch = ∑ j=1 t δo,j hT j−1 = ∑ j=1 t δf,jhT j−1 = ∑ j=1 t δi,jhT j−1 = ∑ j=1 t δ ,jc~ hT j−1 bf bi bc bo ∂E ∂bo,t ∂E ∂bf,t ∂E ∂bi,t ∂E ∂bc,t = ∂E ∂neto,t ∂neto,t ∂bo,t = δo,t = ∂E ∂netf,t ∂netf,t ∂bf,t = δf,t = ∂E ∂neti,t ∂neti,t ∂bi,t = δi,t = ∂E ∂net ,tc~ ∂net ,tc~ ∂bc,t = δ ,tc~ 对于 、 、 、 的权重梯度,只需要根据相应的误差项直接计算即可: 以上就是LSTM的训练算法的全部公式。因为这里面存在很多重复的模式,仔细看看,会发觉并不是太复杂。 当然,LSTM存在着相当多的变体,读者可以在互联网上找到很多资料。因为大家已经熟悉了基本LSTM的算法,因此理解这些变体比较容易,因 此本文就不再赘述了。 长短时记忆网络的实现 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/lstm.py (python2.7) 在下面的实现中,LSTMLayer的参数包括输入维度、输出维度、隐藏层维度,单元状态维度等于隐藏层维度。gate的激活函数为sigmoid函数,输 出的激活函数为tanh。 激活函数的实现 我们先实现两个激活函数:sigmoid和tanh。 1. class SigmoidActivator(object): 2. def forward(self, weighted_input): 3. return 1.0 / (1.0 + np.exp(-weighted_input)) 4. 5. def backward(self, output): 6. return output * (1 - output) 7. 8. 9. class TanhActivator(object): 10. def forward(self, weighted_input): 11. return 2.0 / (1.0 + np.exp(-2 * weighted_input)) - 1.0 12. 13. def backward(self, output): 14. return 1 - output * output LSTM初始化 和前两篇文章代码架构一样,我们把LSTM的实现放在LstmLayer类中。 ∂E ∂bo ∂E ∂bi ∂E ∂bf ∂E ∂bc = ∑ j=1 t δo,j = ∑ j=1 t δi,j = ∑ j=1 t δf,j = ∑ j=1 t δ ,jc~ Wfx Wix Wcx Wox ∂E ∂Wox ∂E ∂Wfx ∂E ∂Wix ∂E ∂Wcx = ∂E ∂neto,t ∂neto,t ∂Wox = δo,txT t = ∂E ∂netf,t ∂netf,t ∂Wfx = δf,txT t = ∂E ∂neti,t ∂neti,t ∂Wix = δi,txT t = ∂E ∂net ,tc~ ∂net ,tc~ ∂Wcx = δ ,tc~ xT t 根据LSTM前向计算和方向传播算法,我们需要初始化一系列矩阵和向量。这些矩阵和向量有两类用途,一类是用于保存模型参数,例如 、 、 、 、 、 、 、 ;另一类是保存各种中间计算结果,以便于反向传播算法使用,它们包括 、 、 、 、 、 、 、 、 、 、 ,以及各个权重对应的梯度。 在构造函数的初始化中,只初始化了与forward计算相关的变量,与backward相关的变量没有初始化。这是因为构造LSTM对象的时候,我们还不 知道它未来是用于训练(既有forward又有backward)还是推理(只有forward)。 1. class LstmLayer(object): 2. def __init__(self, input_width, state_width, 3. learning_rate): 4. self.input_width = input_width 5. self.state_width = state_width 6. self.learning_rate = learning_rate 7. # 门的激活函数 8. self.gate_activator = SigmoidActivator() 9. # 输出的激活函数 10. self.output_activator = TanhActivator() 11. # 当前时刻初始化为t0 12. self.times = 0 13. # 各个时刻的单元状态向量c 14. self.c_list = self.init_state_vec() 15. # 各个时刻的输出向量h 16. self.h_list = self.init_state_vec() 17. # 各个时刻的遗忘门f 18. self.f_list = self.init_state_vec() 19. # 各个时刻的输入门i 20. self.i_list = self.init_state_vec() 21. # 各个时刻的输出门o 22. self.o_list = self.init_state_vec() 23. # 各个时刻的即时状态c~ 24. self.ct_list = self.init_state_vec() 25. # 遗忘门权重矩阵Wfh, Wfx, 偏置项bf 26. self.Wfh, self.Wfx, self.bf = ( 27. self.init_weight_mat()) 28. # 输入门权重矩阵Wfh, Wfx, 偏置项bf 29. self.Wih, self.Wix, self.bi = ( 30. self.init_weight_mat()) 31. # 输出门权重矩阵Wfh, Wfx, 偏置项bf 32. self.Woh, self.Wox, self.bo = ( 33. self.init_weight_mat()) 34. # 单元状态权重矩阵Wfh, Wfx, 偏置项bf 35. self.Wch, self.Wcx, self.bc = ( 36. self.init_weight_mat()) 37. 38. def init_state_vec(self): 39. ''' 40. 初始化保存状态的向量 41. ''' 42. state_vec_list = [] 43. state_vec_list.append(np.zeros( 44. (self.state_width, 1))) 45. return state_vec_list 46. 47. def init_weight_mat(self): 48. ''' 49. 初始化权重矩阵 50. ''' 51. Wh = np.random.uniform(-1e-4, 1e-4, 52. (self.state_width, self.state_width)) 53. Wx = np.random.uniform(-1e-4, 1e-4, 54. (self.state_width, self.input_width)) 55. b = np.zeros((self.state_width, 1)) 56. return Wh, Wx, b 前向计算的实现 Wf Wi Wo Wc bf bi bo bc ht ft it ot ct c~ t δt δf,t δi,t δo,t δ ,tc~ forward方法实现了LSTM的前向计算: 1. def forward(self, x): 2. ''' 3. 根据式1-式6进行前向计算 4. ''' 5. self.times += 1 6. # 遗忘门 7. fg = self.calc_gate(x, self.Wfx, self.Wfh, 8. self.bf, self.gate_activator) 9. self.f_list.append(fg) 10. # 输入门 11. ig = self.calc_gate(x, self.Wix, self.Wih, 12. self.bi, self.gate_activator) 13. self.i_list.append(ig) 14. # 输出门 15. og = self.calc_gate(x, self.Wox, self.Woh, 16. self.bo, self.gate_activator) 17. self.o_list.append(og) 18. # 即时状态 19. ct = self.calc_gate(x, self.Wcx, self.Wch, 20. self.bc, self.output_activator) 21. self.ct_list.append(ct) 22. # 单元状态 23. c = fg * self.c_list[self.times - 1] + ig * ct 24. self.c_list.append(c) 25. # 输出 26. h = og * self.output_activator.forward(c) 27. self.h_list.append(h) 28. 29. def calc_gate(self, x, Wx, Wh, b, activator): 30. ''' 31. 计算门 32. ''' 33. h = self.h_list[self.times - 1] # 上次的LSTM输出 34. net = np.dot(Wh, h) + np.dot(Wx, x) + b 35. gate = activator.forward(net) 36. return gate 从上面的代码我们可以看到,门的计算都是相同的算法,而门和 的计算仅仅是激活函数不同。因此我们提出了calc_gate方法,这样减少了很多 重复代码。 反向传播算法的实现 backward方法实现了LSTM的反向传播算法。需要注意的是,与backword相关的内部状态变量是在调用backward方法之后才初始化的。这种延迟 初始化的一个好处是,如果LSTM只是用来推理,那么就不需要初始化这些变量,节省了很多内存。 1. def backward(self, x, delta_h, activator): 2. ''' 3. 实现LSTM训练算法 4. ''' 5. self.calc_delta(delta_h, activator) 6. self.calc_gradient(x) 算法主要分成两个部分,一部分使计算误差项: 1. def calc_delta(self, delta_h, activator): 2. # 初始化各个时刻的误差项 3. self.delta_h_list = self.init_delta() # 输出误差项 4. self.delta_o_list = self.init_delta() # 输出门误差项 5. self.delta_i_list = self.init_delta() # 输入门误差项 6. self.delta_f_list = self.init_delta() # 遗忘门误差项 7. self.delta_ct_list = self.init_delta() # 即时输出误差项 c~ t 8. 9. # 保存从上一层传递下来的当前时刻的误差项 10. self.delta_h_list[-1] = delta_h 11. 12. # 迭代计算每个时刻的误差项 13. for k in range(self.times, 0, -1): 14. self.calc_delta_k(k) 15. 16. def init_delta(self): 17. ''' 18. 初始化误差项 19. ''' 20. delta_list = [] 21. for i in range(self.times + 1): 22. delta_list.append(np.zeros( 23. (self.state_width, 1))) 24. return delta_list 25. 26. def calc_delta_k(self, k): 27. ''' 28. 根据k时刻的delta_h,计算k时刻的delta_f、 29. delta_i、delta_o、delta_ct,以及k-1时刻的delta_h 30. ''' 31. # 获得k时刻前向计算的值 32. ig = self.i_list[k] 33. og = self.o_list[k] 34. fg = self.f_list[k] 35. ct = self.ct_list[k] 36. c = self.c_list[k] 37. c_prev = self.c_list[k-1] 38. tanh_c = self.output_activator.forward(c) 39. delta_k = self.delta_h_list[k] 40. 41. # 根据式9计算delta_o 42. delta_o = (delta_k * tanh_c * 43. self.gate_activator.backward(og)) 44. delta_f = (delta_k * og * 45. (1 - tanh_c * tanh_c) * c_prev * 46. self.gate_activator.backward(fg)) 47. delta_i = (delta_k * og * 48. (1 - tanh_c * tanh_c) * ct * 49. self.gate_activator.backward(ig)) 50. delta_ct = (delta_k * og * 51. (1 - tanh_c * tanh_c) * ig * 52. self.output_activator.backward(ct)) 53. delta_h_prev = ( 54. np.dot(delta_o.transpose(), self.Woh) + 55. np.dot(delta_i.transpose(), self.Wih) + 56. np.dot(delta_f.transpose(), self.Wfh) + 57. np.dot(delta_ct.transpose(), self.Wch) 58. ).transpose() 59. 60. # 保存全部delta值 61. self.delta_h_list[k-1] = delta_h_prev 62. self.delta_f_list[k] = delta_f 63. self.delta_i_list[k] = delta_i 64. self.delta_o_list[k] = delta_o 65. self.delta_ct_list[k] = delta_ct 另一部分是计算梯度: 1. def calc_gradient(self, x): 2. # 初始化遗忘门权重梯度矩阵和偏置项 3. self.Wfh_grad, self.Wfx_grad, self.bf_grad = ( 4. self.init_weight_gradient_mat()) 5. # 初始化输入门权重梯度矩阵和偏置项 6. self.Wih_grad, self.Wix_grad, self.bi_grad = ( 7. self.init_weight_gradient_mat()) 8. # 初始化输出门权重梯度矩阵和偏置项 9. self.Woh_grad, self.Wox_grad, self.bo_grad = ( 10. self.init_weight_gradient_mat()) 11. # 初始化单元状态权重梯度矩阵和偏置项 12. self.Wch_grad, self.Wcx_grad, self.bc_grad = ( 13. self.init_weight_gradient_mat()) 14. 15. # 计算对上一次输出h的权重梯度 16. for t in range(self.times, 0, -1): 17. # 计算各个时刻的梯度 18. (Wfh_grad, bf_grad, 19. Wih_grad, bi_grad, 20. Woh_grad, bo_grad, 21. Wch_grad, bc_grad) = ( 22. self.calc_gradient_t(t)) 23. # 实际梯度是各时刻梯度之和 24. self.Wfh_grad += Wfh_grad 25. self.bf_grad += bf_grad 26. self.Wih_grad += Wih_grad 27. self.bi_grad += bi_grad 28. self.Woh_grad += Woh_grad 29. self.bo_grad += bo_grad 30. self.Wch_grad += Wch_grad 31. self.bc_grad += bc_grad 32. print '-----%d-----' % t 33. print Wfh_grad 34. print self.Wfh_grad 35. 36. # 计算对本次输入x的权重梯度 37. xt = x.transpose() 38. self.Wfx_grad = np.dot(self.delta_f_list[-1], xt) 39. self.Wix_grad = np.dot(self.delta_i_list[-1], xt) 40. self.Wox_grad = np.dot(self.delta_o_list[-1], xt) 41. self.Wcx_grad = np.dot(self.delta_ct_list[-1], xt) 42. 43. def init_weight_gradient_mat(self): 44. ''' 45. 初始化权重矩阵 46. ''' 47. Wh_grad = np.zeros((self.state_width, 48. self.state_width)) 49. Wx_grad = np.zeros((self.state_width, 50. self.input_width)) 51. b_grad = np.zeros((self.state_width, 1)) 52. return Wh_grad, Wx_grad, b_grad 53. 54. def calc_gradient_t(self, t): 55. ''' 56. 计算每个时刻t权重的梯度 57. ''' 58. h_prev = self.h_list[t-1].transpose() 59. Wfh_grad = np.dot(self.delta_f_list[t], h_prev) 60. bf_grad = self.delta_f_list[t] 61. Wih_grad = np.dot(self.delta_i_list[t], h_prev) 62. bi_grad = self.delta_f_list[t] 63. Woh_grad = np.dot(self.delta_o_list[t], h_prev) 64. bo_grad = self.delta_f_list[t] 65. Wch_grad = np.dot(self.delta_ct_list[t], h_prev) 66. bc_grad = self.delta_ct_list[t] 67. return Wfh_grad, bf_grad, Wih_grad, bi_grad, \ 68. Woh_grad, bo_grad, Wch_grad, bc_grad 梯度下降算法的实现 下面是用梯度下降算法来更新权重: 1. def update(self): 2. ''' 3. 按照梯度下降,更新权重 4. ''' 5. self.Wfh -= self.learning_rate * self.Whf_grad 6. self.Wfx -= self.learning_rate * self.Whx_grad 7. self.bf -= self.learning_rate * self.bf_grad 8. self.Wih -= self.learning_rate * self.Whi_grad 9. self.Wix -= self.learning_rate * self.Whi_grad 10. self.bi -= self.learning_rate * self.bi_grad 11. self.Woh -= self.learning_rate * self.Wof_grad 12. self.Wox -= self.learning_rate * self.Wox_grad 13. self.bo -= self.learning_rate * self.bo_grad 14. self.Wch -= self.learning_rate * self.Wcf_grad 15. self.Wcx -= self.learning_rate * self.Wcx_grad 16. self.bc -= self.learning_rate * self.bc_grad 梯度检查的实现 和RecurrentLayer一样,为了支持梯度检查,我们需要支持重置内部状态: 1. def reset_state(self): 2. # 当前时刻初始化为t0 3. self.times = 0 4. # 各个时刻的单元状态向量c 5. self.c_list = self.init_state_vec() 6. # 各个时刻的输出向量h 7. self.h_list = self.init_state_vec() 8. # 各个时刻的遗忘门f 9. self.f_list = self.init_state_vec() 10. # 各个时刻的输入门i 11. self.i_list = self.init_state_vec() 12. # 各个时刻的输出门o 13. self.o_list = self.init_state_vec() 14. # 各个时刻的即时状态c~ 15. self.ct_list = self.init_state_vec() 最后,是梯度检查的代码: 1. def data_set(): 2. x = [np.array([[1], [2], [3]]), 3. np.array([[2], [3], [4]])] 4. d = np.array([[1], [2]]) 5. return x, d 6. 7. def gradient_check(): 8. ''' 9. 梯度检查 10. ''' 11. # 设计一个误差函数,取所有节点输出项之和 12. error_function = lambda o: o.sum() 13. 14. lstm = LstmLayer(3, 2, 1e-3) 15. 16. # 计算forward值 17. x, d = data_set() 18. lstm.forward(x[0]) 19. lstm.forward(x[1]) 20. 21. # 求取sensitivity map 22. sensitivity_array = np.ones(lstm.h_list[-1].shape, 23. dtype=np.float64) 24. # 计算梯度 25. lstm.backward(x[1], sensitivity_array, IdentityActivator()) 26. 27. # 检查梯度 28. epsilon = 10e-4 29. for i in range(lstm.Wfh.shape[0]): 30. for j in range(lstm.Wfh.shape[1]): 31. lstm.Wfh[i,j] += epsilon 32. lstm.reset_state() 33. lstm.forward(x[0]) 34. lstm.forward(x[1]) 35. err1 = error_function(lstm.h_list[-1]) 36. lstm.Wfh[i,j] -= 2*epsilon 37. lstm.reset_state() 38. lstm.forward(x[0]) 39. lstm.forward(x[1]) 40. err2 = error_function(lstm.h_list[-1]) 41. expect_grad = (err1 - err2) / (2 * epsilon) 42. lstm.Wfh[i,j] += epsilon 43. print 'weights(%d,%d): expected - actural %.4e - %.4e' % ( 44. i, j, expect_grad, lstm.Wfh_grad[i,j]) 45. return lstm 我们只对 做了检查,读者可以自行增加对其他梯度的检查。下面是某次梯度检查的结果: GRU 前面我们讲了一种普通的LSTM,事实上LSTM存在很多变体,许多论文中的LSTM都或多或少的不太一样。在众多的LSTM变体中,GRU (Gated Recurrent Unit) 也许是最成功的一种。它对LSTM做了很多简化,同时却保持着和LSTM相同的效果。因此,GRU最近变得越来越流行。 GRU对LSTM做了两个大改动: 1. 将输入门、遗忘门、输出门变为两个门:更新门(Update Gate) 和重置门(Reset Gate)。 2. 将单元状态与输出合并为一个状态: 。 GRU的前向计算公式为: 下图是GRU的示意图: GRU的训练算法比LSTM简单一些,留给读者自行推导,本文就不再赘述了。 Wfh zt rt h zt rt h~ t h = σ( ⋅ [ , ])Wz ht−1 xt = σ( ⋅ [ , ])Wr ht−1 xt = tanh(W ⋅ [ ∘ , ])rt ht−1 xt = (1 − ) ∘ + ∘zt ht−1 zt h~ t 小结 至此,LSTM——也许是结构最复杂的一类神经网络——就讲完了,相信拿下前几篇文章的读者们搞定这篇文章也不在话下吧!现在我们已经了解 循环神经网络和它最流行的变体——LSTM,它们都可以用来处理序列。但是,有时候仅仅拥有处理序列的能力还不够,还需要处理比序列更为复 杂的结构(比如树结构),这时候就需要用到另外一类网络:递归神经网络(Recursive Neural Network) ,巧合的是,它的缩写也是RNN。在下 一篇文章中,我们将介绍递归神经网络和它的训练算法。现在,漫长的烧脑暂告一段落,休息一下吧:) 参考资料 1. CS224d: Deep Learning for Natural Language Processing 2. Understanding LSTM Networks 3. LSTM Forward and Backward Pass 零基础入门深度学习(7) - 递归神经网络 机器学习 深度学习入门 无论即将到来的是大数据时代还是人工智能时代,亦或是传统行业使用人工智能在云上处理大数据的时代,作为一个有理想有追求的程序 员,不懂深度学习(Deep Learning)这个超热的技术,会不会感觉马上就out了?现在救命稻草来了,《零基础入门深度学习》系列文章旨 在讲帮助爱编程的你从零基础达到入门级水平。零基础意味着你不需要太多的数学知识,只要会写程序就行了,没错,这是专门为程序员写 的文章。虽然文中会有很多公式你也许看不懂,但同时也会有更多的代码,程序员的你一定能看懂的(我周围是一群狂热的Clean Code程序 员,所以我写的代码也不会很差)。 文章列表 零基础入门深度学习(1) - 感知器 零基础入门深度学习(2) - 线性单元和梯度下降 零基础入门深度学习(3) - 神经网络和反向传播算法 零基础入门深度学习(4) - 卷积神经网络 零基础入门深度学习(5) - 循环神经网络 零基础入门深度学习(6) - 长短时记忆网络(LSTM) 零基础入门深度学习(7) - 递归神经网络 往期回顾 在前面的文章中,我们介绍了循环神经网络,它可以用来处理包含序列结构的信息。然而,除此之外,信息往往还存在着诸如树结构、图结构等 更复杂的结构。对于这种复杂的结构,循环神经网络就无能为力了。本文介绍一种更为强大、复杂的神经网络:递归神经网络 (Recursive Neural Network, RNN) ,以及它的训练算法BPTS (Back Propagation Through Structure) 。顾名思义,递归神经网络(巧合的是,它的缩写和循环神 经网络一样,也是RNN)可以处理诸如树、图这样的递归结构。在文章的最后,我们将实现一个递归神经网络,并介绍它的几个应用场景。 递归神经网络是啥 因为神经网络的输入层单元个数是固定的,因此必须用循环或者递归的方式来处理长度可变的输入。循环神经网络实现了前者,通过将长度不定 的输入分割为等长度的小块,然后再依次的输入到网络中,从而实现了神经网络对变长输入的处理。一个典型的例子是,当我们处理一句话的时 候,我们可以把一句话看作是词组成的序列,然后,每次向循环神经网络输入一个词,如此循环直至整句话输入完毕,循环神经网络将产生对应 的输出。如此,我们就能处理任意长度的句子了。入下图所示: 然而,有时候把句子看做是词的序列是不够的,比如下面这句话『两个外语学院的学生』: 上图显示了这句话的两个不同的语法解析树。可以看出来这句话有歧义,不同的语法解析树则对应了不同的意思。一个是『两个外语学院的/学 生』,也就是学生可能有许多,但他们来自于两所外语学校;另一个是『两个/外语学院的学生』,也就是只有两个学生,他们是外语学院的。为 了能够让模型区分出两个不同的意思,我们的模型必须能够按照树结构去处理信息,而不是序列,这就是递归神经网络的作用。当面对按照树/图 结构处理信息更有效的任务时,递归神经网络通常都会获得不错的结果。 递归神经网络可以把一个树/图结构信息编码为一个向量,也就是把信息映射到一个语义向量空间中。这个语义向量空间满足某类性质,比如语义 相似的向量距离更近。也就是说,如果两句话(尽管内容不同)它的意思是相似的,那么把它们分别编码后的两个向量的距离也相近;反之,如 果两句话的意思截然不同,那么编码后向量的距离则很远。如下图所示: 从上图我们可以看到,递归神经网络将所有的词、句都映射到一个2维向量空间中。句子『the country of my birth』和句子『the place where I was born』的意思是非常接近的,所以表示它们的两个向量在向量空间中的距离很近。另外两个词『Germany』和『France』因为表示的都是地 点,它们的向量与上面两句话的向量的距离,就比另外两个表示时间的词『Monday』和『Tuesday』的向量的距离近得多。这样,通过向量的距 离,就得到了一种语义的表示。 上图还显示了自然语言可组合的性质:词可以组成句、句可以组成段落、段落可以组成篇章,而更高层的语义取决于底层的语义以及它们的组合 方式。递归神经网络是一种表示学习,它可以将词、句、段、篇按照他们的语义映射到同一个向量空间中,也就是把可组合(树/图结构)的信息 表示为一个个有意义的向量。比如上面这个例子,递归神经网络把句子"the country of my birth"表示为二维向量[1,5]。有了这个『编码器』之后, 我们就可以以这些有意义的向量为基础去完成更高级的任务(比如情感分析等)。如下图所示,递归神经网络在做情感分析时,可以比较好的处 理否定句,这是胜过其他一些模型的: 在上图中,蓝色表示正面评价,红色表示负面评价。每个节点是一个向量,这个向量表达了以它为根的子树的情感评价。比如"intelligent humor"是正面评价,而"care about cleverness wit or any other kind of intelligent humor"是中性评价。我们可以看到,模型能够正确的处理doesn't 的含义,将正面评价转变为负面评价。 尽管递归神经网络具有更为强大的表示能力,但是在实际应用中并不太流行。其中一个主要原因是,递归神经网络的输入是树/图结构,而这种结 构需要花费很多人工去标注。想象一下,如果我们用循环神经网络处理句子,那么我们可以直接把句子作为输入。然而,如果我们用递归神经网 络处理句子,我们就必须把每个句子标注为语法解析树的形式,这无疑要花费非常大的精力。很多时候,相对于递归神经网络能够带来的性能提 升,这个投入是不太划算的。 我们已经基本了解了递归神经网络是做什么用的,接下来,我们将探讨它的算法细节。 递归神经网络的前向计算 接下来,我们详细介绍一下递归神经网络是如何处理树/图结构的信息的。在这里,我们以处理树型信息为例进行介绍。 递归神经网络的输入是两个子节点(也可以是多个),输出就是将这两个子节点编码后产生的父节点,父节点的维度和每个子节点是相同的。如 下图所示: 和 分别是表示两个子节点的向量, 是表示父节点的向量。子节点和父节点组成一个全连接神经网络,也就是子节点的每个神经元都和父节 点的每个神经元两两相连。我们用矩阵 表示这些连接上的权重,它的维度将是 ,其中, 表示每个节点的维度。父节点的计算公式可以 写成: 在上式中,tanh是激活函数(当然也可以用其它的激活函数), 是偏置项,它也是一个维度为 的向量。如果读过前面的文章,相信大家已经非 常熟悉这些计算了,在此不做过多的解释了。 然后,我们把产生的父节点的向量和其他子节点的向量再次作为网络的输入,再次产生它们的父节点。如此递归下去,直至整棵树处理完毕。最 终,我们将得到根节点的向量,我们可以认为它是对整棵树的表示,这样我们就实现了把树映射为一个向量。在下图中,我们使用递归神经网络 处理一棵树,最终得到的向量 ,就是对整棵树的表示: c1 c2 p W d × 2d d p = tanh(W [] + b)(式1)c1 c2 b d p3 举个例子,我们使用递归神将网络将『两个外语学校的学生』映射为一个向量,如下图所示: 最后得到的向量 就是对整个句子『两个外语学校的学生』的表示。由于整个结构是递归的,不仅仅是根节点,事实上每个节点都是以其为根的 子树的表示。比如,在左边的这棵树中,向量 是短语『外语学院的学生』的表示,而向量 是短语『外语学院的』的表示。 式1就是递归神经网络的前向计算算法。它和全连接神经网络的计算没有什么区别,只是在输入的过程中需要根据输入的树结构依次输入每个子节 点。 需要特别注意的是,递归神经网络的权重 和偏置项 在所有的节点都是共享的。 递归神经网络的训练 递归神经网络的训练算法和循环神经网络类似,两者不同之处在于,前者需要将残差 从根节点反向传播到各个子节点,而后者是将残差 从当前 时刻 反向传播到初始时刻 。 下面,我们介绍适用于递归神经网络的训练算法,也就是BPTS算法。 误差项的传递 首先,我们先推导将误差从父节点传递到子节点的公式,如下图: p3 p2 p1 W b δ δ tk t1 定义 为误差函数E相对于父节点 的加权输入 的导数,即: 设 是父节点的加权输入,则 在上述式子里, 、 、 都是向量,而 是矩阵。为了看清楚它们的关系,我们将其展开: 在上面的公式中, 表示父节点p的第i个分量; 表示 子节点的第i个分量; 表示 子节点的第i个分量; 表示子节点 的第k个分量到 父节点p的第i个分量的的权重。根据上面展开后的矩阵乘法形式,我们不难看出,对于子节点 来说,它会影响父节点所有的分量。因此,我们 求误差函数E对 的导数时,必须用到全导数公式,也就是: 有了上式,我们就可以把它表示为矩阵形式,从而得到一个向量化表达: 其中,矩阵 是从矩阵W中提取部分元素组成的矩阵。其单元为: 上式看上去可能会让人晕菜,从下图,我们可以直观的看到 到底是啥。首先我们把W矩阵拆分为两个矩阵 和 ,如下图所示: 显然,子矩阵 和 分别对应子节点 和 的到父节点 权重。则矩阵 为: 也就是说,将误差项反向传递到相应子节点 的矩阵 就是其对应权重矩阵 的转置。 现在,我们设 是子节点 的加权输入, 是子节点c的激活函数,则: 这样,我们得到: δp p netp δp =def ∂E ∂netp netp = W [] + bnetp c1 c2 netp c1 c2 W ⎡ ⎣ ⎢⎢⎢⎢ netp1 netp2 ... netpn ⎤ ⎦ ⎥⎥⎥⎥ = + ⎡ ⎣ ⎢⎢⎢⎢ wp1c11 wp2c11 ... wpnc11 wp1c12 wp2c12 wpnc12 ... ... ... wp1c1n wp2c1n wpnc1n wp1c21 wp2c21 wpnc21 wp1c22 wp2c22 wpnc22 ... ... ... wp1c2n wp2c2n wpnc2n ⎤ ⎦ ⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ c11 c12 ... c1n c21 c22 ... c2n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ ⎡ ⎣ ⎢⎢⎢⎢⎢⎢ b1 b2 ... bn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥ pi c1i c1 c2i c2 wpicjk cj cjk cjk ∂E ∂cjk = ∑ i ∂E ∂netpi ∂netpi ∂cjk = ∑ i δpiwpicjk ∂E ∂cj = Uj δp Uj =ujik wpkcji Uj W1 W2 W1 W2 c1 c2 p Uj =Uj W T j cj Uj Wj netcj cj f = f()cj netcj δcj = ∂E ∂netcj = ∂E ∂cj ∂cj ∂netcj = ∘ ( )W T j δp f ′ netcj 如果我们将不同子节点 对应的误差项 连接成一个向量 。那么,上式可以写成: 式2就是将误差项从父节点传递到其子节点的公式。注意,上式中的 也是将两个子节点的加权输入 和 连在一起的向量。 有了传递一层的公式,我们就不难写出逐层传递的公式。 上图是在树型结构中反向传递误差项的全景图,反复应用式2,在已知 的情况下,我们不难算出 为: 在上面的公式中, , 表示取向量 属于节点p的部分。 权重梯度的计算 根据加权输入的计算公式: 其中, 表示第l层的父节点的加权输入, 表示第l层的子节点。 是权重矩阵, 是偏置项。将其展开可得: 那么,我们可以求得误差函数在第l层对权重的梯度为: 上式是针对一个权重项 的公式,现在需要把它扩展为对所有的权重项的公式。我们可以把上式写成矩阵的形式(在下面的公式中,m=2n): cj δcj = []δc δc1 δc2 = ∘ ( ) (式2)δc W T δp f ′ netc netc netc1 netc2 δ(3) p δ(1) p δ(2) δ(2) p δ(1) δ(1) p = ∘ ( )W T δ(3) p f ′ net(2) = [δ(2)]p = ∘ ( )W T δ(2) p f ′ net(1) = [δ(1)]p = []δ(2) δ(2) c δ(2) p [δ(2)]p δ(2) = W + bnet(l) p c(l) net(l) p c(l) W b = +netl pj ∑ i wjicl i bj ∂E ∂w(l) ji = ∂E ∂net(l) pj ∂net(l) pj ∂w(l) ji = δ(l) pj c(l) i wji 式3就是第l层权重项的梯度计算公式。我们知道,由于权重 是在所有层共享的,所以和循环神经网络一样,递归神经网络的最终的权重梯度是 各个层权重梯度之和。即: 因为循环神经网络的证明过程已经在零基础入门深度学习(4) - 卷积神经网络一文中给出,因此,递归神经网络『为什么最终梯度是各层梯度之 和』的证明就留给读者自行完成啦。 接下来,我们求偏置项 的梯度计算公式。先计算误差函数对第l层偏置项 的梯度: 把上式扩展为矩阵的形式: 式5是第l层偏置项的梯度,那么最终的偏置项梯度是各个层偏置项梯度之和,即: ∂E ∂W (l) = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ∂E ∂w(l) 11 ∂E ∂w(l) 21 ... ∂E ∂w(l) n1 ∂E ∂w(l) 12 ∂E ∂w(l) 22 ∂E ∂w(l) n2 ... ... ... ∂E ∂w(l) 1m ∂E ∂w(l) 2m ∂E ∂w(l)nm ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ δ(l) p1 cl 1 δ(l) p2 cl 1 ... δ(l) pncl 1 δ(l) p1 cl 2 δ(l) p2 cl 2 lδ(l) pn cl 2 ... ... ... δlp1c(l) m δlp2c(l) m δlpnc(l) m ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ = ( (式3)δ(l) c(l))T W = (式4)∂E ∂W ∑ l ∂E ∂W (l) b b(l) ∂E ∂b(l) j = ∂E ∂net(l) pj ∂net(l) pj ∂b(l) j = δ(l) pj ∂E ∂b(l) = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ ∂E ∂b(l) 1 ∂E ∂b(l) 2 ... ∂E ∂b(l)n ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ = ⎡ ⎣ ⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢⎢ δ(l) p1 δ(l) p2 ... δ(l) pn ⎤ ⎦ ⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥⎥ = (式5)δ(l) p 权重更新 如果使用梯度下降优化算法,那么权重更新公式为: 其中, 是学习速率常数。把式4带入到上式,即可完成权重的更新。同理,偏置项的更新公式为: 把式6带入到上式,即可完成偏置项的更新。 这就是递归神经网络的训练算法BPTS。由于我们有了前面几篇文章的基础,相信读者们理解BPTS算法也会比较容易。 递归神经网络的实现 完整代码请参考GitHub: https://github.com/hanbt/learn_dl/blob/master/recursive.py (python2.7) 现在,我们实现一个处理树型结构的递归神经网络。 在文件的开头,加入如下代码: 1. #!/usr/bin/env python 2. # -*- coding: UTF-8 -*- 3. 4. 5. import numpy as np 6. from cnn import IdentityActivator 上述四行代码非常简单,没有什么需要解释的。IdentityActivator激活函数是在我们介绍卷积神经网络时写的,现在引用一下它。 我们首先定义一个树节点结构,这样,我们就可以用它保存卷积神经网络生成的整棵树: 1. class TreeNode(object): 2. def __init__(self, data, children=[], children_data=[]): 3. self.parent = None 4. self.children = children 5. self.children_data = children_data 6. self.data = data 7. for child in children: 8. child.parent = self 接下来,我们把递归神经网络的实现代码都放在RecursiveLayer类中,下面是这个类的构造函数: 1. # 递归神经网络实现 2. class RecursiveLayer(object): 3. def __init__(self, node_width, child_count, 4. activator, learning_rate): 5. ''' 6. 递归神经网络构造函数 7. node_width: 表示每个节点的向量的维度 8. child_count: 每个父节点有几个子节点 9. activator: 激活函数对象 10. learning_rate: 梯度下降算法学习率 11. ''' 12. self.node_width = node_width 13. self.child_count = child_count 14. self.activator = activator 15. self.learning_rate = learning_rate 16. # 权重数组W = (式6)∂E ∂b ∑ l ∂E ∂b(l) W ← W + η ∂E ∂W η b ← b + η ∂E ∂b 17. self.W = np.random.uniform(-1e-4, 1e-4, 18. (node_width, node_width * child_count)) 19. # 偏置项b 20. self.b = np.zeros((node_width, 1)) 21. # 递归神经网络生成的树的根节点 22. self.root = None 下面是前向计算的实现: 1. def forward(self, *children): 2. ''' 3. 前向计算 4. ''' 5. children_data = self.concatenate(children) 6. parent_data = self.activator.forward( 7. np.dot(self.W, children_data) + self.b 8. ) 9. self.root = TreeNode(parent_data, children 10. , children_data) forward函数接收一系列的树节点对象作为输入,然后,递归神经网络将这些树节点作为子节点,并计算它们的父节点。最后,将计算的父节点保 存在self.root变量中。 上面用到的concatenate函数,是将各个子节点中的数据拼接成一个长向量,其代码如下: 1. def concatenate(self, tree_nodes): 2. ''' 3. 将各个树节点中的数据拼接成一个长向量 4. ''' 5. concat = np.zeros((0,1)) 6. for node in tree_nodes: 7. concat = np.concatenate((concat, node.data)) 8. return concat 下面是反向传播算法BPTS的实现: 1. def backward(self, parent_delta): 2. ''' 3. BPTS反向传播算法 4. ''' 5. self.calc_delta(parent_delta, self.root) 6. self.W_grad, self.b_grad = self.calc_gradient(self.root) 7. 8. def calc_delta(self, parent_delta, parent): 9. ''' 10. 计算每个节点的delta 11. ''' 12. parent.delta = parent_delta 13. if parent.children: 14. # 根据式2计算每个子节点的delta 15. children_delta = np.dot(self.W.T, parent_delta) * ( 16. self.activator.backward(parent.children_data) 17. ) 18. # slices = [(子节点编号,子节点delta起始位置,子节点delta结束位置)] 19. slices = [(i, i * self.node_width, 20. (i + 1) * self.node_width) 21. for i in range(self.child_count)] 22. # 针对每个子节点,递归调用calc_delta函数 23. for s in slices: 24. self.calc_delta(children_delta[s[1]:s[2]], 25. parent.children[s[0]]) 26. 27. def calc_gradient(self, parent): 28. ''' 29. 计算每个节点权重的梯度,并将它们求和,得到最终的梯度 30. ''' 31. W_grad = np.zeros((self.node_width, 32. self.node_width * self.child_count)) 33. b_grad = np.zeros((self.node_width, 1)) 34. if not parent.children: 35. return W_grad, b_grad 36. parent.W_grad = np.dot(parent.delta, parent.children_data.T) 37. parent.b_grad = parent.delta 38. W_grad += parent.W_grad 39. b_grad += parent.b_grad 40. for child in parent.children: 41. W, b = self.calc_gradient(child) 42. W_grad += W 43. b_grad += b 44. return W_grad, b_grad 在上述算法中,calc_delta函数和calc_gradient函数分别计算各个节点的误差项 以及最终的梯度。它们都采用递归算法,先序遍历整个树,并逐 一完成每个节点的计算。 下面是梯度下降算法的实现(没有weight decay),这个非常简单: 1. def update(self): 2. ''' 3. 使用SGD算法更新权重 4. ''' 5. self.W -= self.learning_rate * self.W_grad 6. self.b -= self.learning_rate * self.b_grad 以上就是递归神经网络的实现,总共100行左右,和上一篇文章的LSTM相比简单多了。 最后,我们用梯度检查来验证程序的正确性: 1. def gradient_check(): 2. ''' 3. 梯度检查 4. ''' 5. # 设计一个误差函数,取所有节点输出项之和 6. error_function = lambda o: o.sum() 7. 8. rnn = RecursiveLayer(2, 2, IdentityActivator(), 1e-3) 9. 10. # 计算forward值 11. x, d = data_set() 12. rnn.forward(x[0], x[1]) 13. rnn.forward(rnn.root, x[2]) 14. 15. # 求取sensitivity map 16. sensitivity_array = np.ones((rnn.node_width, 1), 17. dtype=np.float64) 18. # 计算梯度 19. rnn.backward(sensitivity_array) 20. 21. # 检查梯度 22. epsilon = 10e-4 23. for i in range(rnn.W.shape[0]): 24. for j in range(rnn.W.shape[1]): 25. rnn.W[i,j] += epsilon 26. rnn.reset_state() 27. rnn.forward(x[0], x[1]) 28. rnn.forward(rnn.root, x[2]) 29. err1 = error_function(rnn.root.data) δ 30. rnn.W[i,j] -= 2*epsilon 31. rnn.reset_state() 32. rnn.forward(x[0], x[1]) 33. rnn.forward(rnn.root, x[2]) 34. err2 = error_function(rnn.root.data) 35. expect_grad = (err1 - err2) / (2 * epsilon) 36. rnn.W[i,j] += epsilon 37. print 'weights(%d,%d): expected - actural %.4e - %.4e' % ( 38. i, j, expect_grad, rnn.W_grad[i,j]) 39. return rnn 下面是梯度检查的结果,完全正确,OH YEAH! 递归神经网络的应用 自然语言和自然场景解析 在自然语言处理任务中,如果我们能够实现一个解析器,将自然语言解析为语法树,那么毫无疑问,这将大大提升我们对自然语言的处理能力。 解析器如下所示: 可以看出,递归神经网络能够完成句子的语法分析,并产生一个语法解析树。 除了自然语言之外,自然场景也具有可组合的性质。因此,我们可以用类似的模型完成自然场景的解析,如下图所示: 两种不同的场景,可以用相同的递归神经网络模型来实现。我们以第一个场景,自然语言解析为例。 我们希望将一句话逐字输入到神经网络中,然后,神经网络返回一个解析好的树。为了做到这一点,我们需要给神经网络再加上一层,负责打 分。分数越高,说明两个子节点结合更加紧密,分数越低,说明两个子节点结合更松散。如下图所示: 一旦这个打分函数训练好了(也就是矩阵U的各项值变为合适的值),我们就可以利用贪心算法来实现句子的解析。第一步,我们先将词按照顺序 两两输入神经网络,得到第一组打分: 我们发现,现在分数最高的是第一组,The cat,说明它们的结合是最紧密的。这样,我们可以先将它们组合为一个节点。然后,再次两两计算相 邻子节点的打分: 现在,分数最高的是最后一组,the mat。于是,我们将它们组合为一个节点,再两两计算相邻节点的打分: 这时,我们发现最高的分数是on the mat,把它们组合为一个节点,继续两两计算相邻节点的打分......最终,我们就能够得到整个解析树: 现在,我们困惑这样牛逼的打分函数score是怎样训练出来的呢?我们需要定义一个目标函数。这里,我们使用Max-Margin目标函数。它的定义如 下: 在上式中, 、 分别表示第i个训练样本的输入和标签,注意这里的标签 是一棵解析树。 就是打分函数s对第i个训练样本的打分。因 为训练样本的标签肯定是正确的,我们希望s对它的打分越高越好,也就是 越大越好。 是所有可能的解析树的集合,而 则 是对某个可能的解析树 的打分。 是对错误的惩罚。也就是说,如果某个解析树 和标签 是一样的,那么 为0,如果网络的输 出错的越离谱,那么惩罚项 的值就越高。 表示所有树里面最高得分。在这里,惩罚项相当于Margin,也就是 我们虽然希望打分函数s对正确的树打分比对错误的树打分高,但也不要高过Margin的值。我们优化 ,使目标函数取最小值,即: 下面是惩罚函数 的定义: 上式中,N(y)是树y节点的集合;subTree(d)是以d为节点的子树。上式的含义是,如果以d为节点的子树没有出现在标签 中,那么函数值+1。最 终,惩罚函数的值,是树y中没有出现在树 中的子树的个数,再乘上一个系数k。其实也就是关于两棵树差异的一个度量。 是对一个样本最终的打分,它是对树y每个节点打分的总和。 J(θ) = max(0, (s(, y) + Δ(y, )) − s( , ))∑ i max y∈A()xi xi yi xi yi xi yi yi s(,)xi yi s(,)xi yi A()x1 s(, y)xi y Δ(y,)yi y yi Δ(y,)yi Δ(y,)yi max(s(, y) + Δ(y, ))xi yi θ θ = J(θ)argmin θ Δ Δ(y, ) = k 1{subTree(d) ∉ }yi ∑ d∈N(y) yi yi yi s(x, y) 具体细节,读者可以查阅『参考资料3』的论文。 小结 我们在系列文章中已经介绍的全连接神经网络、卷积神经网络、循环神经网络和递归神经网络,在训练时都使用了监督学习(Supervised Learning) 作为训练方法。在监督学习中,每个训练样本既包括输入特征 ,也包括标记 ,即样本 。然而,很多情况下,我们 无法获得形如 的样本,这时,我们就不能采用监督学习的方法。在接下来的几篇文章中,我们重点介绍另外一种学习方法:增强学习 (Reinforcement Learning) 。在了解增强学习的主要算法之后,我们还将介绍著名的围棋软件AlphaGo ,它是一个把监督学习和增强学习进行完 美结合的案例。 参考资料 1. CS224d: Deep Learning for Natural Language Processing 2. Learning Task-Dependent Distributed Representations by Back Propagation Through Structure 3. Parsing Natural Scenes and Natural Language with Recursive Neural Networks s(x, y) = ∑ n∈nodes(y) sn x y = { , }d(i) x(i) y(i) {,}x(i) y(i)
还剩104页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享pdf获得金币 ] 2 人已下载

下载pdf

pdf贡献者

lqkwww

贡献于2018-07-17

下载需要 10 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf