超酷的反向传播算法

人工智能


  目前最火的技术莫过于人工智能,或者说机器学习。从IBM Watson到Google AlphaGo,人工智能仿佛已经冲出了实验室,在实际生活中发挥作用。面对如此高大上的技术,普通老百姓要如何去看待它,理解它呢?

  首先要知道机器学习的本质是算法,这里就会有好几种,比如:神经网络、支持向量机、朴素贝叶斯等等一堆。虽然算法很多,但他们主要解决的都是同一个问题—分类预测问题。为什么分类预测问题这么重要?因为机器学习的本质是重现人类学习的过程,这个过程可以大致分为两部分:1.定义一个事物,2.判断一个事物。如果一个机器可以对一个事物进行判断,判断的结果与人类的判断相似,那它近似的就是一个人工智能。

  假设这样一个场景,我和机器都看到了一个苹果,按照之前对机器的训练如果他告诉我他看到了一个苹果,那说明这个机器是具有一定人工智能的,如果它还能告诉我这个苹果的产地,成熟度,净重,品种、颜色、气味特征等等….我也不会惊讶,因为这是描述一个苹果的特征,也是机器定义这是一个苹果的依据。你看,这里就出现了一个人类与机器的差异,我们学习一个事物并不需要太多维度的特征来描述一个事物,比如,在某某地区生长的、重量在这个范围的、颜色可能是这种的、可能有这些形状的、…(此处省略无限字,因为可以从无限个维度去描述一个苹果),OK,这个东西叫苹果!我们只要摸过吃过看过,大概就知道啥是苹果,也不会和梨搞错,为什么!因为我们聪明,没错,我们的大脑做了定义和判断的工作并且是在我们无意识的情况下。从这个角度上来说,其实研究机器学习的本质其实是研究人类自己认识这个世界的过程。

神经网络


  本篇文章的主题就是人工神经网络中的反向传播算法(Back Propagation Algorithm,BP算法)。反向传播算法是实现人工神经网络(Neural Networks,NNs)中非常重要的技术,就是它让神经网络变的“智能”,本文将会利用最简单的NNs模型来模拟整个反向传播算法,并同时使用JavaScript来实现整个过程,文章的最后会提供该例程序。这里需要声明一点,这个程序只是为了演示算法。好了,开始吧。

  首先神经网络的模型是这个样子的,这是一个简化到不能在简化的神经网络结构,图中的球模拟了神经元的细胞,线模拟了神经元的突触,简而言之它在用数学模型模拟我们的大脑:

  在左侧有i1,i2,表示输入层;最右侧有o1,o2,表示输出层。当我们在训练机器学习的时候,会把输入值和输出值都设定好,好比说,i1=0.15,i2=0.10;计算结果应该是o1=0.01,o2=0.99,如果训练是成功的,那当我输入相同的输入值时,结果应该也是相同的。是不是有点像我们在构造一个函数,而这个函数的计算过程我们并不能看到,这也是为什么很多人说神经网络是一个黑盒模型。我们需要在中间加入一层来描述其中转换的过程,由于是不可见的,这层叫隐含层h1,h2。现在我们需要初始化节点之间的连线,为这些连线加上随机的权值。这些初始化的权值会在之后的计算中被更新,事实上这些权值就是描述这个机器思考的模型。在计算的过程中,我们还会用到b1,b2,这称为偏置项,值永远是1,权值可以自由设置,这里我们设b1权值为0.35,b2权值为0.60。OK,现在这个模型变成了这样:

  好了,一切就绪,我们要开始算了!怎么算呢?整个算法的过程分为3个部分:前向传播、计算误差、反向传播,可以理解为我先试一下现在判断,与预期的判断做个比较,然后修正我的判断,下面就每个步骤详细说明一下。

前向传播

  首先我们会从模型的左侧计算到右侧,这个方向称为前向。这一步可以分为2步,第1步是简单的加权相加或者叫做线性回归,第2步是代入一个激活函数,激活函数的作用是将线性函数表达为非线性函数,它会把值挤压进一个(0,1)区间的范围作为规范化处理,同时还可以反应出对象的条件概率,激活函数使用sigmoid函数,exp函数表示了以e为底的指数函数:


1
2
3
4
//激活函数
this.sigmoid=function(z) {
return 1 / (1 + Math.exp(-z));
}

  第1步,计算线性回归:


  第2步,激活:


  现在我们就计算出了h1节点的值,用相同的方法,我们计算出h2,o1,o2节点:






1
2
3
4
5
6
7
8
9
10
11
//前向传播
this.forward=function() {
//隐含层
for (var x = 0; x < i.length; x++) {
h[x] = sigmoid(i[0] * w[0][x * v] + i[1] * w[0][x * v + 1] + b[0]);
}
//输出层
for (var y = 0; y < i.length; y++) {
o[y] = sigmoid(h[0] * w[1][y * v] + h[1] * w[1][y * v + 1] + b[1]);
}
}

  可以看到计算的结果和我们设定的结果(0.01,0.99)有很大的误差,这很大程度上是由于权值初始化的时候,接下来我们需要减小这个误差。

计算总误差


  误差计算通过平方误差函数为每个节点计算误差,然后将这些误差相加计算总误差:


1
2
3
4
5
6
7
//计算总误差
this.totalError=function() {
for (var x = 0; x < t.length; x++) {
e[x] = squareErr(t[x], o[x]);
te += e[x];
}
}

  我们分别计算o1,o2的误差值,并将他们相加:





