基本语法也差不多就那样,是时候用Julia的基础语法综合起来做一点事情了。Julia内置了线性代数的标准库,用线性代数的基本运算,就可以打造出一个接近专业水准、可以有效识别手写数字的神经网络。
Jupyter环境配置
以下内容是在Julia 1.0搭配Jupyter环境下编写。upyter可以为Julia提供文学编程,即可以文字说明+代码,在Python的科学计算中相当流行。安装方法如下:
在Julia的REPL中输入 进入包管理环境:
使用 添加包(注意, 是是大写)
构建包:
如果已经装有Jupyter,比如之前使用过Anconda Python科学环境的,需要先添加原有Jupyter的环境,否则很可能会出错。请先在REPL中执行:ENV["JUPYTER"] = ".../bin/jupyter"(将路径替换成Jupyter的安装路径)
安装完成后,可通过退格键(BackSpace)或者退出包管理环境,回到正常的REPL。要使用Jupyter,先用导入包,然后输入,就会自动跳出基于浏览器的Jupyter环境。
基础设施
神经网络
如果要用一句话概括神经网络,那就是神经网络干的就是拟合数据的事情,只不过它的拟合能力很强。它的实现原理(以有监督学习为例),你给一堆数据,标注好数据是什么,然后扔进一个黑盒,然后这个黑盒子给出结果,你对比一下你的参考答案,可能发现误差好像有点大,于是你把误差算出来,反过来再扔回那个黑盒里,骂一句,你就是个人工智障!!!那个人工智障,不对,是那个黑盒子会吸取教训,修正一下,变得聪明一点点,当然也有可能变傻了。
你扔的数据越多,学习的目标越少,效果肯定越好,毕竟勤能补拙,但是一定要记住过犹不及~凡事适可而止,小心过拟合!
黑盒子的奥秘
那个黑盒子没有什么神奇的,就是神经网络。而所谓的神经网络,是由所谓的神经元构成。据说研究表明,神经元不会立即反应,需要等到输入增强到一定程度才会触发输出。
为了模拟这个过程,我们使用了一个激活函数。通常我们不会使用阶跃函数,避免出现尖锐点。
我们想要描述这个网络,需要一个称为矩阵的强有力工具。
可以这么说,矩阵就是那个黑盒子。
隐藏层
神经网络不能是简单的从输入层到输出层,需要中间再加隐藏层。这一层既不接收外部的信息,也不直接对外部世界起作用,只和其它层级的神经元发生联系。最早的人工神经网络就没有隐藏层,因此它们的输出会简化接收到的信息。这种单纯的“输入 - 输出”的感知系统有明显的缺陷,比如无法做非线性的分类。直到20世纪80年代,人们才意识到哪怕加入一两层的隐藏层,都能大大提升网络的性能。当然,加入的层级越多,通常拟合数据的能力就强,但是收敛速度也就越慢,需要的数据量就越大。
构建网络
有了上述铺垫,我们需要一个输入层、一个隐藏层、一个输出层的三层神经网络。还需要一个学习率(待会再说),一个激活函数。嗯,就这么简单。
我们保存的是神经元间链接的权重,并不是保存节点。比如,w11是从上一层的第一个节点,到下一层的第一个节点的权重。
我们使用来保存神经网络的结构信息。不用,是因为训练的时候要修正中间的神经网络,如果用了不可变的,就修改不了了。
于是,有了这个代码:
由于函数是一等对象,可以考虑声明一个变量,用来保存激活函数。但是,Julia似乎不打算把函数绑定在对象上。所以,我们在外面,另外写出激活函数:
到了这里,我们的基础设施就完成了。接下来,要考虑一下神经网络的初始化问题了。考虑用简单的工厂方法的设计模式。
初始化神经网络
说白了,只不过加了一层包装。这一层包装中,最重要的是对输入层到隐藏层和隐藏层到输出层之间链接权重的初始化。这里用到了,Julia的标准库,请先。
你当然可以选择最简单的,在这里,我用了一个简单的优化,利用传入的链接信息,采用平均值是0,标准差是的正态分布。这是一个简单,但是的确有效的改进。因为要用到正态分布,需要Julia的标准库。
香农的信息论指出:有新的信息加入可以降低不确定性。
还有,参数一定不要写错。矩阵计算的时候,隐藏层实际上是左矩阵,输入层是右矩阵。这样,由矩阵乘法(左矩阵的列数必须等于右矩阵的行数),就可以消除输入层影响(最终结果没有n和k,输入层到隐藏层消除了n,隐藏层到输出层消除了k)。
接下来,就到了激动人心的时刻,用线性代数实现前馈计算和反向传播。
线性代数的胜利
先来实现最简单的前馈计算,这也是最终我们用来识别手写数字的方法。
前向计算
举个简单的例子,你就知道为什么矩阵乘法很重要了。我们假设输入层有两个节点,隐藏层有2个节点,那么显然就会有4条链接。将这4个权重写成矩阵,放在左边,输入的值也写成矩阵放在右边,一运算。结果刚好是每个隐藏层节点(神经元)应该得到的输入。
这显然非常有用,因为我们可以使用矩阵乘法表示所有的计算,计算出组合调节后的信号。然后应用激活函数,就可以进入到下一层的计算。
可以简单地把公式写成:
我们用Julia简单模拟一下这个计算:
我们将这个结果放进激活函数,注意要使用广播:
输入层到隐藏的计算是这样,隐藏层到输出层的计算也是一样的,于是我们有了代码:
这个是我们扔数据的过程,也是模型训练完,我们用来输出结果的方法。接下来我们开始着手写训练模型的方法。
反向传播
反向传播可以使得神经网络能够创建属于自己的内部表征。当输出和目标结果不符合的时候,神经网络将创建一个“错误信号”,将信号通过神经网络传回输入节点(反过来)。随着错误的一层层传递,网络的权重也随之改变,这样就能够将错误最小化。当然,这个计算相对而言要复杂很多,所以比较消耗计算资源。后面会有显得比较高能的数学推导,如果不喜欢,可以跳过。要修正模型的误差,我们可以根据传入节点对最终值的贡献来分割误差。例如,两条边权重为3和1,显然权重为3对结果的贡献更大,那么要修正的误差同样应该更大一点。很容易得到3/(3 + 1)=3/4和1/(3 + 1)=1/4这两个分割误差的比率。
我们尝试把这个过程用矩阵的语言描述出来。我们把目标结果和神经网络的输出结果做差,就可以得到误差矩阵。
为了让代码更优雅,这里采用简单的err1*w1,而不是err1*w1/(w1+w2)。实践表明这种简单的计午误差信号反馈,和复杂的方式一样有效。我们采用这种简单的方法,就可以用转置优雅地写出来:
这个矩阵是系数矩阵的转置,在Julia中,你只需要优雅地写一个。
到了这里,我们已经可以把误差反向传播到网络的每一层。但是还有一个最复杂、也是最关键的问题,如何更新链接权重。我们的不是简单的加权求和,而是在结果上应用了S阀值函数,然后才把结果传递到下一层。求出代数表达式是不靠谱的,毕竟节点太多了。数学家多年来也没有解决这个问题,直到20世纪60年才有了一个切实可行的解决方法。这个迟来的发现导致了现代神经网络的发展。这就是梯度下降的算法!这里就不展开梯度下降算法的原理了,推导一下公式就行了。我们知道导数的几何意义就是斜率,而那一点的斜率和那一点可以得到一条直线,可以用来代替那一小段的函数值,也就是我们常说的以直代曲。
如果我们能找到误差函数的在节点处的斜率,我们就找到了函数应该变化的方向,从而也就得到了误差调整的数值。我们最终想做的是让误差函数尽可能小。你可以理解成我们希望找到使误差函数达到最小值的那个数。
最简单的衡量误差的方法就是把目标值-输出值,但这会有一个问题,就是正负误差相互抵消。
很容易想到用绝对值,但是绝对值函数在0处有个尖锐点,斜率不是连续的,这样梯度下降方法无法很好地发挥作用。
所以,我们采用和绝对值等价的误差的二次方。这样可以克服上述问题。假设我们现在有一个输出节点输出了ok,我们的目标值是tk,那么误差的二次方自然是:
输出的结果显然由隐藏层的wj,k产生(j现在是泛指所有隐藏层节点)。
我们想要得到某个隐藏节要修正的误差,就是要找到那一点的导数值:
目标值自然是固定不变的常数,所以我们可以把ok视为变量,把上面式子用链式法则改写成:
对平方函数进行简单的求导,就可以得到:
对于输出结果ok,我们它是加权求和后使用了激活函数,所以可以继续写成:
继续对Sigmoid求导,有:
我们不关心前面的系数,我们只对方向感兴趣,所以可以去掉2。把误差替换成e,得到最终的表达式:
这样我们就得到权重的更新公式:
其中的α 就是学习率,目的就是调节每次更新的幅度,值越小,每次更新的幅度就越小,当然可能结果可以更精确、也不太容易过拟合。但是,收敛速度慢。最后,用矩阵来表达上面这个式子,可以更容易编程实现:
代码很简单,前面和前向计算是一样的,只不过多了权重调整:
至此,我们的训练函数就完成了。一个通用的三层神经网络就搭建好了。是时候来干点什么了。
准备数据集
领取专属 10元无门槛券
私享最新 技术干货