找回密码
 立即注册
首页 业界区 业界 手算神经网络BP传播算法

手算神经网络BP传播算法

姚望舒 昨天 21:24
虽然说是手算,但是我还是会写一点 C# 代码,避免敲坏了计算器。我和大家保证,整个手算过程中,最终的计算结果只需要用到初高中知识。推导过程会用到部分高数的知识。我尽量将用到的知识点全列举出来,本文对学渣友好,期望能够拿出纸笔和 VisualStudio 的伙伴阅读完本文能够真的理解神经网络BP传播算法是如何计算的
看了一下时间,今年确实 2025 年,而不是 2015 年。在 2025 时还在聊BP 算法实在有点一言难尽。我在 10 多年前尝试写过贴近的程序,当时写的时候有一些概念没有理解,但代码是写了也能跑,甚至于在当时世界上最快的超级计算机跑了我的代码。但不能理解的部分就是不能理解。最近在散步的时候,我的伙伴 https://github.com/SeWZC 和我说明白了求偏导的数学意义。我当初高数学了3年,别人都是只学一年(因为我不断挂科),那会以为偏导没有什么,于是缺了一个知识点,导致我对 BP 的部分理解错误。尽管代码能跑,也能符合预期进行训练,但里面关于传播算法中,如何计算各个权重参数的过程我是不能全说明白的。我最近理解了偏导的数学意义之后,再到知乎上阅读了 通俗理解神经网络BP传播算法(https://zhuanlan.zhihu.com/p/24801814 ) 文章,尝试按照知乎文章的给定的内容和方法,自己手算了一遍,我就完全理解了之前我所写的代码了。担心本金鱼会忘了之前的想法,或者担心下次和伙伴聊天的时候又说错了,我就编写了本文。可以认为本文没有给出比 通俗理解神经网络BP传播算法(https://zhuanlan.zhihu.com/p/24801814 ) 文章更多的内容,只是按照我自己的方式一步步推导和计算。阅读完本文,预期大家能够对神经网络有了更明了的理解。如果大家还是在迷迷糊糊地训练人工智能做玄学的活,那阅读本文可以让大家能够稍微有点落地的感觉,至少大概知道简单的 BP 神经网络是如何工作起来的
我努力让大家尽量少知识地开局
开始之前,期望大家已经听说过一些神经网络的概念。这里只要求听说过,不解其中的内容也没关系,有部分概念我没提到的,也许大家看完了自然就能明白,或阅读完本文之后再继续在网上搜关键词继续了解。期望听说过的概念有:神经网络、图论、矩阵、激活函数、损失函数、权重
神经网络可以理解图论上的一张图,尽管在几乎所有的科普文章中,都会使用到矩阵,但从原理上说都是图论的一张图。经费有限,有了节省矩阵的出场费,本文就不带入矩阵的内容了,直接平铺进行计算。本文也不会用到多少图论的知识,只是用了一点图论上的概念。本文介绍的手算的神经网络是一个简单的有向图,只有5个点。即使没有了解过图论的伙伴,也许通过示意图也能够看懂
本文的手算内容是假定咱就是一个无情的计算机,正在被训练的人工智能。咱的需要计算出图上(图论的图)的各个点的权重。为了让人类更方便理解,以下图中,我给定了明确的数值。假定输入是两个数字,期望经过咱训练出来的神经网络之后,能够获取符合预期的输出数字。这样的模拟情况,就能够覆盖非常多的情况了。也许有伙伴此时还会有一些疑惑,没关系,先带着疑惑看下去吧,我也不能一口气全交代清楚情况
以下是咱的神经网络图(图论的图)的示意图
1.png

其中 a 和 b 两个点负责输入,e点负责输出。各个点之间的连接存在权重,其示意图如下,下图的 wxx 表示的就是各个边的权重。写成 wxx 的形式其实就是在隐含矩阵的概念了,只是咱本文这里不需要请出矩阵而已,如果真套上矩阵的话,这里的权重就是 w行列的表示法,即 w21 就是表示第2行第1列的值。这也是一个习惯表示方法
2.png

