程序员的AI书:从代码开始
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.4 单层神经网络的Python实现

机器学习算法随着硬件算力的提升而演化。早期的机器学习以神经元为切入点,产生了简单的参数调整和拟合算法。然而要模拟较为复杂的计算,显然不是单个神经元能做到的。我们也看到,神经元的提出是为了模拟人类大脑的神经突触工作模式,那么在理解单个神经元的工作原理后,下一步自然就是模拟多个神经元了,即神经网络的算法。

2.4.1 从神经元到神经网络

前面实现的都是基于图2-1的感知器结构,利用不同的算法来实现对权重的训练,重点是如何用梯度下降来修正权重,达到不断逼近最优解的目的。

但并不能说这是一个真正的神经网络。一般来说,神经网络包含输入层、隐藏层和输出层,而前面讲解的内容,只是隐藏层中一个神经元的权重调整方法而已。

如图2-4所示,其中的左图是图2-1所示的感知器神经元,右图是一个完整的单层神经网络。神经元实际上只是神经网络中的一小部分而已(用粗线标识)。神经网络的输出不再由单个神经元决定,而由多个神经元的输出共同决定。

图2-4 从神经元到神经网络

在图2-4中只设计了一组神经元,我们把这组神经元称为神经网络的隐藏层(Hidden Layer)。早期的神经网络基本上只包括一个隐藏层,少数包括多个隐藏层,如多层感知器(Multilayer Perceptron,MLP),如图2-5所示。

图2-5 多层感知器,包括多个隐藏层

如果我们在图2-5的基础上再增加层数,则这样形成的网络往往被称为深度网络(Deep Network),这也是深度学习的由来。我们通常把隐藏层的数量称为网络的深度(Depth),把每一层的神经元个数称为宽度(Width)。于是我们在研究中有一个有趣的问题:对于同样数量的神经元,是使用更大的宽度、减少深度,还是增加深度、减少宽度?如何达到最佳平衡?这是机器学习中仍然引人深思的问题,目前并没有标准答案。在实际应用中,我们更多地结合网络的宽度和深度,通过实验达到最佳效果,如在Wide & Deep Learning for Recommendation Systems[3]一文中所提及的方式。

2.4.2 单层神经网络:初始化

和单个神经元的训练相比,神经网络确实要复杂许多,但实际上也只是计算次数和参数变得更多,其原理和训练方式实质上是一样的。我们下面亲手实现一个类似图2-4的单层神经网络,看看在引入隐藏层之后,到底是如何进行训练和预测的。

首先,定义问题。这次不再对正负数进行分类,而是对平面坐标点进行分类,如图2-6所示。

图2-6 平面坐标点分类

在图2-6中,斜线y=x将平面坐标点分为两类,线上方为0,线下方为1。我们希望设计一个神经网络来对任意坐标点(x,y)进行自动分类。仿照图2-4,这里的输入层包括xy两个输入,输出则包括0和1这两个类别,因此网络结构如图2-7所示。

图2-7 待实现的单层神经网络,其中隐藏层的神经元个数可设置

可以看到,图2-7中神经网络的输出是由隐藏层的多个神经元的输出确定的。同时,因为我们的输出不再只依靠一个神经元(虽然可以设置隐藏层只用一个神经元),因此除了隐藏层中每个神经元对应的输入权重都需要计算(Whidden),输出层中的每个输出对于每个隐藏层的神经元的输入也有对应的权重需要计算(Woutput)。

下面看看具体如何实现以上网络。

因为该分类的数据特点比较明显,所以我们可以先手工创建两组数据分别用于训练与测试:

然后定义输入和输出的个数。因为输入包括xy两个坐标,输出包括1和0两个类别,因此各自都为2。

接着我们就要开始定义神经网络了,可以通过创建一个initialize_network函数来实现:

在上面的代码中,我们将网络模型定义为一个包含两个数组的list,两个数组分别对应图2-7中的两组权重:WhiddenWoutput