反向传播


  现在我们从模型的右侧开始向左侧计算,目标是要使得总误差值变小。我们首先计算w5这一路,这里需要一个方法来计算w5对总误差的影响,刚好导数的意义是描述参数变化对函数造成影响的变化率,或者叫斜率。所以我们想知道w5对总误差带来的变化率可以通过求w5的偏导来计算。然而单纯去算是算不出的,要使用连式法则来分解计算步骤(宏观上看这些中间变量都可以被约分约掉),下面我们就将这个问题分为等式右侧的3部分去求解:




  第1部分,o1的输出对于总误差的影响,由于我们不关心o2的输出,所以整个右侧可以计算为0,然后利用求导公式就可以算出:




  代入之前的数据可以求解:




  第2部分,这里其实就是对于激活函数来说,o1输出对它的影响,由于激活函数是sigmoid,其求导公式推导如下:




  所以我们可以得到第二部分的求解:




  第3部分,w5对于线性方程的影响,其求导结果就是w5的斜率:




  现在!该有的都有了,现在我们知道w5对于总误差的影响有多大了:




  我们现在要对w5这项权值做出调整,调整的依据是刚刚算出的误差值,通过加权的方式去调整,我们需要设定一个学习率来衡量误差对于调整过程的比例,本例中设为0.5:




  神奇的事情发生了,可以看到w5的权值由原来的0.3调整为0.25772292463736585,从调整的幅度上来看好像还挺有道理的,我们用相同的方法把所有的权值都调整一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//反向传播第一层
this.backward1 =function() {
for (var y = 0; y < t.length; y++) {
for (var x = 0; x < h.length; x++) {
var selfEffect = -1 * (t[y] - o[y]) * o[y] * (1 - o[y]) * h[x];
nW[1][x + y * v] = w[1][x + y * v] - lr * selfEffect;
console.info('w' + parseInt(5 + x + y * v) + "权重变化: " + w[1][x + y * v] + " => " + nW[1][x + y * v]);
}
}
}
---------------
w5权重变化: 0.3 => 0.25772292463736585
w6权重变化: 0.35 => 0.3075091952101869
w7权重变化: 0.4 => 0.413242963882813
w8权重变化: 0.45 => 0.46330991295770874

  OK,到目前为止,我们已经成功一半了,接下来需要更新w1~w4的权值,以w1为例,我们需要算出w1对于总误差的影响,依然通过链式法则求偏导:




  这里有一个情况出现了,我们的算式里第一项是描述h1节点对于总误差的影响,如何描述这个影响,直接求求不出啊?冷静,思路依然是将问题细分,我们可以看到模型中这个h1节点可以影响o1,也可以影响o2,所以这个过程可以看做h1对o1,o2的影响之和,下面我们开始计算:




  第1部分,这一部分其实又可以分为2个小部分,我们以计算o1例。在计算的时候有一个技巧,o1输出对于E0的影响其实就等于o1输出对于E_total的影响,所以可以用之前算过的值直接代入;由于out_o1是线性方程,h1对于out_o1的影响就等于其斜率w5:




  代入之前求得的值就可以求解第1部分:






  第2部分,就是对sigmoid函数求导,代入可以求解:




  第3部分,是对线性函数求导,求解:




  大功告成,我们将3部分数据相乘,并加上学习率,最终求解:





  我们将其余的权重都求解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//反向传播第二层
this.backward2 = function() {
var f_1 = [];
var f_2 = 0;
var f_3 = 0;
var _f_1 = -1 * (t[0] - o[0]) * o[0] * (1 - o[0]) * w[1][0];
_f_1 += -1 * (t[1] - o[1]) * o[1] * (1 - o[1]) * w[1][2];
f_1.push(_f_1);
_f_1 = -1 * (t[0] - o[0]) * o[0] * (1 - o[0]) * w[1][1];
_f_1 += -1 * (t[1] - o[1]) * o[1] * (1 - o[1]) * w[1][3];
f_1.push(_f_1);
for (var y = 0; y < h.length; y++) {
f_2 = h[y] * (1 - h[y]);
for (var z = 0; z < i.length; z++) {
f_3 = i[z];
nW[0][y + z * v] = w[0][y + z * v] - lr * (f_1[y] * f_2 * f_3);
console.info('w' + parseInt(y + z * v) + "权重变化: " + w[1][y + z * v] + " => " + nW[1][y + z * v]);
}
}
w = nW;
};
--------------------
w0权重变化: 0.3 => 0.25772292463736585
w2权重变化: 0.4 => 0.413242963882813
w1权重变化: 0.35 => 0.3075091952101869
w3权重变化: 0.45 => 0.46330991295770874

  目前为止,我们已经完成了训练,接下来我们来验证一下训练的成果。仍然将[0.15,0.1]作为输入层输入,我们看到通过新的权重计算后得到的结果是:

1
预测值变化:0.7286638276265998,0.751601224586807 => 0.7185477013468267,0.7545430129300831

  可以观察到o1越来越接近0.01,o2越来越接近0.99,OK,我们来循环10000次,观察训练后的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var test = new Neural();
var final_o=[];
for (var i = 0; i < 10000; i++) {
test.forward();
var old_o=[].concat(test.o);
test.totalError();
test.backward1();
test.backward2();
test.forward();
final_o=[].concat(test.o);
}
console.info('最终预测值:'+final_o);
-----------------------
最终预测值:0.015360963767399535,0.9845412722122693

  最终结果已经非常接近我们的预期值。但是这里如果一味的增加训练次数会产生边际效应,最终的预测值会越来越慢的接近目标值。

结语


  最终我们模拟了最为简单的神经网络结构,可以想像如果增加输入层维度,并且增加隐含层层数,模型的拟合度会越来越高,计算量也会指数倍增长。
  本例代码:https://github.com/yuri147/hexo/blob/master/source/js/bp/bpdemo.js

0%