假定有输入源 x0=0.35 x1=0.9 , 期望的输出是 \(y_{out}=0.5\)。这就是一组训练值,当然了,在实际的神经网络训练里面,肯定会给许多组训练值的。只是现在换成人类手算,需要降低难度,就只要求训练这一组数据。假定没有神经网络,就是一个正常人类在拿到 x0=0.35 x1=0.9的输入,要求写出一个函数,让这个函数能够根据此输入计算出 \(y_{out}=0.5\) 的值,估计也没那么简单(禁止直接写 \(f(x0,x1)=0.5\) 这样的拖出去打靶的函数)
为了更加方便大家理解,这里先给各个权重添加随机的初始值。初始值很多时候真的是随机给的,依靠的是训练过程中不断算法修改参数,从而训练出一个可用的神经网络。咱现在这个神经网络只是期望训练出当拿到 x0=0.35 x1=0.9 的输入时,能够返回 \(y_{out}=0.5\) 的输出。标记了添加了随机权重初始值的示意图如下
3.png

好的,勤奋的伙伴可以开始拿出纸笔,在纸上画出以上示意图内容了。云程序猿们没有纸笔那就进入幻想模式,我不会让大家不至于说没纸笔就看不下去的
对于一个正常的神经网络来说,每个点就是一个神经元,神经元里面包含了激活函数。在本文这里选定的 \(f(x)\) 激活函数的定义如下
4.png

世界上的激活函数有许许多多,没有说一定要用哪个激活函数,大家也可以在阅读完本文之后,自己尝试其他的激活函数
5.png

在本文的 BP 神经网络里面,是取所有的输入乘以各自的权重之和,进入激活函数计算,从而得到输出。我将取所有的输入乘以各自的权重之和记为 \(z_n\),将经过激活函数之后的输出记为\(y_n\) ,其示意图如下
6.png

也许大家看到这里开始有疑惑了,凭什么神经网络是这样子的。没为什么,最简单的神经网络只需要一个神经元即可,但一个神经元能做的事情就太弱了。虽然本文的图(图论的图)有5个点,但实际上 a 和 b 点都是在接收输入,实际参与计算只有三个点,相对来说已经足够简化了。在阅读完本文之后,大家也可以试试玩玩更多点和更多层的神经网络,只不过这时候就需要多写写代码啦,全靠手算可有点难哦
似乎现在这个示意图看起来就有些复杂了,但相信如果是一步步看下来的伙伴,自然能够很快理解示意图的内容了
写到这里,咱距离整个简单 BP 神经网络就只剩下 损失函数(Loss Function)的定义了。简单说明损失函数就是用来度量神经网络输出的值与预期期望值的差异程度的函数
损失函数要能反馈结果和预期的距离,且还要能够求导有意义。一听到反馈结果和预期的距离,大家可能想到的就是直接减就可以了,如这样的一个函数\(C=y_2-y_{out}\),但在后续步骤里一求导就约分没了,算出来的\(C^{'}=1\),自然就无法继续进行下去了。那求差的平方呢,如\(C=(y_2-y_{out})^2\)呢?这个就好多了,加上平方也不用去烦恼绝对值的问题了,那自然就求结果和预期的差的平方好了。试试看求导的结果 \(C^{'}=2y_2-2y_{out}\),这一求导发现约分出了2的值,那索性继续乘以二分之一好了,这样求导返回结果刚好就是简单的减法,计算机看了非常开森。于是决定出来的损失函数定义如下
7.png

看到这里相信大家也感受到了照现在的人类的数学能力还不能让大家随意写一个函数,就能获取预期的结果。在本文的手算过程中,也许大家也就能够理解神经网络的能力边界。所使用的各个函数都有一些原因,不一定是期望获取咋样的结果就能用对应的函数,还需要所使用的函数刚好从数学上能够帮助后续的计算
如此咱的所有内容就都定义完成了。为了防止大家忘记现在的状态,我再次放入当前的示意图,咱接下来就按照这个意义图开始将自己当成一个神经网络,进行手算过程
8.png

