这是一篇一年一度的自由主题分享。
年度好书推荐。
技术圈今年最火的 Idea 莫过于 GPT,从刚开始的 ChatGPT,到各种大语言模型百花齐放,midjourney 能生成各种精美的图片和人像,妙鸭相机生成各种个人专属写真。
从 GitHub Copilot 出现后,我在日常开发中就在不断的体验 AI 带来的变化。刚开始我一直以为 AI 写的代码都比较弱智,基本猜不出来我的意图,直到上个月,我才发现是我用错了方式。我觉得我是这个。
我记录了一下当时的心情和发现
最近 Review 过我的代码的同学应该也能发现,我最近提交的代码会包含很多注释,就是用来提示 AI 我想要完成什么任务,让 AI 来帮忙生成代码。
可以说现在随便上网冲个浪都能看到各种能认识但是不知道是啥的概念,什么 LLM 啦,Transformer 啦,什么茴香豆的茴的八种写法啦。
为了能跟上时代,咱也得抓紧时间学习,免得出现图上的这种情况。
我们今天的分享从这本书开始:神经网络编程入门书籍:Python 神经网络编程
我们会尽量避开其中复杂的数学运算和公式推导,简单的介绍一下什么是神经网络,神经网络可以用来解决什么问题,神经网络是怎么工作的。
先形成一个简单的印象,后续的探索就看大家的兴趣方向了。也推荐大家从这本书开始,一步一步的理解神经网络。
大家注意,今天在我分享的过程当中,会随机抽取幸运观众来回答问题,所以大家尽量撑起眼皮,跟上我的思路,积极参与互动呀。
现在我们开始吧。
比较学术化的解释是这样的:
神经网络是一种模仿生物神经网络的数学模型或者计算模型,用于对函数进行估值或近似。从而实现类人工智能的机器学习技术。
那么通俗一点的解释呢?
计算机的核心部分就是计算器,计算机可以以相当快的速度完成大量的算术运算。但是要从图片中识别出来图像包含哪些内容却是相当困难的。
研究人工智能的任务其实就是找到一些方法,把这些难以完成的任务转换成一些可以用算术运算来完成的任务,那么我们就可以根据算术运算的结果,获得这些任务的执行结果。听起来很像程序员的工作。
程序员的工作其实就是把要完成的具体任务拆解成一步一步可以用算术运算来完成的任务,然后通过编写代码,告诉计算机每一步应该怎么做,知道最后求出结果。
问题在于,自然界中要完成的任务太多了,要把每一种任务都对应上一种代码实现,再多程序员的头发也不够献祭的。
有没有可能我们不直接解决问题,而是解决提出问题的人呢?
人类刚出生的时候,也不是天然的就会这么多技能,是可以通过后天学习得到的。
有没有这么一种可能,我们用计算机模拟人的大脑的工作原理,就可以得到一个可以自己学习技能的机器大脑呢?
毕竟我们通过仿生已经有了那么多成功的案例,飞机,雷达,魔术贴等等。
当然是可以的。
关键点在这里
那么有同学就问了,还能不能再通俗一点呢?
没问题,在我的理解中,神经网络是这么一个东西
为什么很多人开玩笑说大模型是在炼丹呢?
就是因为人们并不确切的知道怎么让机器学习到我们指定的技能,而是使用大量的数据对模型进行调整,直到模型的表现和人类预期的越来越接近,模型中每个参数要设定成多少,需要有多少参数才够,我们并不知道确切的原因。
即便是这样,也不妨碍这个看起来乱七八糟的程序完成了任务。是不是特别神奇?
那么接下来,我们就先从构建超级简单的机器开始,了解它内部的构造。
首先我们用一个非常简化的例子来看看怎么让计算机学会把千米换算成英里。
我们知道,计算机并不是真的思考,而是被包装的计算器,那么解决一个任务,可以看作是这样一个过程。
计算机接受了一些输入,执行了一些计算,然后得到一些输出。
计算机并不知道千米和英里之间的转换公式。
我们可以提供一些信息让机器来试着学习。
接下来让机器试一试吧。
根据现在已知的信息,我们构造一台机器,把千米转换成英里。
我们先用一个随机数来试试,比如 C = 0.5
计算得到一个结果
把示例代入公式,我们发现结果是不准确的。但是没有关系,现在我们知道结果是错误的,并且知道差了多少,我们可以使用这个误差指导我们得到第二个,一个更好的 C 的猜测值。
看看这个误差值,我们算少了,由于千米转换英里的公式是线性的,那么只要增加 C 就可以增加输出。
我们把 C 稍微增加到 0.6
我们发现这个答案比之前的更好,误差变小了。
到这里,大家可能觉得直接算个除法就完事了,没必要这么折腾。但是授人以鱼不如授人以渔,我们的目标是找到一个让机器从数据中寻找规律的方法。
另外一方面,我们只是用了一个相当简单的例子来说明这个过程,而现实中存在很多问题是不能用一个简单的数学公式把输入和输出关联起来的。
我们继续这个过程,这次把 C 调整为 0.7 看看。
现在的误差值变成 - 7.863 了,这次的调整似乎有些过了
那么我们不妨把步子迈小一点,把 C 从 0.6 调整到 0.61 试试。
这次的结果更加接近正确答案了。
通过这次尝试,我们得到一条经验,对于 C 值的调整,需要适度,随着误差越来越小,我们的调整也需要变得越来越小,从而避免得到超调的结果。
当然,在这个结论中,我们更加关注的是这种持续细化误差值的想法,不用过于关注如何用确切的方式计算出 C 值。
到这里,我们就已经走马观花的浏览了一遍神经网络中学习的核心过程:我们通过一些已知数据,对随机生成的参数进行适度的调整,让机器的输出值越来越接近正确答案。
刚才的例子实在是过于简单了,现在我们稍微增加一点难度。来看看怎么实现适当的调整参数。
之前的例子中,机器接受了一个输入,并作出预测,输出结果,我们根据结果和已知的真实示例之间的误差调整内部参数,使预测更加精确。
现在我们把输入增加为两个参数看看。
这里是一个分类器的示意图,花园里的小虫子,毛虫细而长,瓢虫宽而短。
现在问题来了,这里应该怎么设计我们用到的模型呢?
我们在这幅图上画一条直线试试
如果直线可以把毛虫和瓢虫划分开,那么这条直线就可以根据测量值对未知的虫子进行分类,我们看到这条直线并没有实现划分的功能,我们改变一下直线的斜率看看。
这条线似乎对我们的目标没有什么帮助。再换一条线吧。
现在,这条直线整齐的把毛虫和瓢虫区分开了,那么这个时候我们拿一只新的虫子,测量它的长度和宽度,就可以用数据在这条直线的哪一侧来对虫子进行分类了。
当然,这个模型比较初级,没有考虑到还有别的类型的虫子的情况,我们先不要把问题搞的过于复杂,这样更有利于我们理解。
那么我们怎么获得这条直线的斜率呢?
这次可没有一个像千米和英里之间换算关系的公式来告诉我们斜率是多少了。
这个问题的答案处于神经网络学习的核心地带。
像解决之前的问题一样,我们可以继续用猜测加修正的策略来不断接近我们想要的结果。
和之前的例子一样,我们需要一些可以借鉴的实例。这里我们先用如下两个实例进行尝试。
这些用来训练的真实实例我们称为训练数据。
我们可以先把个两条实例可视化出来,这样可以方便我们理解和感知数据。
接下来,我们先随机生成一条直线,为了便于理解,我们可以使用一个特别简化的公式 y=Ax 来表示这条直线。
首先我们试一下 A 为 0.25,来看看是什么情况
通过可视化的结果,我们可以直接看到 y=0.25x 不是一个很好的分类器,这条直线没有把毛虫和瓢虫区别开来。
我们只需要把直线向上移动一点,就能达成想要的目标了,这是我们把数据图像化之后看到的结果,计算机可没有这个能力。
还记得我们之前调整的思路吗?
我们会计算一个误差值,用这个误差值来指导接下来的调整方向对吧。
那么我们回到训练数据,把第一个样本数据代入函数,其中 x 为 3.0
那么我们计算 y=0.25*3.0=0.75
而训练数据告诉我们,真实值是 1.0,这个数字太小了。
现在,我们有了一个误差值,可以用来指导我们调整参数 A。
在开始调整参数 A 之前,我们来想一想 y 应该是什么值,y 应该是 1.0 吗?
如果 y 是 1.0,那这条直线就会穿过瓢虫所在的点,而我们想要的是可以区分瓢虫和毛毛虫的分界线,所以,不妨把预期的 y 稍微调大一些,比如用 1.1 来试试。
我们用小学数学就能计算出来,现在的误差值 E 是 1.1-0.75=0.35
接下来,怎么用 E 来指导我们调整参数 A 呢?
要做到这一点,我们需要知道 E 和 A 之间的关系,这一定难不倒我们对吧,直接列个公式。
y = Ax
我们把目标值记为 t ,A的变化量记为 ΔA
那么 t = (A + ΔA)x
E 等于什么?
E = t - y
E = t - y = (A+ΔA)x - Ax
简化一下
E = ΔAx
最终,得出 E 和 ΔA 的关系
大家现在还跟得上吧?不至于低头捡个笔的功夫,黑板上就写满了公式吧。
好的,这就是我们需要的那条表达式
来算一下,误差值是 0.35, x 是 3.0,那么 ΔA = 0.1167
这意味着当前的 A 需要增加 0.1167 变成 0.3667
这个时候,使用 A 计算得到的 y 值为 1.1,正如我们希望的那样。
我们找到了基于当前的误差值调整参数的方案。
现在我们已经完成了一个实例训练,接下来我们看第二个实例的训练过程。
我们已知正确值 x = 1.0 y = 3.0
我们把现在的 A = 0.3667 代入函数中,我们得到 y = 0.3667
距离样本中的 y = 3.0 差距还是比较大的
之前我们说过,不希望直线经过训练数据,而是稍微高于或者低于训练数据,现在,我们把 y 设为 2.9,这样,毛虫的训练样本就在直线上方。
这个时候误差值 E = 21.9 - 0.3667 = 2.5333
和之前一样,我们根据误差值调整 A 的值,ΔA = E/x = 2.5333
那么 A = 0.3667 + 2.5333 = 2.9
好,现在我们已经用两个训练数据完成了训练,我们来看一下整个的训练过程吧。
现在大家能看出来我们的训练过程有什么问题吗?
我们看这幅图,训练的结果并没有像我们期望的那样比较均匀的分割毛虫和瓢虫,而是贴近最后一次训练的结果。
这样继续训练会有什么问题呢?
我们得到的结果只会和最终一次训练的样本非常匹配,也就是说,我们的训练过程没有顾及先前的训练样本,而是把之前的学习成果直接抛弃掉了。
那么如何解决这个问题呢?
在机器学习中,有一个词叫做适度改进,什么叫适度改进呢?
适度改进也就是说我们不要让改进过于激烈,只采用几分之一个变化值而不是整个 ΔA,让训练的结果保持先前训练结果的一部分,然后小心谨慎地向训练样本指示的方向移动一点。讲究的就是一个雨露均沾。
这种适度改进的策略还带来了一个优势,比如训练数据本身并不是完全正确的,他们可能会包含真实世界中的各种噪声,或者测量误差,当出现这种情况的时候,适度改进的策略有助于抑制这些错误或噪声的影响,让我们的结果更加贴近预期。
现在,我们再来看看我们的改进公式,基于适度改进的思路,我们对公式进行一些调整,添加一个调节系数。
L 这个调节系数我们通常称作学习率。
我们把学习率设置为 0.5,再次重复之前的训练过程,只不过我们这次只更新 ΔA 的一半。
我们可以发现,第一次训练的结果中,由于加入的学习率,训练的结果并不是马上可以清晰的完成分割,但是与初始直线相比,这条直线确实向正确的方向移动了。
在第二次训练结束后,我们可以看到,结果和我们预期的已经很接近了。
到目前为止,我们已经了解了简单的预测器和分类器,他们接受某个输入,进行一些计算之后输出一个答案。我们先把它们称作线性分类器。
线性分类器是存在局限性的,如果不能用一条直线把根本性的问题划分开,简单的线性分类器就没用了。
比如我们尝试用单个分类器来实现异或逻辑。
函数可视化后是这样的,你会发现没有办法只用一条直线就把两种结果分割开来。
这种情况下,解决方案也比较容易,我们可以使用多个线性分类器来划分由单一直线无法分离的数据即可。
这意味着我们可以使用多个分类器一起工作,这就是神经网络的核心思想。
神经元的工作方式启发了神经网络的方法。
现在,我们观察一下生物大脑中的基本单元–神经元。
虽然我们还没有办法完全解释大脑的全部功能是怎么实现的,但是这并不影响利用仿生学进行模仿。
我们来复习一下简单的初中生物知识。神经元是这样工作的:
大体上来说,它接受了一个电输入,输出另一个电信号。和我们之前观察的分类或预测的机器一样,这些机器也是接受了一个输入,进行了一些处理,最后产生一个输出。
再细致一点看的话,其实神经元有多个树突用来接受信号,轴突上也有多个终端将信号传递给多个神经元。
而且神经元在接受到信号之后也不会立即反应,而是会抑制输入,直到输入增强到某个可以触发输出的强度,才会产生输出。
也就是说,神经元不希望传递微小的噪声信号,只传递有意识的明显信号。
我们要模拟这样的行为,就需要找到一个数学函数,模拟这样的行为。
幸运的是,我们今天关注的重点并不在如何寻找这些函数,所以我们可以直接拿来主义。
如图所示的 S 形函数我们称为 S 函数,在人工智能的研究中,还有其他外形类似的函数,这里我们挑选 S 函数主要是因为这个函数足够简单,也完全可以满足我们的使用。
现在,我们回到神经元的话题,思考如何建模人工神经。
如之前所说,生物的神经元可以接收多个输入。我们刚才处理的异或逻辑就有两个输入。
我们把这些输入相加,把得到的总和作为 S 函数的输入,作为当前神经元的输出。
这个时候,如果输入值的总和不够强大,S 函数会抑制输出;如果输入值的总和够大,S 函数会激发当前的神经元。
如果当前神经元被激活,信号会被传递给多个神经元。
把这种模式复制到人造模型的一种方法是构造多层神经元,每一层中的神经元都与它前后层的神经元相互连接,就像这样。
这里我们有三层神经元,每一层有三个人工智能神经元,每个节点都和前一层或后一层的每个节点相互连接。
这个时候,有没有和之前线性分类器中的斜率类似的参数供我们调整呢?
最明显的方式就是调整节点之间的连接强度。当然我们也可以调整每个节点的 S 函数的形状,但是这样会相对比较复杂,如果相对简单的方法可以工作,我们就不要给自己制造难题。
增加连接强度信息之后,我们再来看一眼现在神经网络的样子。
图中,W1,1 就代表当前层第一个节点和下一层第一个节点之间的权重,W 是 Weight 的缩写。
可能有同学会问,为什么一定要把每一层的所有节点都连接起来,为什么第一层的节点不能直接连接到第三层,blabla。这样做的原因主要是这样模拟神经信号传递的计算过程是一致的,把这个模式编写成计算机代码相对简单。在神经网络学习的过程中,如果有一些不需要的连接,这些连接的权重会趋于 0。为了方便,冗余几个连接也无伤大雅。
现在,我们来实际模拟一下整个运算过程吧。放心,鉴于我们只是摸一摸神经网络的门槛,这里涉及的数学计算并不复杂,只需要小学二年级的水平就足够啦。
三层神经元计算起来也没有多复杂,不过为了简单,我们先来一个两层的模型看看。
如图,我们接收了两个输入值作为第一层神经元节点的值。
现在我们要计算第二层的神经元节点的值。
还记得刚才我们模拟信号传递的过程吗?
现在,我们加入了权重的概念,那么计算方式发生了一丢丢变化
那么权重应该是多少呢?
和我们先前简单的线性分类器做的一样,我们完全可以使用随机值作为初始值,反正经过训练之后,这些值会向正确的方向移动。
那么,我们开始计算吧。
先不用管这个神经网络是干啥的,我们就是单纯的感受一下计算量。
假设两个输入值分别是 1.0 和 0.5. 权重值随机。
第一层是输入层,节点的值就等于输入的参数。
第二层节点的值就有一些计算量了,对输入值应用下面的函数。我们可以得到如图的输出值。
可以发现,尽管是简单的 2 * 2 的神经网络,计算量就已经有点大了。我们可以想像一下如果我们要对 5 层,每层 100 个节点的网络进行计算,会有多大的工作量。
有没有什么简单的方式描述这个过程呢?
数学家们早就遇到过这样的问题并找到了解决方式,那就是行列式。
还记得我们小学二年级学过的线性代数吗?就是这个玩意。
最早在九章算术中,就出现过用矩阵形式表示线性方程组系数来解方程的图例。
线性方程组是什么样子的呢?
这个样子看起来熟悉不熟悉?
这样一来,刚才我们计算第二层节点值的整个过程就可以用一个矩阵乘法来表示。
这里我们先忽略 S 函数,应用 S 函数也比较简单,只要对矩阵乘法的结果矩阵中的每个元素计算一次 S 函数即可。
上面这两个过程,在 Python 中都可以找到很方便的函数进行计算,基本上我们只需要编写一两行代码就可以完成上面这么多的计算量了。
到这里,我们就已经完成了构建人工神经网络的前一半工作。
后一半工作是什么呢?
和之前一样,我们需要使用计算结果和真实样本进行对比,得出误差值,使用误差值来调整人工神经网络中的权重参数,让神经网络的输出值偏向我们期望的方向。
在简单的线性分类器中,只有一个参数需要根据误差进行调整。现在我们的每个神经元有多个输入,每个输入有不同的权重,现在这些权重要怎么进行调整呢?
简单粗暴一些的方式就是不管权重,直接根据输入参数的个数平均分配这些误差。
但是既然我们有了权重这个概念,从理论上讲,每个参数对于误差的贡献是不均等的。使用权重按比例分配误差,看起来是更有效的一种方式。
使用误差和权重,从后向前调整各个参数的权重,这个过程我们叫做误差的反向传播。
我们可以看一下简单一些的例子。
以第二层的第二个节点的误差计算为例,首先我们用输出结果和预期值对比,得到了第三层节点的误差值。
将误差值根据权重按比例分配后,再对各个节点收到的误差进行重组。
如图可以计算出第二层第二个节点的误差为 0.88
到现在,一切似乎都比较顺利。
理论上,我们可以根据最后一层的误差,和各层的权重参数,推算出各层的误差。
那么现在问题来了,这些节点都不是简单的线性分类器,怎么根据误差来调整参数呢?
我们跳过复杂的推算,先看看我们要对什么样的函数进行推算,来计算预期的 W 吧。
我们还是不要硬碰硬的去解决这个问题了。
哪怕是利用计算机的速度优势,采用暴力破解的方式,要找到正确的权重值,对于较大一些的神经网络也是不切实际的事情。
为了解决这个问题,我们要做的第一件事情就是调整心态,拥抱悲观主义。
现实中的局限太多了,训练数据可能不足,训练数据可能有错误,神经网络规模不够对问题进行建模等等。
我们需要承认这些限制,即使我们找不到一个完美的数学公式来计算权重,但是我们可以找到方法,向正确的方向一点一点调整,最终,我们也可以获得一个不错的结果。
举个例子,仿佛我们在一个非常复杂,有波峰波谷的群山峻岭中,身边一片漆黑,我们没有精确的地图,只有一把手电筒,要到达山脚,我们要怎么做呢?
我们可以用手电筒观察附近的地形,小步的往附近看起来更低的方向移动。
在数学上,这种方法叫做梯度下降。
我们把复杂的地形看作一个数学函数,梯度下降的方法给我们带来一种能力,我们可以不用完全理解复杂的函数,用代数计算它的最小值。而是一点一点改进所在的位置,虽然这样无法给出精确解,但起码比得不到答案要好。
回到我们的人工神经网络,着意味着什么呢?
下山找到最小值就意味着最小化误差。
我们使用一个简单的例子来演示一下。
要使用梯度下降的方法,我们先找一个起点,我们在起点的地方,观察一下哪个方向是向下的。图上标记了当前的斜率为负,那么我们稍微增加 x 的值,就会向实际的最小值靠近一些。
再换一个位置,现在所在位置的斜率为正,那么我们需要稍微减小 x 的值。
一直持续这样的操作,直到几乎不能改进为止,我们就达到了最小值。
这里有几点需要注意:
我们要改变步子的大小,避免超调。比如我们离最小值只有 0.5 米,却采用了 2 米的步长,这会导致我们在最小值附近不停的跳跃。所以我们在接近最小值的时候,要调小步长。对于光滑的连续函数,越接近最小值,斜率越平缓。
抛开这个简单的例子,当函数有多个参数的时候,函数的图像会更加复杂。
比如这样的函数,会有多个山谷。为了避免终止于错误的山谷,我们可以从山上不同的起点开始进行多次训练,在人工神经网络的概念中,这相当于我们选择了不同的起始链接权重。
这张图就说明了使用梯度下降方法的三种不同的尝试,其中一次,方法终止在了错误的山谷中。
最后,就是要找到合适的误差计算函数,对它使用微积分和一些数学技巧,找到计算权重的变化量的函数,就可以对权重进行更新了。
这部份涉及到的数学知识比较多,但也不超过小学二年级的知识范围,大家可以找到书在睡前啃一啃,相信很快就会理解其中的奥妙的。
如此一来,经过成百上千次的迭代,权重会最终确定下来,此时神经网络就会生成和训练样本相同的输出。
比如,以手写数字的识别为例,训练神经网络。
我们可以把手写数字的图片切分为 28 * 28 = 784 个像素,将每个像素的灰度值作为神经网络的输入值,这样就需要神经网络有 784 个输入节点。
预测结果是 0 到 9 的是个数字,那么神经网络就需要有 10 个输出节点。我们把输出节点值最大的节点代表的值作为图像识别的结果。
把神经网络的节点个数确定之后,选择合适的中间层节点的数目,随机初始化各个节点之间的权重。使用大量人工标注好的数据让神经网络进行学习之后,这个网络就可以识别手写数字了。当然,和人一样,识别正确率很难达到 100%。
现在,我们就完成了今天的任务,成功的摸到了人工智能神经网络的门槛。如果大家还有兴趣,后续可以再找相关的资料进行学习分享。