[Hinton] Neural Networks for Machine Learning - RNN
Lecture 07
Lecture 08
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
完全递归网络(Fully recurrent network) Hopfield网络(Hopfield network) Elman networks and Jordan networks 回声状态网络(Echo state network) 长短记忆网络(Long short term memery network) 双向网络(Bi-directional RNN) 持续型网络(Continuous-time RNN) 分层RNN(Hierarchical RNN) 复发性多层感知器(Recurrent multilayer perceptron) 二阶递归神经网络(Second Order Recurrent Neural Network) 波拉克的连续的级联网络(Pollack’s sequential cascaded networks)
一、序列建模
记忆列表模型只是在序列上用来分类的一种模型,我们能够通过其他方法来生成序列。
一种非常自然的生成序列的方法是建立一个有着自己内部动态的多隐状态模型。隐状态的进化是依据它内部动态而定的,而且同时也可以生成观测值(就是预测值),从而可以得到很多不同的模型:
1、信息可以在他的隐状态中存储很长一段时间,不像之前的那些无记忆模型,它没有简单的标准去判别回头看多远才能不影响后面的预测;
2、如果隐状态的动态是有噪音的,而且从隐状态中生成输出也是有噪音的,那么通过观察一个这样的生成模型的输入,是没法知道他的隐状态是什么样子;
3、我们所能做的最好的就是在基于所有可能的隐状态向量的空间上推测概率分布【深度贝叶斯?】,可以知道他在空间中的某些位置,而不在空间中的其他位置,但是也没法获得准确的信息。
所以在这样的生成模型中,如果想通过观察他的生成的观测值然后去试图推测它的隐状态是什么,那通常是很难的,但是这里有两种类型的模型,他们的隐状态是比较容易计算的,也就是有着一个相对简单的计算去允许我们从可能影响数据的隐状态向量上推测概率分布,当然如果的确这么干了,而且应用到了真实数据上,那么就假定这些真实数据都是由模型生成的。所以这就是我们在建模的时候所做的事情,我们假设数据是由模型生成的,然后在基于这些数据的基础上推测这些模型的内部状态。
卡尔曼滤波
上图就是其中的一个标准模型:线性动态模型。它广泛的应用于工程中。
这是一个有着实值隐状态的生成模型,这些隐状态是有着线性动态的,
如上图中红色箭头指示的,这些动态是有着高斯噪音的所以隐状态可以进化概率(evolves probabilistically)(就是隐状态的概念可以变化的意思吧);
如上图中驱动输入部分是直接影响着隐状态的,而隐状态又可以决定输出层并去预测系统的下一个输出(应用:追踪导弹),所以我们需要去推导出它的隐状态。
总结:给定系统输出的观测值,我们不能确定隐状态是什么,但是我们能通过估算一个高斯分布来假设可能的隐状态是什么。当然,这里的前提条件是我们的模型是在基于观测的真实数据上正确的模型。
隐形马尔科夫链
另一种不同的隐状态模型是使用离散分布而不是高斯分布,叫隐马尔可夫模型(HMM)。
在HMM上有个根本的局限性,当我们考虑在HMM生成数据的时候到底发生什么,就能理解局限性到底在哪。
在每个生成的时刻,它选择隐状态中的一个状态,所以如果有n个隐状态,那么存储在隐状态上的时间信息就差不多有log(n)位,这些就是它目前干的所有已知的事情。
现在让我们考虑一个HMM能够从前一半得到的utterance中传达多少信息到后一半。
假设已经生成了前一半的utterance。现在去生成后一半(它的前一半的记忆存储在包含的n个状态中,所以他的记忆只有log(n)位信息):为了对比前一半去生成后一半,我们必须要句法匹配(所以数量和时态需要一致);同样也需要语义匹配(不可能句子的后半部分的语义完全不同于前半部分),同样语调也是需要匹配,不然前面和后面的语调看上去完全不一样就显得很怪异了;还有其他东西需要匹配:说话人的口音,速率,声响,声道扬声器的特点等。所有的这些都必须让前半部分的句子和后半部分的句子搭上。所以如果想用HMM去生成一个句子,这些隐状态需要将前半部分的信息都传达到后半部分。
现在的问题就是所有的这些方面需要100位来表示信息,所以将前半部分的这100位信息传达到后半部分,这也意味着HMM需要2 ^100个状态(一个输出表示两个状态,开,关),这太大了。
【有具备信息论的思维】
所以,这里就考虑到了递归的方法了,它们有着更高效的方法来记忆信息。
它们的强大在于通过将分布式隐状态的两种属性很好的结合了起来,也就是说,一次可以激活好几个不同的单元,所以能够一次同时记忆好几个不同的事情;
它们不但不止拥有一个激活单元,而且还是非线性的,一个线性动态系统有着整个隐状态向量,所以在一个时刻可以得到不止一个值,但是这些值都包含着线性的行为,所以推理起来容易,在RNN中通过将动态变得更加复杂,就能允许用更复杂的方法来更新隐状态。
在有足够的神经元和时间下,一个RNN可以计算计算机允许计算范围内的任何事情。
所以线性动态系统和HMM都是随机模型。
因为这些动态和由潜在状态得到的观测值的结果都是包含着内在的噪音的。所以建模的时候就需要注意一些问题了:
在基于隐状态上的后验概率分布(不论是在线性动态系统还是HMM中)是目前为止看到的数据的一个决定性函数,也就是说这些系统的推导算法还是概率分布,而这些概率分布都只是一群数字,而且这些数字决定着目前为止数据的结果。
对RNN的思考
在RNN中,这些数字构成了RNN的隐状态,而且它们也非常像这些简单随机模型的概率分布。
所以问题就是什么样的行为会出现在RNN中:
它们可以是振荡的,这对于行为控制是明显的好事情,例如:当你在走路的时候想要知道你的步伐的周期性振荡;可以设置点吸引子,这可以用来检索记忆(后面会介绍到的hopfield网络是通过设置点吸引子来存储记忆),所以在是有着一些粗略 的想法去进行检索,
然后通过让系统稳定到一个状态点上,并将这些状态点对应于所知道的事情,通过设置到这些状态点上,就能检索一个记忆了;如果在合适的制度(regimes这里估计是字幕错误,不过个人觉得想成规则应该还好)下设置权重的话,它们还可以是混乱的,通常来说,混乱的行为不利于信息处理,因为在信息处理中,希望行为是可靠的。但是在某些情况下这是个好想法,如果在面对一个更聪明切觉得不可能战胜的对手的时候,随机可能是一个好主意,也就是混乱(这不就是看天命?);
另一个关于RNN的好处在于,很久以前,Hinton想让RNN更强大,通过使用它的隐状态的不同的子集来执行许多不同的小程序,而这些小程序的每一个都能抓取一块知识,而且所有的这些都能并行运行并以更复杂的方式交互。
可惜的是,RNN的计算能力使它很难被训练。许多年来,我们不能利用RNN的计算能力。但是它却有很多英雄式的影响,例如:Tony Robinson通过使用一个RNN来做语音识别器,他做了许多工作,在晶片机上进行并行计算,但是也只有最近,才有人通过RNN作出了比他好的结果。
二、用BP来训练RNN
![](https://images2015.cnblogs.com/blog/310015/201707/310015-20170715123657118-971989850.png)
通过时间的BP算法只是在将RNN视为一个层次化、有着共享权重的前馈网络上用BP训练的称呼,只是让我们记起这是在时间领域内使用BP罢了。
前馈传播在每个时间步上构建一层所有单元的激活层;然后回向传播计算在每个时间步上的误差导数,这就是他为什么称之为BP through time。
经过后向传播后,将每个权重上所有不同时刻的导数进行相加,然后通过同样的数值(和或者是所有导数的均值)来更新所有的权重。
【难理解,需具体例子】
三、一个训练RNN的简单例子
这部分介绍如何使用RNN去解决一个toy问题(就是玩具级别的问题)。这个问题是用来说明RNN可以干而一般的前向NN不能干的地方,也就是如何将两个二进制数进行相加,接下来可以看到RNN的隐状态是如何和有限状态自动机中的隐状态一样解决相同的问题的。
前馈网络训练的权重只能处理这个固定的输入,但却无法扩展到其他数字的相加。
这个例子充分说明了,我们生活在四维世界当中,必须考虑到每个维度。
尤其是预测,更是在时间维度上的预测,而非其他三维。
所以一个二进制版本的RNN需要两个输入和一个输出。在每个时间步上给定两个输入数字,而且在每个时间步上还需要一次输出。强调下这里的输出是两个时间步之前的列的值的输出(上图橘红色框起来部分,这里的time箭头是不是反了?),这里需要两个时间步的延时是因为需要一个时间步去基于输入的更新隐藏单元,另一个时间步去基于隐藏单元生成输出值。
四、为什么训练RNN这么难
四种方法来高效的学习RNN:
1、长短时记忆:通过改变NN的结构使得能够擅长记忆事物(这课下面详细讲解)。
2、使用更好的优化方法去处理非常小的梯度(下一课讲解),优化中的真正的问题在于检测即使有着更小曲率的很小的梯度。Hessian-free 优化是按照NN来的,很适合干这样的事情。
3、这个方法真的是一种躲避问题的方法,我们所要做的就是谨慎的初始化输入到隐藏的权值,然后谨慎的初始化隐藏到隐藏的权值,然后后向从输出到隐藏单元的权重,这里的想法就是谨慎的初始化去确保隐藏状态有着巨大的弱耦合震荡存储,所以如果输入序列,它将会混响(reverberation)很久,并且这些混响还能记忆输入序列中发生的事情,然后试图耦合这些混响到想要的输出,所以在Echo 状态网络中学到的唯一的事情就是隐藏单元到输出单元之间的连接,如果输出单元是线性的,那么就很容易训练,所以这样其实没学到递归,知识通过使用一个固定的随机递归位 罢了,知识谨慎的选择,然后只是学习隐藏到输出的连接而已。
4、最后一方法是使用动量,但是是将动量初始化称可以在第三种方法中使用的值,并使得工作更好。所以聪明的方法是找出如何对这些RNN网络初始化使得它们拥有很好的动态,但是如果在有助于任务完成的方向上轻微的修改动态是有助于网络更好的工作(也就是第四种方法添加动量)。
具体如何训练呢?
1. 长时、短时记忆 (LSTM)
这部分将会介绍一种训练RNN的方法叫做长短时记忆。可以认为一个NN的动态状态是一个短时记忆。
原理:将这个短时记忆给延长到长时记忆,这是通过创建特别的模块,通过允许信息封闭在里面,然后当需要的时候将信息放出。
在这过程中,门(形象的比喻)是关闭的,所以在这期间到达的信息都不会影响到记忆的状态。长时记忆有着非常成功的应用,例如手写识别,并赢得了一些比赛。
在1997年,Hochreiter和Schmidhuber发了一篇论文在神经计算期刊上,解决了如何让RNN长时间记忆事情的问题。成功的让RNN记忆长达几百个时间步。
他们通过设计一个记忆存储单元,在这个单元中使用逻辑、线性单元和乘法操作。
所以当逻辑写门打开的时候,信息就可以进入记忆存储单元。在这其中RNN剩下的部分决定了写门的状态,当剩下的部分想要存储信息时,就将写门打开(而且不论从剩下的部分的当前输入到记忆单元的是什么)存入单元。里面的信息只要保持门开着就一直不变。同样,网络剩下的部分决定了逻辑保持门的状态,如果它保持开着,那么信息就会留着。最后从记忆单元中读取信息到RNN的剩下的部分并且影响未来的状态,这时候是通过打开读门来读取的,这里是由网络剩下的部分控制一个逻辑单元实现的。
现在来看一个采用长短时记忆的RNN完成的任务。这个任务对于RNN是轻而易举的,就是读取草书书写(估计就是人的各种手写字吧)。
- 输入:由(x,y)代表笔尖坐标的序列加上笔是否在纸上的信息。
- 输出:识别字符的序列。
Graves和Schmidhuber在2009年显示有着长短时记忆的RNN特别的适合干这个任务。到目前为止,他们也是当前最好的能够完成这个任务的系统(这个课程出来的时间),并且Hinton认为加拿大post已经用他们来读取手写字体了。而在2009年这两个人没有使用笔的坐标作为输入,他们只是使用了小图像序列,这也就是说他们可以处理光学输入而不知道笔的时间(估计就是笔上纸到离开纸的时间吧),他们是通过收集写好的纸然后进行读取的。
所以这里介绍一个Alex Graves的系统的实例,这个系统是工作在笔坐标上的(就是通过笔在纸上划过的坐标,这具有实时性的意义,就是你写一个字,还没完成的时候,有可能这个网络就能识别出你要写的是什么字了)。在后续的视频(Hinton说的视频,就是最下面那张图)中,可以看到四条信息流,
1、顶层行显示他们所识别的字符,因为这个系统不会修改他的输出,所以在做这个艰难的决定(输出的结果到底是什么的问题)的时候需要延迟一小会儿来观察后续一小段的未来来帮助网络解决这个模棱两可的问题。
2、第二行显示记忆单元的子集中的状态,然后会注意到当识别一个字符的时候他们是如何重置的。
3、第三行显示实际的写的东西,而网络所全部能看见的就是笔尖的 x 和 y 的坐标,然后加上一些这个笔是否往上还是向下的信息。
4、最后第四行显示的是更复杂的。他显示梯度通过所有的路径后向传播到 x y 的位置。所以你所能看到的就是最有可能的字符,如果从那个字符上后向传播,然后问道怎样可以使得这个最有可能激活的字符变得更加的激活(就是更加的确定),那么就需要去观察到底输入的哪一位才是影响真实字符的概率的。所以你所做的决定都是基于以往发生的信息上的。
8:44-9:15,下面这张图就是一个从左到右的过程,下面第三行不断的写,第四行显示笔尖的位置,然后第二行显示网络的训练,然后第一行得到结果。(哇,这个网络好厉害。)
2. “Hessian-Free”优化
(1) 简单介绍
在这部分中,会简短的介绍Hessian-free优化,这可以用在训练RNN中,这也是一个非常复杂的优化,Hinton并不期望从这部分中就能知道所有的细节(要记得这是给本科生上课的,所以不同级别的 不同对待),只希望能有个大致的感觉,他是如何工作的,在第二部分中才会举个例子,它是如何完成一个很有趣的问题的。
当我们对NN训练权重的时候,会让误差尽可能的在误差表面上达到最低。所以问题来了,
- 一个就是如果我们选择了一个给定的方向,那么在正确的方向上(真正的方向)到底能下降多少,
- 在误差重新上涨之前那个方向上误差到底下降了多少(也就是求局部最小值或者全局最小值),这里我们假设曲率是常数
这里假设它就是个二次误差表面(类似一个碗的纵切)。我们可以合理的假设当我们沿着梯度向下移动的时候,梯度的大小是减少的,这就是说我们认为误差表面其实是个上凸形状(就是碗型)。
我们通过某个具体的方向上的误差中得到的减少的最大值是由梯度的曲率比决定的。所以其实我们希望在移动的方向上有个好的比率,就算是梯度已经相当小了,我们还是希望曲率变得更小。
上图中就是个例子(图中下面的小图):垂直轴是表示误差,水平轴表示移动方向上的权值,蓝色箭头对应关于从红点开始的减少量。(上图中的上面大图):这里演示的是比上面那个更好的梯度曲率比,所以这次我们可以得到更大的误差下降值,所以更贴近最小值,问题在于如何找到第二个这种下降方向,即找到的方向上梯度是很小的,而且曲率甚至要更小。
牛顿梯度下降方法
这里以牛顿方法来开始吧,牛顿方法是为了处理在最速下降法上的基本问题的,也就是当梯度不在想要下降的方向上的问题。
如果这个误差表面有圆形横截面,那么而且是二次的,那么梯度就是一个好的方向的选择,它将会引导直至最小值,所以牛顿方法的想法是通过一个线性变换使得椭圆变成圆,如果将这个想法应用于梯度向量的转换,那么就好像是在一个圆形误差表面上下山(形象的称呼)一样。
为了实现这一的效果,需要将dE关于dW的梯度乘以曲率矩阵的逆矩阵,所以H是曲率矩阵,有时候也被称为Hessian,这就是我们拥有的权重的函数,而且我们需要对它取逆和与梯度相乘,然后我们就需要在那个方向上前进一段距离了。
如果它真的是一个二次表面,而且我们选择的epsilon是正确的,那么剩下的事情就好办了,我们就可以在一个单步上达到表面的最小值,当然这里的单步是包含了很多复杂的东西的,也就是Hessian矩阵的逆矩阵。
这里伴随的问题是即使我们只有1百万个权重,这里的曲率矩阵,Hessian将会有1兆的项,这就是完全的不可能去求逆了。
所以曲率矩阵像上图这样,对于每个权重,Wi或者Wj,它们会告诉你在一个方向上和另一个方向上梯度的变化,换句话说,正如现在改变权重 i ,那么误差关于权重 j 的梯度的变化是多少,也就是非对角线上的项所表示的;而在对角线上的项说明的是误差梯度在这个权重方向上的这个权重的改变。所以曲率矩阵中非对角线项就是对应于误差表面中的曲折(twists),这里的曲折意思是当你在一个方向上尝试的时候,在另个方向上梯度会改变。如果我们有个很好的圆碗,所有的这些非对角线项就是0了,正如我们在一个方向上使用梯度,在其他方向上的梯度就不会受改变。
所以在最速下降法中的问题就是当你有个椭圆误差表面,如上述一样,在一个方向上的梯度改变会影响到其他方向上的梯度的改变,所以如果更新所有权重中的一个,那么在同一时刻就是也同时更新所有其他的权重,所有其他的权重上的更新又会导致第一个权重上梯度的改变,也就是说当更新的时候,有可能会让事情变得更糟。梯度也许会因为其他权重上的改变而事实上会是个反正弦(这句话不知道什么意思)。所以当我们得到越来越多的权重的时候,我们就需要在每个权重上都要越来越注意,因为在所有权重上的同时的改变有可能会改变我们梯度的范围。曲率矩阵决定着这些交互的尺寸(就是这个Hessian矩阵决定着所有权重的更新影响)。
所以我们不得不需要处理曲率,我们不可能忽略它。而且我们也希望处理的时候不需要进行对一个巨大的矩阵求逆,因为这个矩阵在一个大NN中将会有超多的项。我们所能做的就是通过观察曲率矩阵的主对角线,然后以此得到步长尺寸,这个的确有所帮助,他会让我们在不同的权重上拥有不同的步长尺寸,但是对角线也只是交互中的微小的一部分(这里是对于整个矩阵来说),所以我们这么干的时候其实是忽略了曲率矩阵的大部分信息的,事实上,我们差不多忽略了所有的信息;另一个我们所能做的就是将曲率矩阵用能抓住曲率矩阵主要方面的更低的矩阵秩来逼近(有点拗口,不过要是懂下面的几个方法的原理,相信就应该知道说的是什么意思),方法有:Hessian-Free, LBFGS和许多其他方法,去试图逼近最小化误差的二阶值。
Hessian-Free方法
在Hessian-Free方法中,我们通过对曲率矩阵进行逼近,然后假设逼近是正确的,所以我们假设知道曲率是多少,而且误差表面也的确是二次的。然后现在,引入一个高效的方法来最小化误差,叫做共轭梯度,一旦我们完成了,一旦我们逼近了曲率上的最小值,我们就随后就对曲率矩阵进行另一次的逼近,然后使用共轭梯度来再次进行最小化。在RNN中加上一个关于其他隐藏激活值改变太多的惩罚项,这可以阻止我们过早的改变权值而导致在序列中后期的巨大影响。我们不想让(RNN中的)影响很大,如果我们观察隐藏激活值中的变化,那么就能通过惩罚这些变化来阻止事情的发生,如果我们在这些变化上使用的是二次惩罚项,那么就能将它们与Hessian-free方法中剩下的部分结合起来。
共轭梯度下降方法
这里最后需要解释的就是共轭梯度,这里将会简短的说下。共轭梯度是个非常聪明的方法:不是和牛顿方法中直接试图得到最小值,而是每次在一个方向上达到最小,所以它是以某个方向上的最速下降法然后在那个方向上达到最小值。
这包含了对梯度的重新评估,和重新评估误差去找到这个方向上的最小值。一旦这么做了,然后就可以找另一个方向,然后在这个方向上找最小值。这个方法聪明的地方在于它以这样的方法选择的第二个方向并不会影响在第一个方向上得到的最小值的结果,这也被称之为共轭方向,共轭就是说在选择新的方向上,不会改变之前方向上的梯度。这是个很有趣的想法,就像之前误差表面上曲折(twists)的想法一样,曲折就是说当你在一个方向上的时候,就会改变另一个方向上的梯度;而共轭方向是一旦到了一个方向上,那就不会有曲折发生,即一旦到了一个方向上,在第一个方向上的梯度就不会发生。
这里是个椭圆图形,红线是椭圆的主轴,我们通过采用沿着所有方向上执行一步最速下降法来作为开始(上图中从椭圆外面进入的黑色箭头),如果想想的话,就能发现其实最小值不是沿着红线的,在红线的正确角度上梯度是为0的,因为这是山谷的底部了,但是我们跟进的方向事实上不是这个点的正确角度。我们可以多做一些处理来使得在红线上朝着正确角度前进一小步,然后沿着红线前进一小步。因为红线是朝着椭圆的中部坡度下降的,这也指导着我们一些处理上的想法,所以当在第一个方向上达到了最小值,我们将会笔直的横跨椭圆的底部(第一个大黑箭头),然后当我们达到了那个点,也就是那个方向上的最小值,对于沿着上图中绿色线来说,黑色箭头(第一个)的方向上的梯度都是0,所以我们可以沿着绿色线去任何地方而且不会破坏之前在黑色箭头上得到最小值的事实。(个人:这大段意思就是开始的大黑色箭头就是在第一个方向上得到的最小值,而且通过计算在绿色线上运动,第一个方向上的梯度都不会变化,那么就可以沿着这条线来计算第二个方向上的梯度并计算最小值了)
如果我们能在高维度误差表面上徐哦多方向上这么干,我们最终就能得到在许多不同方向上的最小值,而且如果我们在我们空间上所有维度方向上都达到了局部最小值,那么也就会最后处在全局最小值的位置了。所以我们先执行第一步最速下降,然后找出绿色线方向,然后在这个方向上搜索我们可以走多远以至于沿着这条线都能达到这个方向的误差最小化;然后采取第二步,如图中第二个黑色箭头,在这个2D空间中,我们可以得到最小值(黑色箭头顶端,也就是和绿线和红线交叉的地方),是因为我们在第一步方向上得到的最小值而且现在第二个方向上也都达到了最小值,那么两个方向上的最小值,就是全局最小了。
共轭梯度所能达到的它达到了在N-D二次表面上的全局最小,而这只需要N步,这非常的高效。它成功的在N个不同方向上使得梯度为0。它们虽然不是正交方向,但是他们都是各自独立的,所以这是很有效率的达到全局最小的方法。更重要的是,在二次表面上跨出许多步后(就是只运行了小于N步的时候)将会让误差非常的接近最小值,而这就是为什么用它的理由,我们不需要完整的运行N步,因为当N很大的时候,计算代价比较大,有可能和将整个矩阵求逆一样大,我们只需要执行小于N步,那么就能接近最小值。
可以直接将共轭梯度方法应用在非二次误差表面上,比如来自多层非线性NN的误差表面,而且通常效果还很不错。它本质上也是批量的方法,但是也能将它应用在大mini批量上,当这么做了之后,在同一个大mini批量上执行了许多步共轭梯度,然后在下一个大mini批量上操作,这也叫做非线性共轭梯度。
Hessian-Free优化使用的是共轭梯度方法,在真正的二次表面上达到最小值,这也是共轭梯度最擅长的。
在二次表面上的工作效果还是比非线性表面上的效果要好。这里HF方法是通过对真正的表面进行二次逼近得到的真正的二次表面。
所以在得到逼近之后,使用共轭梯度在第一个逼近上接近最小值,然后在曲率上进行新的逼近,然后再次采用同样的操作。
(2) 使用乘法连接来对字符串进行建模
这部分将介绍使用Hessian-Free优化去对字符串数据进行建模,这里的字符串都来自于维基。
所以这里的想法就是从维基上阅读大量的语句,然后让模型试着去预测下一个单词。
在介绍模型学习之前,先说明下为什么我们需要乘法连接和我们怎样在一个RNN中高效的执行这些乘法连接
这里先解释下为什么使用字符串而不是单词串(个人觉得翻译成单词流更好,想法来自c++语法)来进行模型的训练,而单词串是通常用来在对语言进行建模的时候使用的。
web网页都是由字符串组成的;任何足够强大的学习方法都能够通过阅读这些网页理解知道到底发生了什么,这些方法都需要最基本的知道哪些字符组成单词,正如我们肉眼看到的,这些都是正确的道理。所以在这里我们对接下来做的事情很有信心,我们希望某些模型能够阅读维基然后理解这个世界。
如果我们对维基文本预处理成单词,那么这将会导致一个巨大的争论,那么就充满了问题了。比如第一个问题就是语素(相对于声音也有音素),也就是语言学家认为的意思的最小单元。所以如果想要明智的去处理的话,我们就需要将每一个单词拆成语素(如上图中pre,es),但是问题就是还不清楚语素到底是什么。这些(上图中的例子)都有点像语素,但是语言学家却不会这么认为这些是语素。
所以在英语中,如果你以sn开头的任何单词,那么有可能有会存在嘴唇或鼻子所导致的意思的高度变化,特别是上嘴唇和鼻子,所以例如单词snarl、sneeze、snot、snog、snort等。有着太多这样的单词是恰好一致的(个人:就是这里分出来的语素没法用来表达单词的意思,一个sn看起来像语素,但是同一个语素却能存在多个单词中,所以才觉得按照单词训练不太好,想按照字符训练)。许多人也许会说是的,那么现在该干嘛呢?这完全不影响上嘴唇和鼻子啊,但是问问自己吧,为什么雪(snow,觉得是字幕错误,这里就当是某个单词吧)是针对可卡因的一个好的单词,然后这些单词都来自于好几个部分。所以正常的来说,我们想要将New York视为一个词汇,但是如果我们想谈论纽约的大教堂屋顶,那么我们就需要将new 和york视为两个独立的单词(就是有些词汇在某些情况是可以视为词汇,但是某些情况却需要分开看待)。
还有像Finnish(芬兰语)这样的语言,Finnish(芬兰语)是一个胶合的语言,所以它是将许多语素放在一起来形成一个大单词。所以这里就有个例子是将5个英语单词组合成一个单词的芬兰语(上图红色最长的单词),这时候就没法知道单词的意思了,但是不管是理解的缺失(就是水平不够),这就是为了说明语素的划分其实很难。
上图就是一个公认的RNN,可以用来对字符进行建模。本例子中,它有着一个隐状态,然后Hinton使用了1500个隐藏单元,这里的隐状态动态就是在时刻T 隐状态提供的输入去决定在时刻T+1时的隐状态,同样字符也提供着输入,所以我们将当前字符和之前隐状态的影响结合起来去得到一个新的隐状态,然后当我们获得了一个新的隐状态,那么就试图去预测下一个字符。所以我们这里有着在86个字符上(86这个数字目测应该就是ascii中可打印出的字符吧)的一个单一的softmax,然后通过得到的隐状态去指定高度概率的下一个正确的字符(softmax就是选定输出层上概率最大的那个作为结果的),然后将低概率的作为其他部分。然后从softmax开始使用BP来训练整个系统的得到正确字符的低概率情况(就是使误差最小化,让整个系统能够最大概率的识别正确的字符,当然是将正确字符的低概率事件作为误差考虑了)。
(看上图结构说明)我们用BP方法先穿过从隐藏到输出之间的连接(就是输出层与倒数第二层之间的权重)往回穿过隐藏到字符之间的连接,然后往回穿过隐藏到隐藏之间的连接等等,从所有的路径返回到字符串的开始部分(也就是输入层)。
预测86个字符比100,000个单词简单的多,所以在输出层使用softmax也是很容易的,这里没有前面课程说的那种大softmax的问题。
现在,来解释为什么不使用那种RNN吧,而是使用另一种不同的网络,而且工作的效率更好。在我们的例子中,你可能将所有的字符安排成一个有着86个分支率的树。这里展示的,就是那个大树的一个微型子树,事实上,这个子树将会出现很多次,但是每次代表着不同的事物(这里由fix前面的 三个点来表示)
,所以这里表示我们已经有了整个字符树,然后我们就有了f,然后是i ,然后是 x。现在如果我们得到了一个 i ,我们就顺着根到叶子节点部分往左下方走;如果我们得到了一个 e ,那么我们就往右下方走。所以每次我们得到了一个字符,我们往下移动一层去得到一个新的节点。在长度为n的所有字符串中有着指数级别的节点,所以我们会得到一个超大型树以至于我们没法将它们进行存储起来,如果我们将它存储起来了,我们所要做的就是在每个箭头上加上概率了,这就是在给定节点的上下文的情况下得到字母的概率了。
在RNN中,我们试图处理的事实是这整个树是很巨大的,是通过一个隐状态向量来表示每个节点的,所以现在下一个字符所要做的就是:考虑用来表示字符串而且后面跟着fix的一个隐状态向量,然后在这个隐状态向量上进行处理去生成一个合适的新隐状态向量。如果下一个字符是 i 的话,那么就需要将这个隐状态向量转化成一个新的隐状态向量。
关于处理这个(使用RNN的隐状态)字符树中的这些节点的一个很好的方法就是通过共享一些结构。例如,在我们到达那个节点的时候,也就是f,i,x ,我们就能决定他可能是个动词,如果他是一个动词,那么下一个就多半是 i 了,因为考虑可以用 i,n,g来后缀。而这时候就可以将那些没有 f i x 的但是具有i,n,g 的子树进行共享,而且可以在所有动词之间共享。
注意到,是我们所处的当前状态的连接和这个字符决定了我们所要走的路径,我们不想 i 给我们一个状态说下一个期望的字符是 n 而它却不是一个动词,所以我们不需要说i 可能会导致下一个是 n 了。我们想要说的就是,如果已经认为这是一个动词了,那么当看见一个 i ,那么就期望下一个是 n 。这是实际生活中的联系:我们认为他是一个动词,然后我们看见个 i ,这使得我们进入这个状态被标记为 f,i,x,i,那么就期望遇见下一个是 n。(这里说的比较罗嗦,其实就是这个树结构可以有组织的搜索,而不是已经匹配了半个单词的时候,下个字符还需要26个字母的去瞎匹配。就可以通过实际的单词表等来很好的预测下个字符了)
所以我们通过乘法连接去试图去实现上述的结果。不使用字符输入到RNN中去得到额外的输入,然后输入给隐藏单元,Hinton这里使用这些字符去在一个完整的隐藏到隐藏的权重矩阵中进行交换,这里字符决定了转换矩阵。现在,如果使用一个简单的方法,那么就是让这86个字符中的每个一都能找到一个1500×1500的矩阵,而这就是有着很多的参数需要处理了,如果我们有了这么多的参数,那么很有可能会过拟合,除非我们在一个超巨大数量的文本上运行,而这也没那么多时间。
所以解决的方法就是如何达到使用乘法的相互作用的结果,就是在字符决定隐藏到隐藏权重的矩阵上使用更少的参数。通过考虑一个事实:字符在很多地方是通用的。例如,所有的数字在使得隐状态进化的方式中都是相互之间相似的,所以我们希望对于86个字符中的每一个都得到一个不同的转换矩阵,但是这86个字符具体权重矩阵能够共享参数,这是合理的,因为我们知道字符8和9有着非常相似的转换矩阵。
这里就是我们将要做的事情,这里先引入一个被称为因子的东西,他们被上图中有着F的一个三角形表示。因子的意思是组a 和组 b相互之间相乘去提供输入给组 c。所以每个因子所作的就是首先计算它的两个输入组的权重化的和,所以这里先将组a的向量状态称之为 a,然后通过一个与因子连接的权重与之相乘,换句话说,就是基于向量a和权重向量u的标量积,就可以得到上图中三角形左边顶点的一个值,相似的,我们考虑组b就能得到因子的底部的顶点的值。现在我们将这两个数字相乘,然后得到一个数字或者标量。然后我们使用这个标量与输出到组c的权值v相乘去得到组c。就是如上图中的公式。(中间省略一段废话)然后,我们就得到了整群因子了。
这里是对因子的另一种理解方式,来说明具体的过程。每个因子实际上定义了一种非常简单的转换矩阵,这是一个有着的秩为1的转换矩阵,所以之前得到的式子中(上图中最上面那个式子)是将因子视为两个标量的乘积乘以即将输出的向量 v (也就是权值)。我们可以重新排列下这个式子(上图中第二个式子),所以我们得到了一个标量乘积,然后重新安排最后一位,所以现在,我们考虑权重向量 u 和 v,然后得到一个外积,就得到了一个矩阵。这里的标量乘积是由b乘以w得到的,只是这个外积矩阵的系数而已,就是一个标量系数,然后乘以得到的外积矩阵得到一个标量矩阵,然后用当前隐状态 a 乘以这个矩阵去决定因子 f 的输入去得到下一个隐状态。如果我们将所有的因子相加(上图第三个式子),那么到组 c 的所有输入就是一个标量的所有因子的和乘以一个秩为 1 的矩阵,这个和得到的是一个大矩阵,这就是所谓的转换矩阵,然后将它乘以当前的 隐状态 a 去得到一个新的隐状态,所以我们能发现我们综合了这个转换矩阵,实际上,这个秩为1 的矩阵是由每个因子提供的,组 b 中当前字符所做的就是决定这些秩为 1 的矩阵的每个权值,b 乘以 w 就决定了每个矩阵的标量权值,标量系数。我们这里要做的就是将这个大字符指定正确的矩阵进行组合。
这里就是整个系统的示意图了。这里有一群因子,实际上会有大约1500个因子,而且字符输入在这里面是不相同的,只有他们中的一个是激活的(一次只输入一个字符)所以每一次只有一个相关的权值,这个权值来自于当前的字符 k (上图中举的例子),也被称为Wkf,是用在秩为1 的矩阵上的增益,这个矩阵是 由 u 和 v 的外积得到的。所以这个字符决定了一个增益 ,Wkf, 然后用u和v 得到的矩阵乘以这个增益,接着对所有不同的因子上加上所有的标量矩阵并得到一个转换矩阵。
(3) 使用HF去学习并预测下一个字符
这部分将介绍在当Hessian-Free优化被用在包含乘法连接的RNN上和这个网络在维基上训练并预测下一个字符时到底发生了什么。这个网络是在百万级别的字符上训练的,而且结果显示它工作相当的出色。它学习了许多英语然后变得非常擅长以很有趣的方式来完成句子(就是补完句子)。
Ilya Sutskever使用了5百万个字符串,每个字符串有100个字符,这些都是来自于英语版的维基。对于每个字符串来说,他就开始预测第11个字符后面的字符
,所以这个RNN是以一个默认的状态开始的,它读取前11个字符,然后每个时间步改变它的隐状态,然后它就开始预测了,然后在基于预测上的误差来使用BP去进行训练。
他使用了Hessian-free优化,然后在一个非常快的GPU板上训练了一个月并得到了一个很好的模型。
他的针对字符预测的最好的RNN有可能是当前用来预测字符的最好的单一模型。你可以通过结合不同的模型来比这个模型做的更好,比如决定使用哪个不同的NN,但是如果是单一的模型的话,他的模型也是差不多最好的了。
它与其他模型相比以非常不同的方式工作,所以Ilya的模型能够平衡引号和括号很长的距离,而任何依赖于匹配指定的之前上下文的模型却不能做到。例如,如果有一个括号,然后差不多想最多在35个字符后发现右括号,为了合理的完成这个任务,一个依赖于匹配以前上下文的模型就不得不匹配所有的介于括号中的35个字符,而且它也不像已经存储了整个字符串。
一旦这个模型学习完成,那么就可以看到通过从模型中生成的字符串来观察它到底知道了什么,当然也不需要过分解读它所说的。生成字符串的方式是以隐状态的默认状态开始的;然后给它一个“燃烧中”的字符串流,所以我们是通过给他馈送字符,然后让他在每个字符后进行更新它的隐状态;然后我们让它停止预测,观察它预测下一个字符的概率分布;然后随机从这个分布中挑选出一个字符,所以如果他预测Q的概率是1/1k,那么我们就每1k次挑选一个Q,然后我们告诉这个网络我们挑选的字符是真实发生的字符,然后叫模型预测下一个字符,换句话说,我们告诉它它猜测的什么都是对的;然后让它接着预测直到我们得到我们所要的的时候;然后观察它输出的字符来观察他到底知道什么。
所以这里就有个由Ilya的网络在被输送一些“燃烧中”的字符串后预测的字符串。这“燃烧中”的字符串是从一个更大段的文本中选择出来的,但是它也是一个连续的短文,结果显示这个模型工作的很好。你有可能发现他有一些奇怪的语义联想,例如“opus paul at rome”没有人会这么说,但是我们可以理解opus和paul和rome是高度相互关联的。你也可能注意到它不是真正的拥有很长范围的主题结构,它在每个句号之后主题后就有了很大的改变。一个令人惊讶的事情是它几乎不会生成奇怪的单词(非单词,就是它生成的都是我们可看过的单词),意思就是即使他预测字符的概率,一旦你拥有了足够的字符,那么让它作为一个英语单词来完成的唯一方法就是它会近乎完美的预测下一个字符,如果这不会发生的话,它就会生成一个非单词。即使当它生成了一个非单词,如上图中红色标注的,它们都是一个很好的非单词。我们不会完全的肯定或者当看见它第一眼的时候,都不认为“ephemerable”不是个英语单词;
你也许会发现它生成了一个右括号而没有生成一个左括号,所以它没有总是很好的平衡括号,它这样的行为也是挺频繁的,因为你有可能在文章的最后发现一个左括号然后在很久之后才有一个右括号。这在它的部分一致的行为(这里是解释为什么上图只有有括号,有可能在之前就有了左括号),它真的生成了这个右括号,是因为他在之前就有了一个左括号;
如果观察这个文本,那么就能发现,里面还是有很多很好的局部语法的,所以三、四个单词组成的单词串看起来也很有合理性;同样它有着许多的语义知识。
所以我们能做的一件事就是可以通过给它一些精心设计的字符串来测试这个模型去观察它所知道的。所以Hinton试图给它一些非单词,“Thrunge”这不是一个英语单词,但是大多数说英语的在但他们看到这个这个单词的时候,都因为它的形式而猜测这是一个动词(就是拿一个大多数人都会分错的单词给这个模型)。所以给它一个公开的上下文环境然后观察下一个预测的将会是什么。
所以如果给它一个"Sheila thrunge"然后让它预测下一个字符,最可能的字符就是 s ,这表明它认为(只是从维基上去读起来)sheila是一个单数;如果给它“people thrunge”那么下一个最有可能的字符就是一个空格,而不是一个 s ,这表明它知道people是个复数;然后试图给它一个名字列表,所以这里使用了名字首字母大写,并在之间用了一个逗号,然后给“thrunge”的首字母大写让这个单词看起来也像个名字,去观察这个模型会用它们怎么做,然后事实上它真的把它当成个名字了,而且看起来还不是那么糟糕,这也表示它知道很多语言中的一大堆名字;同样你也可以给他“the meaning of life is”然后看接下来是什么,如果结果是42,那么事情就不会那么有趣了,因为Hinton相当确信这是存在于维基的某个地方的语句,它随机生成事物,但是在前10次的实验中,它生成的都是“the meaning of ief is literally recognition”,这在句法上和语义上都是正确的;然后我们试了很多次,然后在上面的测试的10次之后它又得出了一个很有意思的语句,它生成的完整的“the meaning of life is”的语句,从在维基上读取的结果看来它真的开始知道“the meaning of life”的真正意思了,尽管这可能看起来是奇异的过度解释了。就是上图最长的那句。
所以这个模型在读完了这些维基上得到的字符之后知道的就是:当然它知道单词,它几乎总是生成英语单词,它还知道在字符串的开头通常都是大写,它能生成数字和日期和类似的东西,不过它不是经常生成非单词的,它几乎很少会犯错,而且当就算生成了非单词,那也是具有可以理解的非单词。它同样知道很多名字,例如“Frangelini Del Key”,它知道日期和数字和上下文中发生的东西;
它也擅长平衡引号和括号,事实上它能对括号计数。如果你给它的不是一个左括号,它也不怎么生成一个右括号,如果你给它一个左括号,它差不多会在接下来的20或者更多的字符后面生成一个右括号。如果你给它两个左括号,它会比较快速的生成一个右括号,但是如果给它三个的话不是意味着会更快;
它也清楚的知道一些有关语法的知识,因为它可以生成一些具有意义的简短的英语单词串,但是也很难一针见血的说形成这些知识的是什么,它不像三元组模型(之前课程说的)那样是学习很短的单词串,或者说他们有个包含简短单词串的表。它实际上是综合了单词串的,而且它通过有意义的语法来进行综合的。也很难说是什么形成了语法知识,它也不是像一个语言学一样有着一大堆的规则,它看起来更像是当一个人说一门语言的时候它脑袋中的语法一样(传说中的语感);
它也知道许多的弱语义联想,所以例如:它甚至只生成一次“Wittgenstein”这个单词,然后就很快的生成单词“Plato”,所以它知道Plato和Wittgenstein都是有联系的,这是一个很好的假设。它清楚的知道“cabbage”是与“vegetable”之间有着联系。它不知道这些事物联系的准确方式,同样人也是这样,如果你让他们快速的反应:问你一个问题,然后想要你大声的喊出答案。即你坐在这里看这个课程,实验马上就要开始了,最好如果你知道了这个答案,那么就需要快速大声的喊出来,你就能很快的得到奖励,对你说的什么内容不关心,关心的是你的快速反应。那么问题来了:奶牛喝什么?大多数人会立马喊出来牛奶,但是大多数奶牛在大多数时间上都不喝牛奶。我们说牛奶是因为它有着喝和奶牛上的关系才直接脱口而出的,但是在逻辑上这不符合
最近,Thomas Mikolov和他的同僚已经训练了一个相当大的RNN去预测在大量的数据集中的下一个单词,他们使用和前馈NN一样的技术,即首先将单词转换成一个实值特征向量,然后使用这些特征作为网络剩余部分的输入,他们做的比前馈NN好很多;他们同样做的也比最好的其他模型要好;当你将它们与其他最好的模型平均后相比,他们仍然计胜一凑,所以这也是当前最好的语言模型了;
RNN的一个有趣的特性是它与其他达到通级别效果的模型相比需要更少的训练数据;
更重要的是,正如这些数据集变得越来越大,RNN提升的比其他方法要快,所以其他方法例如三元组,在遇到更大的数据集的时候要想要得到更好的结果,但是却是更慢的处理速度,你还需要将数据集的尺寸翻倍才能得到一个小的提升结果。而RNN,他们能使用这些数据做的更好,也就是说在随着数据变得越来越大的情况下,打败他们将会变得更加困难。Hinton认为这与使用大的,深的NN来做对象识别也是同样的道理,但是一旦NN领先了,它们就能在更快的计算机和更大的数据集上做的更好,也会让其他的方法更加的望尘莫及。
3. Echo状态网络(echo state network)
在这部分将会介绍echo状态网络。通过使用一个聪明的技巧去学习RNN变得更容易。他们在RNN中线通过一种方法进行初始化连接,即这种方法中有一个大的耦合振荡器的保留。所以如果你提供输入给它,它就会将输入转换成这些振荡器的阻焊台,然后你就能从这些振荡器的状态中预测你想要的输出。唯一你所要学习的就是怎么耦合这些输出到振荡器。这个方法是完全避免了关于学习隐藏到隐藏连接或者输入到隐藏的连接的问题。然而,为了让这些网络在复杂的任务中表现良好,你就需要一个非常大的隐状态,正如这部分将要介绍的,没有原因不使用(就是应该用)给echo状态网络精心设计的初始化,然后使用带有动量的BP through time去训练这个网络使它在当前执行的任务上完成的更好。
一个有趣和最近的想法去训练RNN是完全不要训练隐藏到隐藏的连接,而是随机固定他们,并期望你能通过对输出的影响进行训练来学习序列。这与老想法的感知机强烈的相似。所以一个简单的想法去训练一个前馈NN的方法是去将网络前面结构上特征检测层的层随机赋值固定,就是将这些权值以有意义的尺寸(个人:这里说的就是权重的大小的范围)随机赋值,然后所有你要学习的就是最后一层,也就是从最后一层隐藏单元上的激活值到输出层上去学习一个线性模型。当然学习一个线性模型是可以更快的,这依赖于一个想法:一个输入向量的大的随机扩展可以通常使得线性模型能够容易的拟合数据,当不能很好的拟合数据的时候,那么就观察下原始的输入。
虽然这里是个小型nn,这些红色权值都是随机固定的,它们会将输入向量进行扩展,然后使用这些扩展的表征,然后试图去拟合出一个线性模型。这和SVM很大的相似。所以这些相同的想法,在许多年之后,又重新循环到RNN上被RNN所用,想法就是将输入到隐藏的连接和隐藏到隐藏的连接给精心选择的值固定了,只是学习隐藏的最后一层到输出的连接,然后当你使用的是线性输出单元的时候,学习是相当简单的,而且可以做的超级快。这个方法只在你精心安排随机连接的时候才会工作,所以RNN不会没有激活值的消失或者爆炸(之前课上讲的两个极端)。
所以在echo状态网络中设置随机连接的方法就是设置隐藏到隐藏的权值使得激活向量的长度能够保持在每个迭代之后还是相同的。如果使用线性系统和矩阵的话,就设置他们的谱半径为 1 .,即隐藏到隐藏权值矩阵的最大特征值为 1 ,或者当他是线性系统的时候它就是 1 。如果你想在非线性系统中获得同样的特性,如果你设置这些权值都在正确的大小,那么一个输入就能在递归状态的周围回显很长一段时间(这里就是echo的来源)。
同样的使用稀疏连接也很重要,所以不使用许多中等尺寸的权重,而是有一些相当大的权重,而且在隐藏到隐藏连接上靠近所有这些权重的权重都是为0,这也使得有了很多松散的耦合振荡,所以信息就能在网络的一部分上挂起,而不会太快的传递给网络的其他部分。
选择输入到隐藏连接的尺度同样也是很重要的,这些连接需要去驱动这些松散的耦合振荡器,但是他们有不能抹去这些信息,也就是这些振荡器需要包括最近的历史信息。
幸运的是在echo状态网络中这些学习是可以很快的,所以我们可以提供实验去寻找合适的重要连接的尺寸,你可能认为那就是个小的学习循环而已,只是为了学习这些连接的尺寸,而它是通过这些实验来进行反馈的排序。它同样有助于学习在隐藏到隐藏连接上的稀疏,同样的因为学习可以很快,所以可以提供实验来找到好的解。这很重要是因为通常来说做实验去使得系统很好的工作是很有必要的。
这里介绍的是一个简单的例子,从一个echo状态网络的web上得到的。它有一个输入序列,这个序列的实值是随着时间变化的,并指定了一个echo状态网络输出的sin波的频率,所以你会希望这个模型能够生成sin波,这里的输入是指定这个频率的。
目标输出序列是同样也是用输出来指定同样的频率的波。
而且可以通过设置一个线性模型来简单的进行学习,这个现象模型考虑隐藏单元的状态然后用这些来预测正确的标量输出值。
所以这里就是一张从echo状态网络的学术百科上下的图,为了完成这个程序,输入信号是sin波的期望频率,在学习之后得到输出信号(或者说是监督(teacher)信号),当学习的时候,输出信号是一个被输入指定频率的sin波。在中部的填充是一个大的动态存储器,所以从输入信号得到的输入可以驱动这些松散的耦合振荡器,并引发复杂的动态持续很长一段时间。这些输出权值可以将这些复杂的动态映射到你想要的输出的具体的动态。所有的其他图片都是为了说明事实上包含在动态存储器中的独立单元的动态。所需要注意的一件事是从输出回向到存储器的连接,这部分不是总是需要的,但是他们有助于告诉存储器到目前为止到底生成了什么。
这里就是一个在系统学习完之后实际生成的例子,你可以发现在开始部分,他生成一个sin波的相,在最后,他生成一个正确频率的sin波,但是这个相却是错的,这是因为我们没有告诉它里面应该是什么样的相的sin波,所以他是满足生成一个合适频率要求的。
这里是echo状态网络许多好的方面。它们可以被很快的训练是因为它们只是去拟合一个线性模型;他们同样证明聪明的隐藏到隐藏权值初始化是很重要的;他们可以令人印象深刻的建立基于一维时间序列模型,这是它们优异的地方,它们可以通过观察一段时间的时间序列,然后很好的预测未来很长一段时间上的结果,他们不擅长的地方是对高维数据进行建模,例如声音系数帧,或者视频帧;为了对像这样的数据进行建模,它们需要在隐藏到隐藏连接上比RNN更多的隐藏单元;最近,Ilya Sutskever试图去初始化一个RNN,并使用了所有人们在echo状态网络上提出的技巧,一旦你也这么做了,你会发现只是学习隐藏到输出的连接就可以学习的相当好。但是假定你同样学着去使隐藏到隐藏的权值更好(就是这里也考虑隐藏到隐藏的权值,Ilya是只对隐藏到输出的权值进行学习了),那么模型就可以学的更好。所以Ilya试图是echo状态网络初始化,但是随后使用BP through time 来进行训练。他使用带有动量的rmsprop方法,然后发现,这实际上是一个非常高效的方法去训练RNN。
![](https://images2017.cnblogs.com/blog/310015/201711/310015-20171108133036278-395950413.png)