拿出纸笔的伙伴可以将上述的示意图抄在草稿纸上,咱现在就来开始第一轮的计算过程。打开了 VisualStudio 的伙伴,还请创建好控制台项目,开始记录一些代码,先是一些初始化的变量或常量的值。为了确保让大家能够校验自己的计算结果内容,我这里的代码就写固定值,而不是使用随机数,这样也好方便纸笔手算的伙伴对比计算差异
  1. const double x0 = 0.35;
  2. const double x1 = 0.9;
  3. const double y_out = 0.5;
  4. var a = x0;
  5. var b = x1;
  6. var w11 = 0.1;
  7. var w12 = 0.8;
  8. var w21 = 0.4;
  9. var w22 = 0.6;
  10. var w31 = 0.3;
  11. var w32 = 0.9;
  12. var count = 0;
复制代码
看到以上的代码,也许有伙伴有疑惑,以上代码中的 y_out 是不是不符合 C# 代码规范?我这里是借用 Latex 的表示,表示 y_out 常量,按照 Latex 写法就是 y_out 啦。因此就不要将其改成 yOut 或 YOut 哦
来进行一步步的计算过程,求 \(z_0\) 的值
9.png

对应的代码实现如下
  1. var z0 = w11 * a + w12 * b;
复制代码
这个过程看起来十分简单,就是将所有进入 c 点的输入乘以对应的权重返回的和。同理继续计算d点的\(z_1\)的值
10.png

对应的代码如下
  1. var z1 = w21 * a + w22 * b;
复制代码
分别让 \(z_0\) 和 \(z_1\) 经过 \(f(x)\) 激活函数,可分别得到 \(y_0\) 和 \(y_1\) 的值
11.png

也许大家用计算器算出来的小数点精度有些差异,但只要前面几位差不多就可以,对于神经网络来说不用太精确
对应的代码如下,封装了 F 函数
  1. double F(double x)
  2. {
  3.     return 1.0 / (1 + Math.Pow(Math.E, -x));
  4. }
  5. var y0 = F(z0);
  6. var y1 = F(z1);
复制代码
继续计算 \(z_2\) 的值
12.png

对应的代码如下
  1. var z2 = w31 * y0 + w32 * y1;
复制代码
让 \(z_2\) 经过激活函数,可得到\(y_2\)的值
13.png

对应的代码如下
  1. var y2 = F(z2);
复制代码
此时可见这一轮的输出\(y_2\)距离预期的\(y_{out}\)还有点距离。具体度量的距离有多大,那就要经过损失函数计算看
14.png

对应的代码如下
  1. var c = C(y2);
  2. static double C(double y2)
  3. {
  4.     return 1.0 / 2 * Math.Pow((y2 - y_out), 2);
  5. }
复制代码
完成了第一轮计算,接下来就是进行第一轮训练,进行 BP 的传播算法。此时在推导计算过程,就需要用到偏导的知识了。其中包括了偏导的链式计算和对函数求导的知识。借用从 https://www.cnblogs.com/awei040519/articles/18529084 博客的一张图来说明偏导在这里的意义。在这里的意义就是假定输入到输出中间经过的神经网络的某些权重变量计算之后,能够获取预期的输出内容,求解神经网络中的权重变量的值。这个求解过程有解,即是求让损失函数快速到达一个极小点。在偏导意义里面,极大和极小这些极值是相同的,从极大到极小的转换只需乘以负一即可
15.png

没有经过损失函数可不可以?可以的,但是存在的问题是,如果不用随机数随便乱碰,那怎么能够知道各个权重应该如何调,应该是朝着加号方向调还是减号方向调好呢。假定咱有无穷的计算时间,那就不停各个参数加0.00001或0或-0.00001给他全遍历即可,这样自然能够获取很(不敢说最哈)优解。但大家肯定也看出来问题,这样的做法需要大量的时间,且随着图(图论的图)上的点越多,这个计算时间就会越长,且是排列组合的快速加长,也许此时只有量子计算机才能计算了。聪明的数学家想到了利用数学上的偏导工具,协助更快速求较优解的权重数值解。如上图所示,利用偏导数协助求某一个切面上的函数导数的极值方向从而让权重的修改能够更快到达较优解,如从 https://zhuanlan.zhihu.com/p/1945144971243000845 拷贝的动图所示
16.gif

