1.2 神经网络的推理
现在我们开始复习神经网络。神经网络中进行的处理可以分为学习和推理两部分。本节将围绕神经网络的推理展开说明,而神经网络的学习会在下一节进行讨论。
1.2.1 神经网络的推理的全貌图
简单地说,神经网络就是一个函数。函数是将某些输入变换为某些输出的变换器,与此相同,神经网络也将输入变换为输出。
举个例子,我们来考虑输入二维数据、输出三维数据的函数。为了使用神经网络进行实现,需要在输入层准备2个神经元,在输出层准备3个神经元。然后,在隐藏层(中间层)放置若干神经元,这里我们放置4个神经元。
这样一来,我们的神经网络就可以画成图1-7。
图1-7 神经网络的例子
在图1-7中,用〇表示神经元,用箭头表示它们的连接。此时,在箭头上有权重,这个权重和对应的神经元的值分别相乘,其和(严格地讲,是经过激活函数变换后的值)作为下一个神经元的输入。另外,此时还要加上一个不受前一层的神经元影响的常数,这个常数称为偏置。因为所有相邻的神经元之间都存在由箭头表示的连接,所以图1-7的神经网络称为全连接网络。
图1-7的网络一共包含3层,但有权重的层实际上是2层,本书中将这样的神经网络称为2层神经网络。因为图1-7的网络由3层组成,所以有的文献也称之为3层神经网络。
下面用数学式来表示图1-7的神经网络进行的计算。这里用(x1, x2)表示输入层的数据,用w11和w12表示权重,用b1表示偏置。这样一来,图1-7中的隐藏层的第1个神经元就可以如下进行计算:
如式(1.2)所示,隐藏层的神经元是基于加权和计算出来的。之后,改变权重和偏置的值,根据神经元的个数,重复进行相应次数的式(1.2)的计算,这样就可以求出所有隐藏层神经元的值。
权重和偏置都有下标,这个下标的规则(为何将下标设为11或12等)并不是很重要,重要的是神经元是通过加权和计算的,并且可以通过矩阵乘积整体计算。实际上,基于全连接层的变换可以通过矩阵乘积如下进行整理:
这里,隐藏层的神经元被整理为(h1, h2, h3, h4),它可以看作1×4的矩阵(或者行向量)。另外,输入是(x1, x2),这是一个1×2的矩阵。再者,权重是2×4的矩阵,偏置是1×4的矩阵。这样一来,式(1.3)可以如下进行简化:
这里,输入是x,隐藏层的神经元是h,权重是W,偏置是b,这些都是矩阵。此时,留意式(1.4)的矩阵形状,可知进行了如图1-8所示的变换。
图1-8 形状检查:确认对应维度的元素个数一致(省略偏置)
如图1-8所示,在矩阵乘积中,要使对应维度的元素个数一致。通过像这样观察矩阵的形状,可以确认变换是否正确。
在矩阵乘积的计算中,形状检查非常重要。据此,可以判断计算是否正确(至少可以判断计算是否成立)。
这样一来,我们就可以利用矩阵来整体计算全连接层的变换。不过,这里进行的变换只针对单笔样本数据(输入数据)。在神经网络领域,我们会同时对多笔样本数据(称为mini-batch,小批量)进行推理和学习。因此,我们将单独的样本数据保存在矩阵x的各行中。假设要将N笔样本数据作为mini-batch整体处理,关注矩阵的形状,其变换如图1-9所示。
图1-9 形状检查:mini-batch版的矩阵乘积(省略偏置)
如图1-9所示,根据形状检查,可知各mini-batch被正确地进行了变换。此时,N笔样本数据整体由全连接层进行变换,隐藏层的N个神经元被整体计算出来。现在,我们用Python写出mini-batch版的全连接层变换。
>>> import numpy as np >>> W1 = np.random.randn(2 , 4) # 权重 >>> b1 = np.random.randn(4) # 偏置 >>> x = np.random.randn(10 , 2) # 输入 >>> h = np.dot(x , W1) + b1
在这个例子中,10笔样本数据分别由全连接层进行变换。此时,x的第1个维度对应于各笔样本数据。比如,x[0]是第0笔输入数据,x[1]是第1笔输入数据……类似地,h[0]是第0笔数据的隐藏层的神经元,h[1]是第1笔数据的隐藏层的神经元,以此类推。
在上面的代码中,偏置b1的加法运算会触发广播功能。b1的形状是(4,),它会被自动复制,变成(10, 4)的形状。
全连接层的变换是线性变换。激活函数赋予它“非线性”的效果。严格地讲,使用非线性的激活函数,可以增强神经网络的表现力。激活函数有很多种,这里我们使用式(1.5)的sigmoid函数(sigmoid function):
如图1-10所示,sigmoid函数呈S形曲线。
图1-10 sigmoid函数的图像
sigmoid函数接收任意大小的实数,输出0~1的实数。现在我们用Python来实现这个sigmoid函数。
def sigmoid(x): return 1 / (1 + np.exp(-x))
这是式(1.5)的直接实现,应该没有特别难的地方。现在,我们使用这个sigmoid函数,来变换刚才的隐藏层的神经元。
>>> a = sigmoid(h)
基于sigmoid函数,可以进行非线性变换。然后,再用另一个全连接层来变换这个激活函数的输出a(也称为activation)。这里,因为隐藏层有4个神经元,输出层有3个神经元,所以全连接层使用的权重矩阵的形状必须设置为4×3,这样就可以获得输出层的神经元。以上就是神经网络的推理。现在我们用Python将这一段内容总结如下。
import numpy as np def sigmoid(x): return 1 / (1 + np.exp(-x)) x = np.random.randn(10, 2) W1 = np.random.randn(2, 4) b1 = np.random.randn(4) W2 = np.random.randn(4, 3) b2 = np.random.randn(3) h = np.dot(x, W1) + b1 a = sigmoid(h) s = np.dot(a, W2) + b2
这里,x的形状是(10, 2),表示10笔二维数据组织为了1个mini-batch。最终输出的s的形状是(10, 3)。同样,这意味着10笔数据一起被处理了,每笔数据都被变换为了三维数据。
上面的神经网络输出了三维数据。因此,使用各个维度的值,可以分为3个类别。在这种情况下,输出的三维向量的各个维度对应于各个类的“得分”(第1个神经元是第1个类别,第2个神经元是第2个类别……)。在实际进行分类时,寻找输出层神经元的最大值,将与该神经元对应的类别作为结果。
得分是计算概率之前的值。得分越高,这个神经元对应的类别的概率也越高。后面我们会看到,通过把得分输入Softmax函数,可以获得概率。
以上就是神经网络的推理部分的实现。接下来,我们使用Python的类,将这些处理实现为层。
1.2.2 层的类化及正向传播的实现
现在,我们将神经网络进行的处理实现为层。这里将全连接层的变换实现为Affine层,将sigmoid函数的变换实现为Sigmoid层。因为全连接层的变换相当于几何学领域的仿射变换,所以称为Affine层。另外,将各个层实现为Python的类,将主要的变换实现为类的forward()方法。
神经网络的推理所进行的处理相当于神经网络的正向传播。顾名思义,正向传播是从输入层到输出层的传播。此时,构成神经网络的各层从输入向输出方向按顺序传播处理结果。之后我们会进行神经网络的学习,那时会按与正向传播相反的顺序传播数据(梯度),所以称为反向传播。
神经网络中有各种各样的层,我们将其实现为Python的类。通过这种模块化,可以像搭建乐高积木一样构建网络。本书在实现这些层时,制定以下“代码规范”。
·所有的层都有forward()方法和backward()方法
·所有的层都有params和grads实例变量
简单说明一下这个代码规范。首先,forward()方法和backward()方法分别对应正向传播和反向传播。其次,params使用列表保存权重和偏置等参数(参数可能有多个,所以用列表保存)。grads以与params中的参数对应的形式,使用列表保存各个参数的梯度(后述)。这就是本书的代码规范。
遵循上述代码规范,代码看上去会更清晰。我们后面会说明为什么要遵循这样的规范,以及它的有效性。
因为这里只考虑正向传播,所以我们仅关注代码规范中的以下两点:一是在层中实现forward()方法;二是将参数整理到实例变量params中。我们基于这样的代码规范来实现层,首先实现Sigmoid层,如下所示(ch01/forward_net.py)。
import numpy as np class Sigmoid: def__init__(self): self.params = [] def forward(self, x): return 1 / (1 + np.exp(-x))
如上所示,sigmoid函数被实现为一个类,主变换处理被实现为forward(x)方法。这里,因为Sigmoid层没有需要学习的参数,所以使用空列表来初始化实例变量params。下面,我们接着来看一下全连接层Affine层的实现,如下所示(ch01/forward_net.py)。
class Affine: def__init__(self, W, b): self.params = [W, b] def forward(self, x): W, b = self.params out = np.dot(x, W) + b return out
Affine层在初始化时接收权重和偏置。此时,Affine层的参数是权重和偏置(在神经网络的学习时,这两个参数随时被更新)。因此,我们使用列表将这两个参数保存在实例变量params中。然后,实现基于forward(x)的正向传播的处理。
根据本书的代码规范,所有的层都需要在实例变量params中保存要学习的参数。因此,可以很方便地将神经网络的全部参数整理在一起,参数的更新操作、在文件中保存参数的操作都会变得更容易。
现在,我们使用上面实现的层来实现神经网络的推理处理。这里实现如图1-11所示的层结构的神经网络。
图1-11 要实现的神经网络的层结构
如图1-11所示,输入X经由Affine层、Sigmoid层和Affine层后输出得分S。我们将这个神经网络实现为名为TwoLayerNet的类,将主推理处理实现为predict(x)方法。
之前,我们在用图表示神经网络时,使用的是像图1-7那样的“神经元视角”的图。与此相对,图1-11是“层视角”的图。
TwoLayerNet的代码如下所示(ch01/forward_net.py)。
class TwoLayerNet: def__init__(self, input_size, hidden_size, output_size): I, H, O = input_size, hidden_size, output_size # 初始化权重和偏置 W1 = np.random.randn(I, H) b1 = np.random.randn(H) W2 = np.random.randn(H, O) b2 = np.random.randn(O) # 生成层 self.layers = [ Affine(W1, b1), Sigmoid(), Affine(W2, b2) ] # 将所有的权重整理到列表中 self.params = [] for layer in self.layers: self.params += layer.params def predict(self, x): for layer in self.layers: x = layer.forward(x) return x
在这个类的初始化方法中,首先对权重进行初始化,生成3个层。然后,将要学习的权重参数一起保存在params列表中。这里,因为各个层的实例变量params中都保存了学习参数,所以只需要将它们拼接起来即可。这样一来,TwoLayerNet的params变量中就保存了所有的学习参数。像这样,通过将参数整理到一个列表中,可以很轻松地进行参数的更新和保存。
此外,Python中可以使用+运算符进行列表之间的拼接。下面是一个简单的例子。
>>> a = ['A' , 'B'] >>> a += ['C' , 'D'] >>> a ['A', 'B', 'C', 'D']
如上所示,通过列表之间的加法将列表拼接了起来。在上面的TwoLayerNet的实现中,通过将各个层的params列表加起来,从而将全部学习参数整理到了一个列表中。现在,我们使用TwoLayerNet类进行神经网络的推理。
x = np.random.randn(10, 2) model = TwoLayerNet(2, 4, 3) s = model.predict(x)
这样就可以求出输入数据x的得分s了。像这样,通过将层实现为类,可以轻松实现神经网络。另外,因为要学习的参数被汇总在model.params中,所以之后进行神经网络的学习会更加容易。