最近 AI 很火。我从读书的时候就对 AI 有兴趣,90 年代的时候读过一本关于神经网络的书。但当时没有能力完全理解。2000 年初刚毕业那会儿想搞明白点,又研究过一段时间。了解了很多相关知识,感觉自己弄明白了,但却没有真正动手实践过。
现在,所有人都看得到,AI 已经变成了一个可以真正帮助我们提高生产力的工具了。我觉得有必要好好学习一下。我并不期望几篇文章就可以搞清楚,那样只会让自己好像懂了,和人聊天估计够用,想理解它还是得自己动手。真正写几段代码,才有触碰的感觉,明白别人到底在解决什么问题。
一开始,我是从维基百科的 Machine learning 开始读的。顺着看了一整天,了解了近年发展的脉络。好多词条,乱七八糟的笔记记了一大堆,感觉快消化不了了。疑问也积累了很多,觉得看下去效率会越来越低。不如换一条路。
然后我开始读 Neural Networks and Deep Learning 这本书。读了两章后,我想,手写数字识别看起来是一个非常好的实践手段,不如自己写一遍最快。有这么一个基础,后面可以继续修修补补,沿着前人的轨迹走一下,可以更好的理解为什么。
现在的条件远超 20 年前,训练这么一个简单的前馈神经网络估计在我的机器只要不到一分钟,可以很快得到反馈。有很多现成的代码可以比对,出了 bug 容易查。练习需要的数据也是唾手可得:THE MNIST DATABASE
of handwritten digits 。虽然神经网络到底怎么工作的,人类恐怕还无法弄清楚每个神经元传递的信号的意义。这会导致程序出了 bug 很容易被掩盖起来,但有了数据库里的数据,我们还是可以从结果的正确率推测自己有没有实现对。所以用这个手写数字识别这个课题来学习是非常好的。
我所理解的神经网络,就是模拟大脑的工作方式,用非常简单的神经单元,相互连接起来,通过传递非常简单的信号,最后可以从网络的一端输入信息,另一端得到预期的结果。用程序模拟现实事物本来就是我的爱好,游戏就是干这个的。所以写这么一个程序让我感到非常快乐。
我们的信念是:模拟神经网络,尤其是实现单个神经元的代码肯定是异常简单的。起作用的不是单个神经元多么奇妙,能思考问题;而起作用的是网络结构本身。如果神给定了一个合适的网络结构,我们从一端输入若干信号,经由若干连接(神经元之间的突触),发送给众多神经元。一个神经元会被很多突触连接到多个神经元,每个通过突触的信号被突触上的权重所修正累积在一起,在接收的神经元内再由此神经元内的阈值修正一次,把该信号传播下去。(也可以把阈值看成连接到这个神经元的一个常量信号)最后,一定能得到正确的结果。如果结果不正确,并不是方法有问题,只是网络结构不对。
所谓人工神经网络,只要得到了正确的网络结构,用它来解决问题是非常简单的:模拟信号传播的过程,把信号通一遍就好了。我的代码的最初版本就是用已有的正确的程序训练出网络,把数据导出,然后导入到我的代码中验证一下。这样,我只用实现最简单的部分,可以快速检验实现的对不对(避免后面加入更复杂的部分时,难以定位 bug )。
最简单的神经网络就是把所有神经元相互全连接。这显然不是生物上神经网络的结构,但它是最简单的。因为,如果神经元是一组组聚集在一起,相互间只有部分连接的话,无非是把全连接中的部分连接权重设为 0 而已。
但这也是真正的困难所在:到底该怎样得到一个正确解决问题的网络结构。也就是所谓的训练。如果我们有无限的时间,固然可以穷举所有的可能性(每个连接上的权重),但穷举所需的时间一定超过了宇宙的寿命。而神经元之间的连接数量也远超了人为去每个设定的能力范畴。我们必须用某种启发式方法减少这个寻找正确网络结果的时间,同时还需要简化数据规模,比如卷积神经网络把神经元分组分层,就极大的减少了连接数量。
作为学习,我想先从一个最简单的前馈神经网络开始。也就是根据问题:从 28x28 = 784 个像素的灰度图中识别出手写的数字 0 - 9 。输入是 784 个灰度信号,输出则是 0 - 9 十个独立信号。其中每个信号表示结果是或不是某一个具体的数字。固然,10 个独立信号有很大的冗余。理论上 4 个信号就够了(可以表示 0-15 个状态)。但是,如果采用 4 个信号的输出,相当于除了让网络识别出数字外,还需要理解数字的二进制表示。这肯定需要更复杂的网络结构,势必大大增加训练出网络的难度。
中间模拟神经网络的部分,采用书上建议的 30 个神经元。采用无环结构,让它们一边和 784 个输入单元全连接,另一端和 10 个输出单元全连接。所谓的训练工作就是反复尝试找到每个连接上的权重,以及每个神经单位过滤信号用的阈值。一共 784 * 30 + 30 + 30 * 10 + 10 个需要测算的参数。
训练的方法是最基本的监督学习。就是用已知结果的信号去跑网络,告诉它这样一张图片是数字几。这样让网络知道人所需要的结果偏好。MINIST 数据集中有 5 万组预扫描好的图片,并标注了结果数字。一开始输入图片后,结果肯定是乱来的。但是我们可以根据错误的结果和正确的结果比对,来调整网络 。这里可以采用一种叫 stochastic gradient descent (SGD) 的方法。有点像调收音机,感觉频道不对,就稍微转一下旋钮。再听一下信号好不好,如果好了,方向就对了,如果差了就反着转一下。这个过程叫 backpropagation ,根据结果误差来修正源头,把误差反向传播回去。但是,对于神经元来说,每个神经元对应的源头有很多个,我们很难确定该转哪个源头的旋钮,该转多少。
为了更好的调整这些权重值,我们需要对信号做一些处理。因为每个神经元接收到的信号都是很多来源叠加起来的,所以,不应该让某些信号过于特殊。不然它的一点点变化就会极大的影响输出,掩盖掉另一些信号的调整影响。我们可以用一个函数,把所谓输出信号的范围从整个实数范围全部放到 [0,1] 之间,这样,对于每个神经元来说,它的每个输入看起来都差不多了,可以按一致的规则去调整。
其实,这个对信号做变形的函数具体是什么不重要,重要的是这个函数连续可微,它把输入信号都规范方便调整了。书上推荐的 sigmoid 函数之所以用得多,还在于对它求导非常简单。后面这点非常重要。
我在一开始觉得 backpropagation 的细节很难理解。后来看了另一篇文章 How Does Backpropagation in a Neural Network Work? 就明白了。原来,现在用的方法并不是人们一开始都知道的。现在我们用的从结果的差异反推来源的 delta ,公式非常简单,但数学原理并不简单。感谢 Andrew Ng 的公式 delta_0 = w . delta_1 . f'(z)
,我们只需要对正向信号传播时,每个神经元上得到的值求导,乘上该神经元输出端的神经元上的 delta 和输出权重就可以得到它的 delta 。总 delta 就是所有输出侧用该公式计算出的 delta 累积量。
我写程序的过程中,一共出过两个 bug。其中一个就是在 backpropagation 过程的,主要还是理解不到位引起的手误。花了两个小时重新读书、理解、再审查代码才找出来。
我将代码提交到了 github ,有兴趣的同学可以参考。https://github.com/cloudwu/neuralnet 。
说到另一个 bug 。一开始我的程序可以正确运行,后来我尝试调大了一些神经元的个数,比如从 30 改到 40 ,质量却急剧下降。这让我难以理解。花了 2 个多小时排查(其实整个程序连同读书的时间,一共也就花了 10 个小时),才发现问题并不是出在神经网络的实现上。
我在书中读到,初始化神经网络时可以用正态分布的随机值初始化,这样可以减少训练时间。暂时我没有去探究这样做为什么有效,或是有什么更好的方法。只是动手实现了一下。因为我没有使用任何第三方库,而 C 标准库中的随机数是均匀分布的。所以我随手 google 了一个公式,把均匀分布的随机数转换为正态分布:取两个均匀分布的随机数 r1, r2 ,然后计算 float x = sqrtf( -2.0 * logf ( r1 ) ) * cosf ( 2.0 * PI * r2 );
。每次用一个随机数,记下另外一个,留给下一次迭代就可以了。
我没有怀疑这个公式有什么问题。但我疏忽了当输入的 r1 为 0 时,log(r1) 会是无穷大。而无穷大会污染神经网络,导致经过几次 backpropagation 后,很多权重都变成了 NaN 。
这次的实践只能说是一个开始。不过已经占用了很多工作时间。我们的游戏还在制作中,我得先放一放,等下周再试试深入一点的知识。