读过高数的伙伴也许有疑惑,由于偏导只是求一个切面方向上的,而整个网络是依靠多个权重一起工作的,那是否会导致画出来的函数图实际上有多个极值呢?没错,这就是局部最优解陷阱。好在本文这里足够简单,还不会踩到这个坑。进入局部最优解陷阱时,也许经过了很多轮的训练,其输出也不能达到预期,这时候就是大佬们常说的炼丹结果是废丹了,需要重新修改权重为随机数重新炼丹
求出来各个权重对应偏导数值之后,就可以更新各个权重的值,进行下一轮计算。下一轮计算结果输出之后,再经过损失函数判断是否结果已经接近了。如果不接近,则再次求各个权重对应偏导数值,用各个权重对应偏导数值更新各个权重的值,再进行下一轮计算。这样的过程就是简化后的 BP 神经网络训练的过程。其计算方式如下,以下列举的公式中带“’”的部分表示更新之后的权重的值
17.png

通过以上公式不难看出,其中的核心点就是在于如何求偏导。只要求出偏导了,那自然就有了更新权重的数值的方法了
开始求偏导准备来修改权重参数。开始之前先简单介绍链式法则的计算方式。在本文用到的偏导知识部分非常少,本文也只介绍本文需要用到的部分知识。假定有一个参数 x 计算出了 y 的过程中,经过了两个步骤,先经过了 g 函数,求出了 t 结果。再将 t 结果传入 f 函数,从而计算出 y 结果。此时求x的偏导则可用以下的公式
18.png

可以看到,此时将计算拆分为两步,先求t的偏导,再求x的偏导。如此过程恰好就可以用在神经网络上,逐层逐个求偏导,且刚好可以实现累加的过程。当然,本文为了简单起见,不会减少计算过程,没有将计算过的值缓存起来省略重新计算的过程,但相信大家看到后面一眼就看出来重复部分很简单就可以省略重复计算
如求w31的偏导,就可以从y2到z2再到w31这样一步步求偏导做乘法,如以下示意图所示
19.png

如此即可得出以下公式
20.png

这个过程里面,各个步骤都是一个简单的确定的函数,求偏导过程也就很简单了。不需要说整个一路将 w31带入到最终计算式里面。大家如果好奇真的一路将w31带入表达式里面,最终损失函数C有多长,且求导稍微困难。通过链式的方式可以进行一级级的计算,整体复杂度也能够降低
接下来咱来开始一步步求这个偏导的内容
21.png

相信大家的求导知识还没忘记,求导数学意义上就是求速率。如对于常量函数,如 y=5 的函数,其速率就是平的,如下图所示,自然求出来的导数就是 0 的值
22.png

求偏导的话,只要对求偏导的变量当成变量,其他当成常量计算。这也就是为什么求出来的式子里面 \(1/2 y_{out}^2\) 会是0的值的原因。那对于一次函数呢,如y=2x 函数呢,其导数反映的速率就刚好是变量前面的系数了。如下图所示
23.png

于是就有
24.png

那平方呢?平方的话,可以认为速率需要乘以变量自身,几倍方就是几倍速率
25.png

于是就有
26.png

回顾一下,整条式子算起来就是
27.png

是不是看到这里感觉损失函数确实不是随便选的,刚好这个函数能够非常好的适应求导的结果。求导结果里面非常方便进行计算,计算机看了非常开森,因为求导结果是两个已经计算出结果的值,而且只做减法。在第一轮训练结果里面,相信大家能够直接口算结果
28.png

看吧,没骗大家,只有过程推导用到了一点高数的知识,最终计算都是小学到高中的知识,且大部分都是小学知识
希望看到这里的大家会有很多信心,接下来开始解下一个偏导内容,这是最难的一个部分了
29.png

