1.3 神经网络的学习
不进行神经网络的学习,就做不到“好的推理”。因此,常规的流程是,首先进行学习,然后再利用学习好的参数进行推理。所谓推理,就是对上一节介绍的多类别分类等问题给出回答的任务。而神经网络的学习的任务是寻找最优参数。本节我们就来研究神经网络的学习。
1.3.1 损失函数
在神经网络的学习中,为了知道学习进行得如何,需要一个指标。这个指标通常称为损失(loss)。损失指示学习阶段中某个时间点的神经网络的性能。基于监督数据(学习阶段获得的正确解数据)和神经网络的预测结果,将模型的恶劣程度作为标量(单一数值)计算出来,得到的就是损失。
计算神经网络的损失要使用损失函数(loss function)。进行多类别分类的神经网络通常使用交叉熵误差(cross entropy error)作为损失函数。此时,交叉熵误差由神经网络输出的各类别的概率和监督标签求得。
现在,我们来求一下之前一直在研究的那个神经网络的损失。这里,我们将Softmax层和Cross Entropy Error层新添加到网络中。用Softmax层求Softmax函数的值,用Cross Entropy Error层求交叉熵误差。如果基于“层视角”来绘制此时的网络结构,则如图1-12所示。
图1-12 使用了损失函数的神经网络的层结构
在图1-12中,X是输入数据,t是监督标签,L是损失。此时,Softmax层的输出是概率,该概率和监督标签被输入Cross Entropy Error层。
下面,我们来介绍一下Softmax函数和交叉熵误差。首先,Softmax函数可由下式表示:
式(1.6)是当输出总共有n个时,计算第k个输出yk时的算式。这个yk是对应于第k个类别的Softmax函数的输出。如式(1.6)所示,Softmax函数的分子是得分sk的指数函数,分母是所有输入信号的指数函数的和。
Softmax函数输出的各个元素是0.0~1.0的实数。另外,如果将这些元素全部加起来,则和为1。因此,Softmax的输出可以解释为概率。之后,这个概率会被输入交叉熵误差。此时,交叉熵误差可由下式表示:
这里,tk是对应于第k个类别的监督标签。log是以纳皮尔数e为底的对数(严格地说,应该记为loge)。监督标签以one-hot向量的形式表示,比如t=(0, 0, 1)。
one-hot向量是一个元素为1,其他元素为0的向量。因为元素1对应正确解的类,所以式(1.7)实际上只是在计算正确解标签为1的元素所对应的输出的自然对数(log)。
另外,在考虑了mini-batch处理的情况下,交叉熵误差可以由下式表示:
这里假设数据有N笔,tnk表示第n笔数据的第k维元素的值,ynk表示神经网络的输出,tnk表示监督标签。
式(1.8)看上去有些复杂,其实只是将表示单笔数据的损失函数的式(1.7)扩展到了N笔数据的情况。用式(1.8)除以N,可以求单笔数据的平均损失。通过这样的平均化,无论mini-batch的大小如何,都始终可以获得一致的指标。
本书将计算Softmax函数和交叉熵误差的层实现为Softmax with Loss层(通过整合这两个层,反向传播的计算会变简单)。因此,学习阶段的神经网络具有如图1-13所示的层结构。
图1-13 使用Softmax with Loss层输出损失
如图1-13所示,本书使用Softmax with Loss层。这里我们省略对其实现的说明,代码在common/layers.py中,感兴趣的读者可以参考。此外,前作《深度学习入门:基于Python的理论与实现》的4.2节中也详细介绍了Softmax with Loss层。
1.3.2 导数和梯度
神经网络的学习的目标是找到损失尽可能小的参数。此时,导数和梯度非常重要。这里我们来简单说明一下导数和梯度。
现在,假设有一个函数y=f(x)。此时,y关于x的导数记为。这个的意思是变化程度,具体来说,就是x的微小(严格地讲,“微小”为无限小)变化会导致y发生多大程度的变化。
比如函数y=x2,其导数可以解析性地求出,即。这个导数结果表示x在各处的变化程度。实际上,如图1-14所示,它相当于函数的斜率。
图1-14 y=x2的导数表示x在各处的斜率
在图1-14中,我们求了关于x这一个变量的导数,其实同样可以求关于多个变量(多变量)的导数。假设有函数L=f(x),其中L是标量,x是向量。此时,L关于xi(x的第i个元素)的导数可以写成。另外,也可以求关于向量的其他元素的导数,我们将其整理如下:
像这样,将关于向量各个元素的导数罗列到一起,就得到了梯度(gradient)。
另外,矩阵也可以像向量一样求梯度。假设W是一个m×n的矩阵,则函数L=g(W)的梯度如下所示:
如式(1.10)所示,L关于W的梯度可以写成矩阵(准确地说,矩阵的梯度的定义如上所示)。这里的重点是,W和具有相同的形状。利用“矩阵和其梯度具有相同形状”这一性质,可以轻松地进行参数的更新和链式法则(后述)的实现。
严格地说,本书使用的“梯度”一词与数学中的“梯度”是不同的。数学中的梯度仅限于关于向量的导数。而在深度学习领域,一般也会定义关于矩阵和张量的导数,称为“梯度”。
1.3.3 链式法则
学习阶段的神经网络在给定学习数据后会输出损失。这里我们想得到的是损失关于各个参数的梯度。只要得到了它们的梯度,就可以使用这些梯度进行参数更新。那么,神经网络的梯度怎么求呢?这就轮到误差反向传播法出场了。
理解误差反向传播法的关键是链式法则。链式法则是复合函数的求导法则,其中复合函数是由多个函数构成的函数。
现在,我们来学习链式法则。这里考虑y=f(x)和z=g(y)这两个函数。如z=g(f(x))所示,最终的输出z由两个函数计算而来。此时,z关于x的导数可以按下式求得:
如式(1.11)所示,z关于x的导数由y=f(x)的导数和z=g(y)的导数之积求得,这就是链式法则。链式法则的重要之处在于,无论我们要处理的函数有多复杂(无论复合了多少个函数),都可以根据它们各自的导数来求复合函数的导数。也就是说,只要能够计算各个函数的局部的导数,就能基于它们的积计算最终的整体的导数。
可以认为神经网络是由多个函数复合而成的。误差反向传播法会充分利用链式法则来求关于多个函数(神经网络)的梯度。
1.3.4 计算图
下面,我们将研究误差反向传播法。不过在此之前,作为准备工作,我们先来介绍一下计算图的相关内容。计算图是计算过程的图形表示。图1-15所示为计算图的一个例子。
图1-15 z=x+y的计算图
如图1-15所示,计算图通过节点和箭头来表示。这里,“+”表示加法,变量x和y写在各自的箭头上。像这样,在计算图中,用节点表示计算,处理结果有序(本例中是从左到右)流动。这就是计算图的正向传播。
使用计算图,可以直观地把握计算过程。另外,这样也可以直观地求梯度。这里重要的是,梯度沿与正向传播相反的方向传播,这个反方向的传播称为反向传播。
这里我想先说明一下反向传播的全貌。虽然我们处理的是z=x+y这一计算,但是在该计算的前后,还存在其他的“某种计算”(图1-16)。另外,假设最终输出的是标量L(在神经网络的学习阶段,计算图的最终输出是损失,它是一个标量)。
图1-16 加法节点构成“复杂计算”的一部分
我们的目标是求L关于各个变量的导数(梯度)。这样一来,计算图的反向传播就可以绘制成图1-17。
图1-17 计算图的反向传播
如图1-17所示,反向传播用蓝色的粗箭头表示,在箭头的下方标注传播的值。此时,传播的值是指最终的输出L关于各个变量的导数。在这个例子中,关于z的导数是,关于x和y的导数分别是和。
接着,该链式法则出场了。根据刚才复习的链式法则,反向传播中流动的导数的值是根据从上游(输出侧)传来的导数和各个运算节点的局部导数之积求得的。因此,在上面的例子中,,。
这里,我们来处理z=x+y这个基于加法节点的运算。此时,分别解析性地求得,。因此,如图1-18所示,加法节点将上游传来的值乘以1,再将该梯度向下游传播。也就是说,只是原样地将从上游传来的梯度传播出去。
图1-18 加法节点的正向传播(左图)和反向传播(右图)
像这样,计算图直观地表示了计算过程。另外,通过观察反向传播的梯度的流动,可以帮助我们理解反向传播的推导过程。
在构成计算图的运算节点中,除了这里见到的加法节点之外,还有很多其他的运算节点。下面,我们将介绍几个典型的运算节点。
1.3.4.1 乘法节点
乘法节点是z=x×y这样的计算。此时,导数可以分别求出,即和。因此,如图1-19所示,乘法节点的反向传播会将“上游传来的梯度”乘以“将正向传播时的输入替换后的值”。
图1-19 乘法节点的正向传播(左图)和反向传播(右图)
另外,在目前为止的加法节点和乘法节点的介绍中,流过节点的数据都是“单变量”。但是,不仅限于单变量,也可以是多变量(向量、矩阵或张量)。当张量流过加法节点(或者乘法节点)时,只需独立计算张量中的各个元素。也就是说,在这种情况下,张量的各个元素独立于其他元素进行对应元素的运算。
1.3.4.2 分支节点
如图1-20所示,分支节点是有分支的节点。
图1-20 分支节点的正向传播(左图)和反向传播(右图)
严格来说,分支节点并没有节点,只有两根分开的线。此时,相同的值被复制并分叉。因此,分支节点也称为复制节点。如图1-20所示,它的反向传播是上游传来的梯度之和。
1.3.4.3 Repeat节点
分支节点有两个分支,但也可以扩展为N个分支(副本),这里称为Repeat节点。现在,我们尝试用计算图绘制一个Repeat节点(图1-21)。
图1-21 Repeat节点的正向传播(上图)和反向传播(下图)
如图1-21所示,这个例子中将长度为D的数组复制了N份。因为这个Repeat节点可以视为N个分支节点,所以它的反向传播可以通过N个梯度的总和求出,如下所示。
>>> import numpy as np >>> D , N = 8 , 7 >>> x = np.random.randn(1 , D) # 输入 >>> y = np.repeat(x , N , axis=0) # 正向传播 >>> dy = np.random.randn(N , D) # 假设的梯度 >>> dx = np.sum(dy , axis=0 , keepdims=True) # 反向传播
这里通过np.repeat()方法进行元素的复制。上面的例子中将复制N次数组x。通过指定axis,可以指定沿哪个轴复制。因为反向传播时要计算总和,所以使用NumPy的sum()方法。此时,通过指定axis来指定对哪个轴求和。另外,通过指定keepdims=True,可以维持二维数组的维数。在上面的例子中,当keepdims=True时,np.sum()的结果的形状是(1, D);当keepdims=False时,形状是(D,)。
NumPy的广播会复制数组的元素。这可以通过Repeat节点来表示。
1.3.4.4 Sum节点
Sum节点是通用的加法节点。这里考虑对一个N×D的数组沿第0个轴求和。此时,Sum节点的正向传播和反向传播如图1-22所示。
图1-22 Sum节点的正向传播(上图)和反向传播(下图)
如图1-22所示,Sum节点的反向传播将上游传来的梯度分配到所有箭头上。这是加法节点的反向传播的自然扩展。下面,和Repeat节点一样,我们也来展示一下Sum节点的实现示例,如下所示。
>>> import numpy as np >>> D , N = 8 , 7 >>> x = np.random.randn(N , D) # 输入 >>> y = np.sum(x , axis=0 , keepdims=True) # 正向传播 >>> dy = np.random.randn(1 , D) # 假设的梯度 >>> dx = np.repeat(dy , N , axis=0) # 反向传播
如上所示,Sum节点的正向传播通过np.sum()方法实现,反向传播通过np.repeat()方法实现。有趣的是,Sum节点和Repeat节点存在逆向关系。所谓逆向关系,是指Sum节点的正向传播相当于Repeat节点的反向传播,Sum节点的反向传播相当于Repeat节点的正向传播。
1.3.4.5 MatMul节点
本书将矩阵乘积称为MatMul节点。MatMul是Matrix Multiply的缩写。因为MatMul节点的反向传播稍微有些复杂,所以这里我们先进行一般性的介绍,再进行直观的解释。
为了解释MatMul节点,我们来考虑y=xW这个计算。这里,x、W、y的形状分别是1×D、D×H、1×H(图1-23)。
图1-23 MatMul节点的正向传播:矩阵的形状显示在各个变量的上方
此时,可以按如下方式求得关于x的第i个元素的导数。
式(1.12)的表示变化程度,即当xi发生微小的变化时,L会有多大程度的变化。如果此时改变xi,则向量y的所有元素都会发生变化。另外,因为y的各个元素会发生变化,所以最终L也会发生变化。因此,从xi到L的链式法则的路径有多个,它们的和是。
式(1.12)仍可进一步简化。利用,将其代入式(1.12):
由式(1.13)可知,由向量和W的第i行向量的内积求得。从这个关系可以导出下式:
如式(1.14)所示,可由矩阵乘积一次求得。这里,WT的T表示转置矩阵。对式(1.14)进行形状检查,结果如图1-24所示。
图1-24 矩阵乘积的形状检查
如图1-24所示,矩阵形状的演变是正确的。由此,可以确认式(1.14)的计算是正确的。然后,我们可以反过来利用它(为了保持形状合规)来推导出反向传播的数学式(及其实现)。为了说明这个方法,我们再次考虑矩阵乘积的计算y=xW。不过,这次考虑mini-batch处理,假设x中保存了N笔数据。此时,x、W、y的形状分别是N×D、D×H、N×H,反向传播的计算图如图1-25所示。
图1-25 MatMul节点的反向传播
那么,将如何计算呢?此时,和相关的变量(矩阵)是上游传来的和W。为什么说和W有关系呢?考虑到乘法的反向传播的话,就容易理解了。因为乘法的反向传播中使用了“将正向传播时的输入替换后的值”。同理,矩阵乘积的反向传播也使用“将正向传播时的输入替换后的矩阵”。之后,留意各个矩阵的形状求矩阵乘积,使它们的形状保持合规。如此,就可以导出矩阵乘积的反向传播,如图1-26所示。
如图1-26所示,通过确认矩阵的形状,可以推导矩阵乘积的反向传播的数学式。这样一来,我们就推导出了MatMul节点的反向传播。现在我们将MatMul节点实现为层,如下所示(common/layers.py)。
图1-26 通过确认矩阵形状,推导反向传播的数学式
class MatMul: def__init__(self, W): self.params = [W] self.grads = [np.zeros_like(W)] self.x = None def forward(self, x): W, = self.params out = np.dot(x, W) self.x = x return out def backward(self, dout): W, = self.params dx = np.dot(dout, W.T) dW = np.dot(self.x.T, dout) self.grads[0][...] = dW return dx
MatMul层在params中保存要学习的参数。另外,以与其对应的形式,将梯度保存在grads中。在反向传播时求dx和dw,并在实例变量grads中设置权重的梯度。
另外,在设置梯度的值时,像grads[0][...] = dW这样,使用了省略号。由此,可以固定NumPy数组的内存地址,覆盖NumPy数组的元素。
和省略号一样,这里也可以进行基于grads[0] = dW的赋值。不同的是,在使用省略号的情况下会覆盖掉NumPy数组。这是浅复制(shallow copy)和深复制(deep copy)的差异。grads[0] = dW的赋值相当于浅复制,grads[0][...] = dW的覆盖相当于深复制。
省略号的话题稍微有些复杂,我们举个例子来说明。假设有a和b两个NumPy数组。
>>> a = np.array([1 , 2 , 3]) >>> b = np.array([4 , 5 , 6])
这里,不管是a = b,还是a[...] = b,a都被赋值[4,5,6]。但是,此时a指向的内存地址不同。我们将内存(简化版)可视化,如图1-27所示。
图1-27 a=b和a[...]=b的区别:使用省略号时数据被覆盖,变量指向的内存地址不变
如图1-27所示,在a = b的情况下,a指向的内存地址和b一样。由于实际的数据(4,5,6)没有被复制,所以这可以说是浅复制。而在a[...] = b时,a的内存地址保持不变,b的元素被复制到a指向的内存上。这时,因为实际的数据被复制了,所以称为深复制。
由此可知,使用省略号可以固定变量的内存地址(在上面的例子中,a的地址是固定的)。通过固定这个内存地址,实例变量grads的处理会变简单。
在grads列表中保存各个参数的梯度。此时,grads列表中的各个元素是NumPy数组,仅在生成层时生成一次。然后,使用省略号,在不改变NumPy数组的内存地址的情况下覆盖数据。这样一来,将梯度汇总在一起的工作就只需要在开始时进行一次即可。
以上就是MatMul层的实现,代码在common/layers.py中。
1.3.5 梯度的推导和反向传播的实现
计算图的介绍结束了,下面我们来实现一些实用的层。这里,我们将实现Sigmoid层、全连接层Affine层和Softmax with Loss层。
1.3.5.1 Sigmoid层
sigmoid函数由表示,sigmoid函数的导数由下式表示。
根据式(1.15),Sigmoid层的计算图可以绘制成图1-28。这里,将输出侧的层传来的梯度()乘以sigmoid函数的导数(),然后将这个值传递给输入侧的层。
图1-28 Sigmoid层的计算图
这里,我们省略sigmoid函数的偏导数的推导过程。相关内容会在附录A中介绍,感兴趣的读者可以参考一下。
接下来,我们使用Python来实现Sigmoid层。参考图1-28,可以像下面这样进行实现(common/layers.py)。
class Sigmoid: def__init__(self): self.params, self.grads = [], [] self.out = None def forward(self, x): out = 1 / (1 + np.exp(-x)) self.out = out return out def backward(self, dout): dx = dout * (1.0 - self.out) * self.out return dx
这里将正向传播的输出保存在实例变量out中。然后,在反向传播中,使用这个out变量进行计算。
1.3.5.2 Affine层
如前所示,我们通过y = np.dot(x, W) + b实现了Affine层的正向传播。此时,在偏置的加法中,使用了NumPy的广播功能。如果明示这一点,则Affine层的计算图如图1-29所示。
如图1-29所示,通过MatMul节点进行矩阵乘积的计算。偏置被Repeat节点复制,然后进行加法运算(可以认为NumPy的广播功能在内部进行了Repeat节点的计算)。下面是Affine层的实现(common/layers.py)。
图1-29 Affine层的计算图
class Affine: def__init__(self, W, b): self.params = [W, b] self.grads = [np.zeros_like(W), np.zeros_like(b)] self.x = None def forward(self, x): W, b = self.params out = np.dot(x, W) + b self.x = x return out def backward(self, dout): W, b = self.params dx = np.dot(dout, W.T) dW = np.dot(self.x.T, dout) db = np.sum(dout, axis=0) self.grads[0][...] = dW self.grads[1][...] = db return dx
根据本书的代码规范,Affine层将参数保存在实例变量params中,将梯度保存在实例变量grads中。它的反向传播可以通过执行MatMul节点和Repeat节点的反向传播来实现。Repeat节点的反向传播可以通过np.sum()计算出来,此时注意矩阵的形状,就可以清楚地知道应该对哪个轴(axis)求和。最后,将权重参数的梯度设置给实例变量grads。以上就是Affine层的实现。
使用已经实现的MatMul层,可以更轻松地实现Affine层。这里出于复习的目的,没有使用MatMul层,而是使用NumPy的方法进行了实现。
1.3.5.3 Softmax with Loss层
我们将Softmax函数和交叉熵误差一起实现为Softmax with Loss层。此时,计算图如图1-30所示。
图1-30 Softmax with Loss层的计算图
图1-30的计算图将Softmax函数记为Softmax层,将交叉熵误差记为Cross Entropy Error层。这里假设要执行3类别分类的任务,从前一层(靠近输入的层)接收3个输入。
如图1-30所示,Softmax层对输入a1, a2, a3进行正规化,输出y1, y2, y3。Cross Entropy Error层接收Softmax的输出y1, y2, y3和监督标签t1, t2, t3,并基于这些数据输出损失L。
在图1-30中,需要注意的是反向传播的结果。从Softmax层传来的反向传播有y1-t1, y2-t2, y3-t3这样一个很“漂亮”的结果。因为y1, y2, y3是Softmax层的输出,t1, t2, t3是监督标签,所以y1-t1, y2-t2, y3-t3是Softmax层的输出和监督标签的差分。神经网络的反向传播将这个差分(误差)传给前面的层。这是神经网络的学习中的一个重要性质。
这里我们省略对Softmax with Loss层的实现的说明,具体代码在common/layers.py中。另外,Softmax with Loss层的反向传播的推导过程在前作《深度学习入门:基于Python的理论与实现》的附录A中有详细说明,感兴趣的读者可以参考一下。
1.3.6 权重的更新
通过误差反向传播法求出梯度后,就可以使用该梯度更新神经网络的参数。此时,神经网络的学习按如下步骤进行。
· 步骤1:mini-batch
从训练数据中随机选出多笔数据。
· 步骤2:计算梯度
基于误差反向传播法,计算损失函数关于各个权重参数的梯度。
· 步骤3:更新参数
使用梯度更新权重参数。
· 步骤4:重复
根据需要重复多次步骤1、步骤2和步骤3。
我们按照上面的步骤进行神经网络的学习。首先,选择mini-batch数据,根据误差反向传播法获得权重的梯度。这个梯度指向当前的权重参数所处位置中损失增加最多的方向。因此,通过将参数向该梯度的反方向更新,可以降低损失。这就是梯度下降法(gradient descent)。之后,根据需要将这一操作重复多次即可。
我们在上面的步骤3中更新权重。权重更新方法有很多,这里我们来实现其中最简单的随机梯度下降法(Stochastic Gradient Descent,SGD)。其中,“随机”是指使用随机选择的数据(mini-batch)的梯度。
SGD是一个很简单的方法。它将(当前的)权重朝梯度的(反)方向更新一定距离。如果用数学式表示,则有:
这里将要更新的权重参数记为W,损失函数关于W的梯度记为。η表示学习率,实际上使用0.01、0.001等预先定好的值。
现在,我们来进行SGD的实现。这里考虑到模块化,将进行参数更新的类实现在common/optimizer.py中。除了SGD之外,这个文件中还有AdaGrad和Adam等的实现。
进行参数更新的类的实现拥有通用方法update(params, grads)。这里,在参数params和grads中分别以列表形式保存了神经网络的权重和梯度。此外,假定params和grads在相同索引处分别保存了对应的参数和梯度。这样一来,SGD就可以像下面这样实现(common/optimizer.py)。
class SGD: def__init__(self, lr=0.01): self.lr = lr def update(self, params, grads): for i in range(len(params)): params[i] -= self.lr * grads[i]
初始化参数lr表示学习率(learning rate)。这里将学习率保存为实例变量。然后,在update(params, grads)方法中实现参数的更新处理。
使用这个SGD类,神经网络的参数更新可按如下方式进行(下面的代码是不能实际运行的伪代码)。
model = TwoLayerNet(...) optimizer = SGD() for i in range(10000): ... x_batch, t_batch = get_mini_batch(...) # 获取mini-batch loss = model.forward(x_batch, t_batch) model.backward() optimizer.update(model.params , model.grads) ...
像这样,通过独立实现进行最优化的类,系统的模块化会变得更加容易。除了SGD外,本书还实现了AdaGrad和Adam等方法,它们的实现都在common/optimizer.py中。这里省略对这些最优化方法的介绍,详细内容请参考前作《深度学习入门:基于Python的理论与实现》的6.1节。