对于隐藏层的权重,参考图2-7,我们可以看到隐藏层的每个神经元(总数由n_hidden定义)都接收了所有输入,其数量为n_inputs+1个(其中比输入多出来的一项为bias,也可将其理解为大多数线性回归所定义的中的。这里一个全连接层(每个输出都和所有输入直接相关)的权重参数总数为(n_inputs+1)·n_hidden。

同样,对output的权重采用同样的处理,注意,output层的权重参数总数是(n_hidden+1)·n_output。

2.4.3 单层神经网络:核心概念

我们接下来开始训练网络。怎么训练呢?其实和前面的流程非常相似。在前面的两段代码示例simple_perceptron和linear_regression中,因为代码过于简单,所以在训练过程中没有明确分离出一些概念的实现。例如在simple_perceptron代码示例中并没有定义损失函数和更新权重的单独方法,而是单独实现了net_input和predict(实际上相当于激活函数)。而在linear_regression的代码中强调了损失函数和更新权重update_weight的概念,用于解释梯度下降优化,却没有提及网络输入和激活函数。

所以实际上,对于任何模型训练,其关键都是实现如下4个核心函数。

◎ net_input:计算神经元的网络输入。

◎ activation:激活函数,将神经元的网络输入映射到下一层的输入空间。

◎ cost_function:计算误差损失。

◎ update_weights:更新权重。

这里还需要引入两个概念:前向传播(Forward Propagation)和反向传播(Back Propagation)。

◎ 前向传播:指将数据输入神经网络中,每个隐藏层的神经元都接收网络输入,通过激活函数进行处理,然后进入下一层或者输出的过程。在前面linear_regression的例子中,predict方法实际上就是一个简单的前向传播方法。

◎ 反向传播:指对网络中的所有权重都计算损失函数的梯度,这个梯度会在优化算法中用来更新权值以最小化损失函数。实际上,它指代所有基于梯度下降利用链式法则(Chain Rule)来训练神经网络的算法,以帮助实现可递归循环的形式来有效地计算每一层的权重更新,直到获得期望的效果。在前面的linear_regression例子中,我们把反向传播计算梯度的内容和更新权重放在了一起。

了解了以上概念,可以知道实际上我们在前面已经实现过相关内容,只是没有清晰地对每个环节都进行模块化。而这里因为不再是单个神经元,所以计算环节更加复杂,对每个关键环节都进行模块化是非常必要的。我们来看看每一步是怎么实现的。

2.4.4 单层神经网络:前向传播

首先是每个神经元的网络输入:

注意,weights实际上包含了一个类似bias的额外参数,即weights的个数比输入(inputs)要多一个。因此我们首先使用weights[-1]对total_input赋值,然后添加每个输入和对应权重的乘积,其形式为total_input = weights[:-2]·inputs + weights[-1]。

然后是激活函数activation:

这里使用了sigmoid激活函数,用于将网络输入映射到(-1,1)区间。激活函数有多种形式和算法,在第3章会做详细解释,这里不再赘述,只需把它当作一个区间映射的函数即可。

定义完上述两个函数后,我们便可以定义前向传播的实现:

可以看到,前向传播其实就是对于每一层,都把上一层的输出作为下一层的输入,进行循环计算。因为这是全连接网络,所以每个神经元都接收所有inputs,进行相同的net_input处理,将获得的total_input结果再输入激活函数activation中,获得该神经元的最终结果,然后把结果添加到该层的输出中。我们把当前层的输出(outputs)作为下一层的输入(inputs),持续迭代下去,直到最后把输出返回。

2.4.5 单层神经网络:反向传播

可以看到,每个神经元所进行的计算过程都是一样的,唯一影响结果的就是其中的权重参数(neuron[′weights′])。下面就要进行反向传播和权重更新的实现,帮助每个神经元都调整自己的相关参数。

这里和前面最大的差别在于,激活函数不再是直接的线性方程,而是使用了sigmoid激活函数,那么我们在计算梯度变化时的求导就需要有所变化。简单看一下sigmoid激活函数的形式和求导结果:

结合forward_propagation函数的实现,我们可以看到,这里中的输入z其实就是前一层的输出(每一层的输入都是上一层的输出)。

在本章第1个简单神经元simple_perceptron及后面的linear_regression实现中,我们看到对权重的调整是这样的:

当感知器只有一个神经元时,对权重的调整很简单:

对于没有隐藏层的单个神经元感知器来说,是有明确的结果(y)和预测结果()的,误差结果由cost_function(MSE)确定。根据前面Linear Regression中的推导,在将MSE对w求导后,可得到,因此可以进一步概括为

那么问题就变为如何计算

回到要在图2-7中实现的单层神经网络,对于其中的输出层,其处理方式和单神经元感知器类似,因为输出的也是最终预测值y’,用MSE作为损失函数计算即可。注意,输出层的输入并不是原始输入值xy,而是上一层(隐藏层)的输出。而对于隐藏层来说,我们并不能直接计算它的输出误差,因为并不存在输出层的真实值y,只能通过链式法则来间接推导。换句话说,假设隐藏层有一个权重w,我们希望对其进行修正,那么只能从最后输出的损失函数计算出的误差倒推(反向传播),如图2-8所示。

图2-8 链式法则示意图

根据图2-8的示意,如果要计算最终output_total的误差和w1的关系,则可以得到这样一个公式:

其中:

这样最终可以得到:

隐藏层可以继续迭代下去,例如对图2-8中的,可用类似的做法。我们记

实际上,我们没有必要单独计算每个,只需沿用前面的运算结果,再和当前神经元的属性相乘即可。那么我们在循环迭代时,只需保存就可以大幅度提高运算效率,不必从头计算。这就是反向传播的核心要点。

这样就理顺了反向传播中更新权重的全过程,下面看看它是怎么具体实现的。

同样,首先定义cost_function函数:

需要指出一点:在反向传播计算中,cost_function函数并不是必需的,根据cost_function函数进行求导才是必需的。但清晰地定义cost_function函数有助于我们理解整个实现。

然后是sigmoid激活函数的导数实现:

二者完成后,便可以完成最重要的反向传播实现。先来看看下面的实现:

让我们看看以上代码都做了什么。

第1行:引入两个参数,一个是需要更新的网络模型network(记住,network实际上是一个list,每个item都是一组参数,其中包含了权重和其他属性);另一个是期望值expected,包含结果的真实分类。

第2~4行:从网络的最后一层(输出层)开始计算,获得当前层(layer)并设置变量errors为一个list,errors将存储当前层中每个神经元的预测值的误差。注意,这个误差是从输出层开始不断迭代累积形成的,而不是和真实目标值y的绝对误差。

第6行:做了一个判断,对输出层和隐藏层做了不同的处理。

第7~10行:对最后一层(输出层)进行处理。对输出层中的每个神经元(neuron),根据前面定义的公式,将第1部分存入errors中。

第18~20行:在这3行里,如果当前是输出层,则在当前层的神经元中存储了,其中,是sigmoid激活函数对输入值的导数。然后进入倒数第2层(隐藏层)。这样,逻辑过程就很简明了,实际上在每个神经元的delta属性中都会存储前面所计算的

再回到第12~16行,根据前面推导的公式,我们在这里计算的是前一层的,注意,这里需要将前一层和当前神经元相关的所有输出误差全部累加(因为这是全连接网络,所以意味着当前层的每个神经元的输出都会作用于下一层的每个神经元的输入)。

于是,当再次进入第18~20行时,隐藏层乘上了对应sigmoid的导数,这时在neuron[′delta′]中最后存储的是要获得最终的,则只需最后再乘以当前神经元的inputs即可(即前一层的output属性),我们将在更新权重时实现这最后一步,请看下面的代码:

在update_weights函数中,我们看到,首先仍然是在第2行遍历网络的所有层。和前面back_propagate函数不一样的是,这里不是倒序遍历,而是顺序遍历(因为初始输入值在第1层)。

另外,update_weight函数需要在back_propagate函数之后调用。因为在back_propagate函数中,我们在每一层所有神经元的delta属性里都存储了,需要乘以该神经元的net_inputs(即前一层的output属性所存数值,上一部分已经做了详尽推导,这里不再赘述)。

因此在第3~5行中,初始化inputs为输入数据row(实际上是一组训练数据),如果不是输入层(即第1层输入),则将输入换为前一组的输出。

从第6行开始,对该层的所有神经元都进行遍历,对该神经元的每一个输入inputs[j]所对应的权重neuron[′weights′][j]都减去,其中,为在back_propagate函数中计算的neuron[′delta′]·inputs[j],于是就有了第8行的计算:

最后,我们对额外的权重参数bias进行处理,因为其输入被设定为1,所以在第10行设定最后一个权重为learning_rate·neuron['delta']。

2.4.6 网络训练及调整

现在,我们已经定义了所有核心的反向传播权重调整中所需要的函数,可以来实现具体的训练代码了:

在定义好前面的函数后,真正的模型训练只有短短12行,而且浅显易懂,如下所述。

第1行:在训练函数的定义中,我们需要指定网络模型对象、训练数据、学习率、训练次数和输出类型的数量。

第2行:根据给定的训练次数n_epoch进行循环训练。

第3行:sum_error是当前训练周期(每个周期都使用全部训练集来训练)的误差,这实际上对训练本身没有影响,只是检查一下损失(Loss)。

第4行:遍历所有训练数据,取其中一组开始训练。

第5行:进行前馈计算(前向传播),获得最终输出。注意,这个输出包括在n_outputs中定义的类别个数,对每个类别都生成一个概率。在本例中输出的是一个长度为2的一维数组,代表0、1两个分类。这还不算是最终的预测结果,需要在这两个分类中选择概率最大的一个作为预测结果。

第6~7行:做了一个小的技巧性实现,我们需要对每组输入数据都创建对应的期望输出(expected)。第6行首先对期望输出置0;第7行根据输入数据的最后一个数值(也就是ground truth标签,表示具体是哪个类别)将期望输出中的对应位置置1。

第8行:通过cost_function计算误差。

第9~10行:首先调用back_propagate设置每个神经元的delta属性,再通过update_weights调整权重。

第11~12行:按照习惯,我们在每个训练周期结束时都需要显示一些必要的参数,供查看进度。

那么模型到底是怎么预测的呢?其实和前面的forward_propagate类似,只是最后要把概率最大的分类选出来,其实现如下:

最后,可以运行其代码:

在上面的代码中首先调用了initialize_network对网络模型进行初始化,这里对隐藏层只设了一个神经元;然后调用train_network训练网络模型;在训练完成后,我们用测试数据test_data中的每一组进行验证,调用predict函数并把预测结果和测试数据的标签列进行对比。

因为network权重的初始值是随机的,所以我们运行3次代码看看结果:

可以看到,前两次均有一组数据预测错误,最后一组数据预测全部正确。怎么提高正确率呢?我们首先可以尝试增加神经网络的宽度,在原有的仅有一个神经元的基础上再加一个,变为两个神经元,也就是在调用initialize_network时,将n_hidden参数设为2:

这时再连续运行三次,可以看到三次的测试结果都完全正确(结果完全一致,这里省略运行结果展示)。

另一种思路是增加深度,在原有的单层隐藏层上再加一层,这涉及initialize_network的改动,我们来试试:

在上面新改动的initialize_network中,我们把之前的单个hidden_layer改为了hidden_layer1和hidden_layer2,这两个新的隐藏层的神经元个数一致,都由n_hidden指定。

我们保持n_hidden=1,运行后发现,增加层数后的预测效果反而不如以前:

为什么增加深度后反而效果不好呢?增加深度后,训练的参数个数和梯度迭代的次数也增加了,是不是需要更多的训练和调整呢?我们尝试把n_epoch的次数从20提高到200后看看:

可以看到,训练次数从20提高到200后效果略好,那么我们再提高到2000呢?

训练次数提高到2000后,我们发现测试正确率为100%(不再重复展示结果)。

因此我们看到,对于本章例子中的简单数字分类,增加网络深度虽然也能提高预测准确率,但同时对计算能力的要求大幅度增加。相对而言,保持一个隐藏层,简单增加神经元的做法见效更快,而且避免过多增加计算能力需求。

在很长一段时间里,机器学习都停留在强调宽度、增加神经元阶段。关于增加深度,没有太多考虑,向深度神经网络(Deep Neural Network,DNN)方向发展即可。这是因为算法本身没有找到合适的突破场景,没有找到深度神经网络能够真正发挥作用的地方;另外,硬件和软件都没有提供足够的计算能力来满足深度神经网络在实践中的需要。

但随着卷积神经网络(Convolutional Neural Network,CNN)在图像分类上的突破,基于深度学习(Deep Learning)的深度神经网络已经成为当前的主流方案,因此相应诞生了各种开发框架,充分发挥硬件和软件的作用可帮助深度神经网络的构建、训练和应用。第3章将介绍Keras开发框架,方便大家系统了解深度神经网络开发框架的基本使用方法,为后续进行推荐系统、自然语言处理、图像识别等方面的学习和应用做好准备。