激活函数求导过程如下,咱选用的激活函数都是现成的,有很多大佬帮忙求导过了的函数的。如果大家感觉这个过程有点难,那也可以跳过,直接看求导结果就可以了
30.png

可以看到激活函数求导结果也是非常开森的计算,直接就是 fx·(1-fx) 的值,计算非常简单。试试代入公式
31.png

看起来这也是非常简单的小学数学就能完成计算结果,虽然本次计算过程有点难,但结果却是简单的。也依然是计算机非常开森的计算表达式,都是已知的变量的基础加减乘除计算
最后一步的求导就特别简单啦
32.png

因为只对w31求偏导,于是 w32 就可以视为常量。本身y0和y1就是被视为常量,直接 w32 乘以 y1就算出0的值。而y0作为w31的系数,求导结果就是y0了。这个过程看起来很解压。好了,各个式子就在这里求完了,将其组合起来
33.png

换成代码的话,其实现如下
  1. var dc_dw31 = (y2 - y_out) * (y2 * (1 - y2)) * y0;
复制代码
不知道大家这一步求出来的结果是否和我的接近呢
第一步的求偏导计算咱算了很久,后续的步骤咱就能快非常多了。如对w32求偏导,可以看到只有最后一条式子有所不同
34.png

代码实现如下
  1. var dc_dw32 = (y2 - y_out) * (y2 * (1 - y2)) * y1;
复制代码
从这里也可以看到对 w31 和 w32 求偏导,也只有最后一个乘数不相同而已。对于程序猿来说,看到有很多相同的代码,本能地自然就会去想优化,这是非常正确的,那就作为课后作业给到大家去优化啦
下一步尝试求w11的偏导,这一次链式过程就稍微长了一些了,如以下示意图
35.png

36.png

有示意图,相信大家很容易就知道了上面这条表达式是如何写出来的。核心关键点就是倒推,看看w11在哪一级表达式中,然后再继续往上找,直到能到达C损失函数里去。这个过程就类似于已知树(图论的树)进行求到根的路径的过程。求导数的前面两项咱已经在w32求偏导中计算过了,其结果如下
37.png

接下来的各项的求导过程如下
38.png

39.png

40.png

将几个表达式合起来,即可计算结果
41.png

表达式虽然长,但拆开看却非常清晰,如下图所示
42.png

以上的各个变量参数都是已经计算出结果的,其整个表达式最终结果也是非常简单的小学计算题。计算结果是 0.000929 的值
写成代码如下
  1. var dc_dw11 = (y2 - y_out) * (y2 * (1 - y2)) * w31 * (y0 * (1 - y0)) * a;
复制代码
同理来求 w12 的偏导,接近和 w11 相同,其差别只有最后的一步计算。如下示意图所示
43.png

可见计算公式为
44.png

对应的代码实现如下
  1. var dc_dw12 = (y2 - y_out) * (y2 * (1 - y2)) * w31 * (y0 * (1 - y0)) * b;
复制代码
同理也可以继续求w21的偏导
45.png

通过以上示意图可知
46.png

各自代入可得
47.png

对应的代码如下
  1. var dc_dw21 = (y2 - y_out) * (y2 * (1 - y2)) * w32 * (y1 * (1 - y1)) * a;
复制代码
按照以上求偏导方式,可得
48.png

对应的代码如下
  1. var dc_dw22 = (y2 - y_out) * (y2 * (1 - y2)) * w32 * (y1 * (1 - y1)) * b;
复制代码
最后将各个权重减去对应的偏导,更新之后的权重我给它上面多标了一瞥
49.png

对应的代码如下
  1. w31 = w31 - dc_dw31;
  2. w32 = w32 - dc_dw32;
  3. w11 = w11 - dc_dw11;
  4. w12 = w12 - dc_dw12;
  5. w21 = w21 - dc_dw21;
  6. w22 = w22 - dc_dw22;
复制代码
按照更新的值再跑一遍计算,可得到y2输出为 0.6820 的值,求损失函数为 0.0165 的值,可以看出比原来的第一遍计算得到的 0.0181 更小,证明更加贴近。好在我过程中顺带编写了代码,可以愉快地让计算机帮我跑个100次,跑了100次之后的各个值如下
  1. w11=0.099497286575324431
  2. w12=0.79870730833654868
  3. w21=0.35650028026826369
  4. w22=0.48814357783267731
  5. w31=-0.30047225429264057
  6. w32=0.32533602822418006
  7. y2=0.50076396515258481
  8. c=2.9182137718196697E-07
复制代码
可见看到此时输出的 y2 已经非常贴近预期的输出了。于是大家可以开森地认为咱手算训练出一个能够当输入为 x0=0.35 x1=0.9时,获得0.5输出值的简单BP神经网络啦
相信看了整个过程的大家也能感受出来,本文选用的例子中所涉及的计算是非常简单的。再回头看看总的代码,可见实现代码量是非常少的
  1. const double x0 = 0.35;
  2. const double x1 = 0.9;
  3. const double y_out = 0.5;
  4. var a = x0;
  5. var b = x1;
  6. var w11 = 0.1;
  7. var w12 = 0.8;
  8. var w21 = 0.4;
  9. var w22 = 0.6;
  10. var w31 = 0.3;
  11. var w32 = 0.9;
  12. var count = 0;while (true){    var z0 = w11 * a + w12 * b;    var z1 = w21 * a + w22 * b;    var y0 = F(z0);    var y1 = F(z1);    var z2 = w31 * y0 + w32 * y1;    var y2 = F(z2);    var c = C(y2);    if (c < 0.0000001)    {        break;    }    var dc_dw31 = (y2 - y_out) * (y2 * (1 - y2)) * y0;    var dc_dw32 = (y2 - y_out) * (y2 * (1 - y2)) * y1;    var dc_dw11 = (y2 - y_out) * (y2 * (1 - y2)) * w31 * (y0 * (1 - y0)) * a;    var dc_dw12 = (y2 - y_out) * (y2 * (1 - y2)) * w31 * (y0 * (1 - y0)) * b;    var dc_dw21 = (y2 - y_out) * (y2 * (1 - y2)) * w32 * (y1 * (1 - y1)) * a;    var dc_dw22 = (y2 - y_out) * (y2 * (1 - y2)) * w32 * (y1 * (1 - y1)) * b;    w31 = w31 - dc_dw31;    w32 = w32 - dc_dw32;    w11 = w11 - dc_dw11;    w12 = w12 - dc_dw12;    w21 = w21 - dc_dw21;    w22 = w22 - dc_dw22;    count++;}Console.WriteLine("Hello, World!");double F(double x){    return 1.0 / (1 + Math.Pow(Math.E, -x));}static double C(double y2){    return 1.0 / 2 * Math.Pow((y2 - y_out), 2);}
复制代码
也许此时有人会说,明明我看到的py代码量更少,为什么换成C#的代码量就这么多了?别急,大家试试看回忆一下上文布置的课后作业。之所以网上看到的很多py实现的版本的代码量少,是因为使用了本文没有付出场费的矩阵来帮忙计算,许多重复的计算逻辑被封装起来了。试试看给以上的代码进行优化,将重复的计算进行合并,将重复的结构进行抽象,很好,此时相信大家一和py代码进行对比就会发现接近相同啦
本文代码放在 github 和 gitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快
先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码
  1. git init
  2. git remote add origin https://gitee.com/lindexi/lindexi_gd.git
  3. git pull origin 3621fbea66658c99f11ed4faa28aa60bb5ac4466
复制代码
以上使用的是国内的 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码
  1. git remote remove origin
  2. git remote add origin https://github.com/lindexi/lindexi_gd.git
  3. git pull origin 3621fbea66658c99f11ed4faa28aa60bb5ac4466
复制代码
获取代码之后,进入 Bp/DewhigarjejelDaykogiqem 文件夹,即可获取到源代码
更多技术博客,请参阅 博客导航

来源:豆瓜网用户自行投稿发布,如果侵权,请联系站长删除

相关推荐

您需要登录后才可以回帖 登录 | 立即注册