深度学习入门_基于Python的理论与实现

《深度学习入门_基于Python的理论与实现》

第2章 感知机

2.1 感知机是什么

感知机的数学公式如下

2.2 简单逻辑电路

现在我们考虑用感知机来解决简单的问题,就是下面这3个简单逻辑电路。

与门(AND gate):仅在两个输入均为1时输出1,其他时候则输出 0 。

与非门 (NAND gate):对与门结果取反,这里 NAND 就是 Not AND 的缩写(那不是应该翻译为“非与”吗),仅在两个输入均为1时输出0,其他时候则输出 1 。

或门:只要有一个输入信号是1,输出就为1

2.3 感知机的实现

现在我们用 Python 来实现刚才的逻辑电路,首先我们先定义一个接收参数 x1 和 x2 的 AND 的函数 。

1
2
3
4
5
6
7
def AND(x1,x2):
w1, w2, theta = 0.5, 0.5, 0.7
tmp = w1*x1 + w2*x2
if tmp <= theta:
return 0
elif tmp > theta:
return 1

然后我们测试一下输出结果是否和与门一致,结果和预想一样,因此我们实现了与门。

1
2
3
4
AND(0, 0) # 输出0
AND(1, 0) # 输出0
AND(0, 1) # 输出0
AND(1, 1) # 输出1

按照相同的步骤,我们也可以实现非门和或门,不过我们下面对它们的实现稍作修改。

导入权重和偏置

下面我们将上面与门函数改为另外一种实现方式,我们将公式中的 改为 ,于是感知机的公式改为:

这里 称为偏置 称为权重。有时也就是这些参数统称为权重。

利用权重和偏置的实现

这里我们利用 Python 中 Numpy 数组的乘法运算功能,两个数组用 * 相乘的结果就是元素相乘后的结果,之后再利用np.sum()函数得到总和。

1
2
3
4
5
6
7
8
9
10
11
def AND(x1,x2):
import numpy as np
x = np.array([x1,x2])
w = np.array([0.5, 0.5])
b = -0.7
# w1, w2, theta = 0.5, 0.5, 0.7
tmp = np.sum(w*x) + b
if tmp <= 0:
return 0
elif tmp > 0:
return 1

接下来,我们继续实现与非门和或门

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def NAND(x1,x2):
import numpy as np
x = np.array([x1,x2])
w = np.array([-0.5, -0.5])
b = 0.7
tmp = np.sum(w*x) + b
if tmp <= 0:
return 0
elif tmp > 0:
return 1

print(NAND(0, 0)) # 输出1
print(NAND(1, 0)) # 输出1
print(NAND(0, 1)) # 输出1
print(NAND(1, 1)) # 输出0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

def OR(x1,x2):
import numpy as np
x = np.array([x1,x2])
w = np.array([0.5, 0.5])
b = -0.2
tmp = np.sum(w*x) + b
if tmp <= 0:
return 0
elif tmp > 0:
return 1

print(OR(0, 0)) # 输出0
print(OR(1, 0)) # 输出1
print(OR(0, 1)) # 输出1
print(OR(1, 1)) # 输出1

2.4 感知机的局限性

这里我们考虑一下异或门(XOR gate),仅当 x1 或 x2 中的一方为1时,才会输出 1 。

事实上这里我们用感知机是无法构建异或门,因为感知机的局限性就在于它只能表示由一条直线分割的空间,以 为例,其感知机会生成由下图直线分隔开的两个空间,直线下面输出0,直线上面输出1

而异或门没法用一条直线来划分开,需要用曲线分隔开,如下图所示。我们称由曲线分隔开的空间称为非线性空间,由直线分隔而成的空间称为线性空间

但是我们可以用多层感知机来解决这个问题。

2.5 多层感知机

已有门电路的组合

异或门的制作方法有很多,其中之一就是组成我们之前做好的与门,与非门、或门进行配置,其符号表示如下图,这里与非门中的圆圈表示反转输出的意思。

这里我们可以通过下面的结果来构建异或门,可以自己确认一下。

异或门的实现

下面用 Python 代码来实现

1
2
3
4
5
6
7
8
9
10
def XOR(x1, x2):
s1 = NAND(x1, x2)
s2 = OR(x1, x2)
y = AND(s1, s2)
return y

print(XOR(0, 0)) # 输出0
print(XOR(1, 0)) # 输出1
print(XOR(0, 1)) # 输出1
print(XOR(1, 1)) # 输出0

下面我们用感知机的表示方式(明确地显示神经元)来表示这个异或门,如下图所示。这里异或门是一种多层结构的神经网络,我们将最左面的一列称为第 0 层,随后称为第1层和第2层。我们将这种叠加了多层的感知机称为多层感知机。这里的感知机总共是3层,但是因为拥有权重的层实质上只有2层(第0层和第1层之间,第1层和第2层之间),因此称为2层感知机,不过也有文献称为3层感知机。图中的圆圈称为神经元节点

这种2层感知机的运行过程如同流水线的组装作业,第1层的工人对传过来的零件进行加工,完成后再传递给第2层的工人,之后出货。

因此我们看到单层感知机无法表示的东西,通过增加一层就可以解决。也就是说,通过叠加层,感知机能进行更加灵活的表示。

第3章 神经网络

3.1 从感知机到神经网络

神经网络和感知机有很多共同点,这里主要介绍二者的差异。

神经网络的例子

用图来表示神经网络的话,如下图所示,我们把最左边的一列称为输入层, 最右边的一列称为输出层,中间的一列称为中间层,有时也称为隐藏层。本书中将输入层称为第 0 层,方便后面的 Python 实现。

这里神经网络的连接方式与感知机相同。

复习感知机

我们先复习一下感知机,先看下面的网络结构

图中的感知机接收 x1 和 x2 两个输入信号,输出 y 。这里没有表示偏置 b ,如果要明确表示出来,可以如下图那样做。这里权重为 b 的输入信号为 1,这个感知机接收 x1 ,x2 和 1 三个输入信号。

原本感知机的计算公式如下

我们将其改成更简洁的形式,我们用一个函数来表示这种分情况的动作(超过0则输出1,否则输出0),引入新函数 ,将上式改成下面这2个式子

激活函数登场

刚才登场的 函数会将输入信号的总和转换为输出信号,这种函数一般称为激活函数(activation funciton)。

我们把这一步写得更细一点,分为2步,第一步计算输入信号的加权总和,第二步用激活函数转换这一总和,写成下面这两个式子。

之前的神经元都式用一个圆圈表示的,如果要在图中明确表示出上面那2个式子,则可以像下图这样做。

激活函数是连接感知机和神经网络的桥梁。

3.2 激活函数

上面表示的激活函数以阈值为界,一旦输入超过阈值,就切换输出。这样的函数称为阶跃函数。因此,感知机可以说就是使用了阶跃函数作为激活函数。如果将激活函数转换成其它函数,就可以进入到神经网络的世界了。下面我们介绍一下神经网络使用的激活函数。

sigmoid函数

神经网络中经常使用的一个函数就是 sigmoid 函数

阶跃函数的实现

让我们试着用 Python 画出阶跃函数的图,阶跃函数如下所示

1
2
3
4
5
def step_function(x):
if x > 0:
return 1
elif x <= 0:
return 0

我们将其改为支持Numpy数组的形式,这里使用 astype() 函数转换数据格式,从布尔型数据转为整数。

1
2
3
def step_function(x):
y = x > 0
return y.astype(int)

阶跃函数的图像

下面就用图来表示上面定义的阶跃函数,为此需要使用 matplotlib 库。

1
2
3
4
5
6
7
8
9
10
11
12
import numpy as np
import matplotlib.pylab as plt

def step_function(x):
y = x > 0
return y.astype(int)

x = np.arange(-5.0, 5.0, 0.1)
y = step_function(x)
plt.plot(x,y)
plt.ylim(-0.1, 1.1) # 指定y轴范围
plt.show()

sigmoid函数的实现

下面我们来实现 sigmoid 函数

1
2
def sigmoid(x):
return 1 / (1 + np.exp(-x))

画图如下

1
2
3
4
5
x = np.arange(-5.0, 5.0, 0.1)
y = sigmoid(x)
plt.plot(x,y)
plt.ylim(-0.1, 1.1) # 指定y轴范围
plt.show()

sigmoid函数和阶跃函数的比较

现在我们来比较这两个函数的不同点:

  1. 平滑性不同:sigmoid 函数就是一条平滑曲线,输出随着输入发生连续性的变化。而阶跃函数以0为界,输出发生急剧性的变化。
  2. 输出值类型不同:阶跃函数只能返回0或1的二元信息,而sigmoid函数返回值是连续的实数值。

共同点:

  1. 二者均是“递增”函数,即输入值越小,输出接近0;输入值越大,输出接近1。
  2. 输出值的范围都在0-1之间。

非线性函数

两个函数还有其它共同点,就是二者均为非线性函数。sigmoid是一条曲线,阶跃函数是一条像阶梯一样的折线,二者均为非线性函数(如果使用线性函数,这里则为 )。

神经网络的激活函数必须使用非线性函数,因为使用线性函数的话,加深神经网络的乘数就没有意义了。线性函数的问题在于,不管如何加深层数,总是存在与之等效的“无隐藏层的神经网络”。为了具体地理解这一点,我们来思考下面这个简单的例子,我们考虑把线性函数 作为激活函数,把 的运算对应3层神经网路。这个运算会得到 的乘法运算,可以视为 这一次乘法运算来表示(即没有隐藏层的神经网络)。因此使用线性函数时无法发挥多层网络带来的优势,因此激活函数必须使用非线性函数。

ReLU函数

最近的激活函数主要使用 ReLU (Rectified Linear Unit)函数 。其在输入大于0时,直接输出函数值;在输入小于等于0时,输出0。公式如下

其 Python 实现如下,这里使用 np.maximum() 函数从输入的数值中选择较大的那个值进行输出。

1
2
def relu(x):
return np.maximum(0,x)

本章剩余部分内容仍将使用 sigmoid 函数作为激活函数,但在本书的后半部分,则将主要使用 ReLU 函数。

3.3 多维数组的运算

如果掌握了 Numpy 多维数组的运算,就可以高效地实现神经网络。因此,本书将介绍 Numpy多维数组地运算,然后再进行神经网络的实现。

多维数组

简单地说,多维数组就是“数字的集合”,数字排成一列的集合,排成长方形的集合(二维数组也称为矩阵)、排成三维状或N维的集合都称为数组。下面我们用 numpy来生成多维数组。在 Python 中,第一个维度对应第0维,之后称为第1维,第2维等(Python的索引从0开始)。

矩阵乘法

对于2个矩阵的矩阵乘法而言,需要第一个矩阵的第1维(列数)与第二个矩阵的第0维(行数)的元素个数相同。

对于二维矩阵和一维数组的乘积,对应维度的个数仍要保持一致,如下图

更具体地规则如下

  1. 二维数组在左,一维数组在右(np.dot(A, B)
    • 二维数组 A 的形状为 (M, N),一维数组 B 的形状为 (N,)
    • B 被视为列向量,结果为一维数组,形状为 (M,)
    • 条件A 的列数必须等于 B 的长度(即 N)。
  2. 一维数组在左,二维数组在右(np.dot(B, A)
    • 一维数组 B 的形状为 (M,),二维数组 A 的形状为 (M, N)
    • B 被视为行向量,结果为一维数组,形状为 (N,)
    • 条件B 的长度必须等于 A 的行数(即 M)。

神经网络的内积

下面我们使用 Numpy 矩阵来实现神经网络,以下图为例,这里忽略了偏置和激活函数,只有权重。

3.4 3层神经网络的实现

现在我们来进行神经网络的实现。这里我们以下图的3层神经网络为对象,实现从输入到输出的(前向)处理。

符号确认

在介绍神经网络中的处理之前,我们先导入 等符号。

我们先从定义符号开始。如下图,图中只突出显示了从输入层神经元 到后一层的神经元 的权重。如下图所示,权重和隐藏层的神经元的右上角的 “(1)” ,表示权重和神经元的层号。此外,权重的右下角有2个数字,它们是后一层的神经元和前一层的神经元的索引号。比如 表示前一层 到后一层的第一个神经元 的权重。权重右下角按照“后一层的索引号,前一层的索引号”的顺序排列。

各层信号传递的实现

现在看一下从输入层到第1层的第1个神经元的信号传递过程,如下图所示,这里增加了表示偏置的神经元 “1”,而偏置的右下角的索引号只有一个,因为不需要表示前一层的偏置神经元,因此其索引号只包含后一层神经元索引号。

为了确认前面的内容,现在用数学式表示

此外,如果使用矩阵的乘法运算,则可以将第1层的加权和表示成下式

其中各式内容如下

下面我们用 Numpy 数组来实现

1
2
3
4
5
6
7
8
9
X = np.array([1.0, 0.5])
W1 = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
B1 = np.array([0.1, 0.2, 0.3])

print(W1.shape) # (2, 3)
print(X.shape) # (2,)
print(B1.shape) # (3,)

A1 = np.dot(X, W1) + B1

接下来我们观察第1层中激活函数的计算过程。如果把这个计算过程用图来表示的话,如下图所示

这里加权和用 a 表示,被激活函数转换后的信号用 z 表示。这里我们使用的激活函数式sigmoid函数,用 Python实现,代码如下

1
Z1 = sigmoid(A1)

下面我们实现从第1层到第2层的信号传递。这里使用的代码完全相同。

1
2
3
4
5
6
7
8
9
W2 = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
B2 = np.array([0.1, 0.2])

print(Z1.shape) # (3,)
print(W2.shape) # (3, 2)
print(B2.shape) # (2,)

A2 = np.dot(Z1, W2) + B2
Z2 = sigmoid(A2)

最后式第2层到输出层的信号传递。输出层的实现也基本相同,不过使用的激活函数略有不同,这里使用的是恒等函数,也就是将输入按原样输出。输出层的激活函数用 表示,而不使用 (因为可能和前面用的激活函数不同)。

1
2
3
4
5
6
7
8
def identity_function(x):
return x

W3 = np.array([[0.1, 0.3], [0.2, 0.4]])
B3 = np.array([0.1, 0.2])

A3 = np.dot(Z2, W3) + B3
Y = identity_function(A3) # 或者Y = A3

代码实现小结

至此,我们已经介绍完了3层神经网络的实现。现在我们把之前的代码实现全部整理一下。这里,我们按照神经网络的实现惯例,只把权重记为大写字母 W1,其它的(偏置或中间结果等)都是用小写字母表示。

这里定义了 init_network()forward() 函数,init_network() 会进行权重和偏置的初始化,保存在字典 network 中。 forward() 函数则封装了将输入信号转换为输出信号的处理过程。

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
27
28
29
30
31
32
33

def init_network():
network = {}
network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
network['b1'] = np.array([0.1, 0.2, 0.3])
network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
network['b2'] = np.array([0.1, 0.2])
network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]])
network['b3'] = np.array([0.1, 0.2])

return network

def forward(network, x):
W1, W2, W3 = network['W1'], network['W2'], network['W3']
b1, b2, b3 = network['b1'], network['b2'], network['b3']

a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)

a2 = np.dot(z1, W2) + b2
z2 = sigmoid(a2)

a3 = np.dot(z2, W3) + b3
y = identity_function(a3)

return y

network = init_network()
x = np.array([1.0, 0.5])

y = forward(network, x)

print(y) # [ 0.31682708 0.69627909]

3.5 输出层的设计

神经网络可以用在分类问题和回归问题上,需要根据情况改变输出层的激活函数。一般而言,回归问题用恒等函数,分类问题用 softmax函数。

恒等函数和 softmax函数

恒等函数会将输入按原样输出。

分类问题使用的 softmax 函数可以用下面的式子表示。

这里 是输出神经元的数目, 是输入信号。所有输入信号的输出值之和为1(输出信号的大小应该是理解为概率)。

用图来表示 software 的话,如下图所示,softmax 函数的输出通过箭头与所有的输入信号相连。

现在我们来实现 softmax 函数。

1
2
3
4
5
def softmax(a):
exp_a = np.exp(a)
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y

实现 softmax 函数时的注意事项

上面的 softmax 函数虽然正确,但是在计算机的运算上有一定缺陷,就是溢出问题。因为 softmax 函数设计指数函数的运算,指数函数的值可能会非常大,超出数值范围,会返回一个表示无穷大的 inf 。

softmax 函数可以按照下式进行改进,下面 C 是一个常数,

这说明在进行指数计算时,加上或减去某个常数不会改变运算结果。这里的常数可以用任何值,但是一般会减去输入信号中的最大值。因此上面的 softmax 函数可以进行如下修改

1
2
3
4
5
6
def softmax(a):
c = np.max(a)
exp_a = np.exp(a - c)
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y

softmax 函数的特征

softmax 函数的输出总和为1,因此我们可以将 softmax 函数的输出值理解为概率。

一般而言,神经网络只把输出值最大的神经元所对应的类别作为识别结果。即便使用 softmax 函数,输出值最大的神经元的位置也不会改变。因此,神经网络在进行分类时,输出层的softmax函数可以省略。在实际的问题中,由于指数函数的运算需要一定的计算机运算量,因此输出层的 softmax 函数一般会被省略。(”学习“/”训练“过程不会省略 softmax 函数,但是”推理“过程会省略)。

输出层的神经元数量

输出层的神经元数量需要根据具体问题来确定。对于分类问题,输出层神经元数目一般设定为类别的数目。

3.6 手写数字识别

介绍完神经网络的结构后,我们来试着解决实际问题。这里我们进行手写数字图像的分类。假设学习已经结束,我们使用学习得到的常数,先实现神经网络的”推断处理"。这个推断处理也称为神经网络的前向传播(forward propagation)。

MNIST 数据集

这里使用的数据集是 MNIST 手写数字图像集,其由 0 到 9 的数字图像构成。训练图像有 6 万张,测试图像有1万张。其图像数据是 28像素 × 28像素的灰度图像,每个像素的取值范围在 0-255 之间,每个图像数据都膘有相应的数字标签。

读取 MNIST 数据如下,此时的当前目录必须是 ch01 等目录中的一个。使用 dataset 文件夹中 mnist.py 模块中的 load_mnist() 函数,就可以按照下面的方式读入MNIST数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys, os
sys.path.append(os.pardir) # 为了导入父目录中的文件而进行的设定
from dataset.mnist import load_mnist

# 第一次调用会花费几分钟......
(x_train, t_train), (x_test, t_test) = load_mnist(flatten=True,
normalize=False)

# 输出各个数据的形状
print(x_train.shape) # (60000, 784)
print(t_train.shape) # (60000,)
print(x_test.shape) # (10000, 784)
print(t_test.shape) # (10000,

这里 load_mnist() 函数以”(训练图像,训练标签),(测试图像,测试标签)“的形式返回读入的 MNIST 数据。这里 load_mnist(normalize=True, flatten=True, one_hot_label=False) 函数有3个参数,第1个常数 normalize 设置是否将输入图像正规化为 0.0 ~ 1.0 的值。第2个常数flatten 设置是否展开输入图像(变成1维数组),如果设置为 False ,则输入图像为 1×28×28 的三维数组;若设置为 True ,则输入图像会保存为由 784 个元素构成的一维数组。第3个参数 one_hot_label 设置是否将标签保存为 one-hot 表示,如果为 False ,则只是像7、2这样简单保存正确解标签。

Python中由 pickle 这个便利的功能。这个功能可以将程序运行中的对象保存为文件。如果加载保存过的 pickle 文件,可以立刻复原之前程序运行中的对象。这里 load_mnist() 函数内部也使用了 pickle 功能(在第2次及以后读入时)。

现在我们试着显示 MNIST 图像,同时也确认一下数据。图像的显示使用 PIL(Python Image Library)模块。执行下述代码后,训练图像的第一张就会显示出来,代码如下。这里显示图像的时候要将原来的一列数组转成 28像素 × 28像素的形状。此外,这里还需要把保存为 NumPy 数组的图像数据转换乘 PLT 用的数据对象,这个转换处理由 Image.fromarray()来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 查看 MNIST 图像
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist

from PIL import Image
def img_show(img):
pil_img = Image.fromarray(np.uint8(img))
pil_img.show()

(x_train, t_train), (x_test, t_test) = load_mnist(flatten=True,
normalize=False)

img = x_train[0]

label = t_train[0]

print(label) # 5
print(img.shape) # (784,)
img = img.reshape(28, 28) # 把图像的形状变成原来的尺寸
print(img.shape) # (28, 28)

img_show(img) # 显示图像

神经网络的推理处理

下面,我们对这个 MNIST 数据集实现神经网络的推理处理。神经网络的输入层有 784个神经元(图像大小),输出层有10个神经元(数字0-9,共10个分类)。此外,这个神经网络有2个隐藏层,第1个隐藏层有50个神经元,第2个隐藏层有100个神经元。这里的50和100可以设置为任意值,下面我们先定义 get_data()init_network()predict() 这3个函数(代码在 ch03/neuralnet_mnist.py中)。

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
27
def get_data():
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, flatten=True, one_hot_label=False)
return x_test, t_test

def init_network():
import pickle
with open("sample_weight.pkl", 'rb') as f:
network = pickle.load(f)

return network

def sigmoid(x):
import numpy as np
return 1 / (1 + np.exp(-x))

def predict(network, x):
W1, W2, W3 = network['W1'], network['W2'], network['W3']
b1, b2, b3 = network['b1'], network['b2'], network['b3']
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
z2 = sigmoid(a2)
a3 = np.dot(z2, W3) + b3
y = sigmoid(a3)

return y

这里 init_network() 会读入保存在 pickle 文件 sample_weight.pkl 中的学习到的权重参数。这个文件中以字典变量的形式保存了权重和偏置参数。剩余的2个函数和前面介绍的代码基本相同,无需再解释。

现在我们用这3个函数就能实现神经网络的推理处理。然后,评价它的识别精度(accuracy),即能在多大程度上正确分类。

1
2
3
4
5
6
7
8
9
10
11
x,t = get_data()
network = init_network()

accuracy_cnt = 0
for i in range(len(x)):
y = predict(network, x[i])
p = np.argmax(y) # 获取概率最高的元素的索引
if p == t[i]:
accuracy_cnt += 1

print("Accuracy:" + str(float(accuracy_cnt)/len(x)))

这里我们先获得数据集,生成网络,然后用 predict() 函数进行分类,这里我们取出输出值最大的索引(第几个元素的概率最高,这里使用 np.argmax()函数),作为预测结果。最后比较预测结果和正确解标签,将回答正确的概率作为识别精度。这里输出识别精度为 0.9352 。

另外,在这个例子中,我们把 load_mnist 函数的参数 normalize 设置成了 true ,将 normalize 设置为 True 后,函数内部会进行转换,将图像的各个像素值除以255,使得数据的值在0.0-1.0的范围内。像这样把数据限定到某个范围内的处理称为正规化(normalization)(现在好像都叫做归一化)。此外,对神经网络的输入数据进行某种既定的转换称为预处理(pre-processing)。这里,作为对输入图像的一种预处理,我们进行了正规化。

批处理

以上就是处理 MNIST 数据集的神经网络的实现,现在我们来关注输入数据和权重参数的”形状“。再看一下刚才的代码实现。

下面我们使用输出一下刚才的各层的权重的形状。

1
2
3
4
5
6
7
8
9
x, _ = get_data()
network = init_network()
W1, W2, W3 = network['W1'], network['W2'], network['W3']

print(x.shape)
print(x[0].shape)
print(W1.shape)
print(W2.shape)
print(W3.shape)

输出结果如下

1
2
3
4
5
(10000, 784)
(784,)
(784, 50)
(50, 100)
(100, 10)

我们通过上述结果来确认一下多维数组的对应维度的元素个数是否一致(省略了偏置)。用图表示的话,如下图所示

不过这里只是输入一张图片的情形,如果我们要用 predict 函数一次性打包处理100张图像。为此,我们可以将 的形状改为 100×784,将100张图像打包作为输入数据。用图表示的话,如下图所示

此时输入数据的形状是 100×784 ,输出数据额形状是 100×10。则表示输入的 100 张图像的结果被一次性输出了。比如 x[0] 和 y[0] 中保存了第0张图像及其推理结果, x[1] 和 y[1] 中保存了第1张图像及其推理结果等等。

这种打包式的输入数据称为(batch)。批有”捆“的意思,图像就如同纸币一样扎成一捆。批处理可以答复缩短处理时间,因为批处理可以减轻数据总线的负荷(较少数据传送的时间)。

下面我们进行基于批处理的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
x,t = get_data()
network = init_network()

batch_size = 100
accuracy_cnt = 0

for i in range(0, len(x), batch_size):
x_batch = x[i:i+batch_size]
y_batch = predict(network, x_batch)
p = np.argmax(y_batch, axis=1) # 获取概率最高的元素的索引
accuracy_cnt += np.sum(p == t[i:i+batch_size])

print("Accuracy:" + str(float(accuracy_cnt)/len(x)))

第4章 神经网络的学习

本章的主题是神经网络的学习。这里所说的”学习“是指从训练数据中自动获取最优权重参数的过程。

4.1 从数据中学习

数据驱动

数据是机器学习的核心,机器学习的方法避免人为介入,尝试从收集到的数据中发现答案。

现在我们考虑一个具体的问题,比如如何实现数字”5“的识别。输入数据是手写图像,我们的目标是实现能区别是否是5的程序。这个问题看起来很简单,但是如果让我们自己来设计一个能将5正确分类的程序,就会发现这是一个很难的问题。人可以简单地识别出5,但却很难明确说出是基于何种规律而识别出了5。此外,每个人都有不同的写字习惯,要发现其中的规律是一件非常难的工作。

因此,与其绞尽脑汁,从零开始想出一个可以识别5的算法,不如考虑通过有效利用数据来解决这个问题。一种方案是,先从图像中提取特征量,再用机器学习技术学习这些特征量的模式。这里所说的”特征量“是指可以从输入数据(输入图像)中准确地提取本质数据(重要的数据)的转换器。图像的特征量通常表示为向量的形式。在计算机视觉领域,常用的特征量包括SIFT,SURF和 HOG等。使用这些特征量将图像数据转换为向量,然后对转换后的向量使用机器学习中的 SVM ,KNN 等分类器进行学习。

机器学习的方法中,由机器从收集到的数据中找出规律性。与从零开始想出算法相比,这种方法可以更加高效地解决问题,也能减轻人的负担。但是需要注意的是,将图像转换为向量时使用的特征量仍是由人设计的。对于不同的问题,必须使用合适的特征量(必须设计专门的特征量),才能得到好的结果。比如,为了区分狗的脸部,人们需要考虑与用于识别5的特征量不同的其它特征量。也就是说,即使使用特征量和机器学习的方法,也需要针对不同的问题人工考虑合适的特征量。

到这里,我们介绍了2种针对机器学习任务的方法。将这两种方法用下图来表示,这里神经网络的方法不存在人为介入,神经网络直接学习图像本身。

神经网络/深度学习有时也称为端对端机器学习(end-to-end machine learning),这里说的端对端是指从一端到另一端的意思,也就是说从原始数据(输入)中获得目标结果(输出)的意思。

训练数据和测试数据

机器学习一般将数据分为训练数据测试数据两部分来进行学习和实验等。首先,使用训练数据进行学习,寻找最优的参数;然后,使用测试数据评价训练得到的模型的实际能力。为什么需要将数据分为训练数据和测试数据呢?因为我们追求的是模型的泛化能力。为了正确评价模型的泛化能力,就必须划分训练数据和测试数据。另外,训练数据也可以称为监督数据

泛化能力是指处理未被观察过的数据的能力。获得泛化能力是机器学习的最终目标。

4.2 损失函数

损失函数(loss function)可以使用任意函数,但是一般用均方误差和交叉熵误差等。

损失函数(loss function)是表示神经网络性能的”恶劣程度“的指标,即当前的神经网络对监督数据在多大程度上不拟合,在多大程度上不一致。

均方误差

可以用作损失函数的函数有很多,其中最有名的是均方误差(mean squared error)。均方误差如下式所示:

这里 是神经网络的输出, 是监督数据, 表示数据的维度。

例如在手写数字识别的例子中, 是由10个元素构成的数据(每个数字的概率)。

1
2
y = [0.1, 0.05, 0.6, 0.0, 0.05, 0.1, 0.0, 0.1, 0.0, 0.0]
t = [0, 0, 1, 0, 0, 0, 0, 0, 0, 0]

这里神经网络的输出 是 softmax 函数的输出。这里正确解标签为1,其他标签为0的表示方式称为one-hot表示

下面我们用python来时间计算均方误差的函数

1
2
def mean_squared_error(y, t):
return 0.5 * np.sum((y-t)**2)

交叉熵误差

除了均方误差之外,交叉熵误差(cross entropy error)也进程用作损失函数。如下所示

这里 表示以 e 为底数的自然对数。 是神经网络的输出, 是正确解标签。并且 中只有正确解标签的索引为 1,其它均为0。因此上式只会计算对应正确解的输出的自然对数。

当正确解输出值接近1时,交叉熵误差接近于0;当正确解输出值接近0时,交叉熵误差接近于正无穷。

下面我们用代码来实现交叉熵误差。

1
2
3
def cross_entropy_error(y, t):
delta = 1e-7
return -nu.sum(t * np.log(y + delta))

这里加入一个微小的值 delta ,避免出现0的情况,导致无法计算。

mini-batch 学习

机器学习使用训练数据进行学习,严格地说,就是针对训练数据计算损失函数的值,找出使得该值尽可能小的参数。因此,计算损失函数时必须将所有的训练数据作为对象。

前面介绍的损失函数的例子都时针对单个数据的损失函数。如果要求所有训练数据的损失函数的总和,以交叉熵误差为例,可以写成下面的式子

这里假设数据有 N 个, 表示第 个数据的第 个元素的值。

这里是计算N份数据的平均值,得到一个平均损失函数。

另外,MNIST 数据集的训练数据有 6万个,如果以全部数据为对象求损失函数的和,则计算过程需要花费较长的时间。再者,如果遇到大数据,数据量会有上百万,上千万之多,这种情况下以全部数据为对象计算损失函数是不现实的。因此,我们可以从全部数据中选出一部分,作为全部数据的”近似“。神经网络的学习也是从训练数据中选处一批数据(称为 mini-batch ,小批量),然后对每个 mini-batch 进行学习。比如从6万个训练数据中随机选择100笔,再对这100笔数据进行学习。这种学习方式称为mini-batch学习

下面我们来编写从训练数据中随机选择指定个数的数据的代码,以进行 mini-batch 学习。在这之前,先来看一下用于读入 MNIST 数据集的代码

1
2
3
4
5
6
7
8
9
10
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist

(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, one_hot_label=True)

print(x_train.shape) # (60000, 784)
print(t_train.shape) # (60000, 10)

这里通过设定参数one_hot_label=True ,得到 one-hot 表示。

那么,如何从这个训练数据中随机抽取10笔数据呢?我们可以使用 np.random.choice() 函数,写成如下形式。使用 np.random.choice() 函数可以得到随机数字,比如 np.random.choice(100,10)就会从 1-99 中随机选择10个数字。

1
2
3
4
5
train_size = x_train.shape[0]
batch_size = 10
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]

之后,我们只需要利用这些随机选出的索引,取出 mini-batch ,然后使用这个 mini-batch 计算损失函数即可。

mini-batch版交叉熵误差的实现

如何实现对应 mini-batch 的交叉熵误差呢?只要改良一下之前实现的对应单个数据的交叉熵误差就可以了。这里,我们来实现一个可以同时实现单个数据和批量数据两种情况的函数。

1
2
3
4
5
6
7
def cross_entropy_error(y, t):
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)

batch_size = y.shape[0]
return -np.sum(t * np.log(y + 1e-7)) / batch_size

这里 , 是神经网络的输出 , 是监督数据。

的维度为1时,即求单个数据的交叉熵误差时,需要改变数据的形状。并且,当输入是 mini-batch 时,要用 batch 的个数进行正规化,计算单个数据的平均交叉熵误差。

此时,当监督数据是标签形式(非one-hot表示,而是像”2“ ”7“这样的标签)时,交叉熵误差可以通过如下代码实现:

1
2
3
4
5
6
7
def cross_entropy_error(y, t):
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)

batch_size = y.shape[0]
return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size

这里实现的要点在于只对正确解标签处的输出计算交叉熵误差。这里利用了 Numpy 花式索引的一个形状,当提供2个列表作为索引时,会返回两个列表的相应位置元素对组成的位置的元素,返回一个一个维数组。下例中,最终选出的是元素(1,0)、(5,3)、(7,1) 和 (2,2) 。

1
arr[[1, 5, 7, 2], [0, 3, 1, 2]]

为何要设定损失函数

因为我们在寻找最优参数时,要寻找使损失函数的值尽可能小的参数。为了找到使损失函数的值尽可能小的地方,需要计算参数的导数(确切地说是梯度)。

假设有一个神经网络,现在我们来关注这个神经网络中的某一个权重参数。此时,对该权重参数的损失函数求导,表示的是”如果稍微改变这个权重参数的值,损失函数的值会如何变化“。如果导数的值为负,通过使该权重参数向正方向改变,可以减少损失函数的值;反过来,如果导数的值为正,则通过使该权重参数向负方向改变,可以减少损失函数的值。不过当导数的值为 0 时,无论权重参数向哪个方向变化,损失函数的值都不会改变,此时该权重参数的更新会停在此处。

之所以不能用识别精度作为指标,因为这样一来绝大多数地方的导数都会变成0,导致参数无法更新。

为什么使用识别精度作为指标,参数的导数在绝大多数地方都会变成0呢?为了回答这个问题,我们来思考一个具体例子。假设某个神经网络正确识别出了100笔训练数据中的32笔,此时识别精度为 32% 。如果以识别精度为指标,即使稍微改变权重参数的值,识别精度也仍将保持在 32%,不会出现变化。也就是说,仅仅微调参数,是无法改善识别精度的。即使识别精度有所改善,它的值也不会像 32.0123…% 这样连续变化,而是变成 33%、34% 这样的不连续的、离散的值。而如果把损失函数作为指标,稍微改变一下参数的值,会发生连续性的变化。

识别精度对微小的参数变化基本没有什么反应,即便有反应,它的值也是不连续地、突然地变化。作为激活函数的阶跃函数也有同样的情况。出于相同的原因,如果使用阶跃函数作为激活函数,神经网络的学习将无法进行。

阶跃函数就像”竹筒敲石“一样,只在某个瞬间发生改变。而 sigmoid 函数不仅输出值是连续变化的,曲线的斜率(导数)也是连续变化的。也就是说,sigmoid 函数的导数在任何地方都不为 0。这对神经网络的学习非常重要。得益于这个斜率不会为 0 的性质,神经网络的学习得以正确进行。

4.3 数值微分

梯度法使用梯度的信息决定前进的方向。这里介绍梯度是什么,有什么性质。

导数

导数就是表示某个瞬间的变化量。它可以定义成下面的式子。

下面我们根据这个式子来实现求函数的导数的程序。

1
2
3
4
# 不好的导数实现示例
def numerical_diff(f, x):
h = 1e-50
return (f(x+h) - f(x)) / h

这个程序名称来自于数值微分的英文 numerical differentiation (所谓数值微分就是用数值方法近似求解函数的导数的过程;而基于数学式的推导求导数的过程,则用”解析性“(analytic)一词,称为解析性求解或解析性求导)。这个函数有2个参数,即函数 f 和传给函数 f 的参数 x 。

在上面的函数中,h 采用了一个尽可能小的值,但是这样会产生舍入误差(rounding error)。所谓摄入误差,指因省略小数的精细部分的数值而造成的计算结果上的误差。这是第一个需要改变的地方,这里可以将 h 改为

第二个需要改进的地方于函数f的差分有关。虽然上述实现中计算了函数 f 在 x+h 和 x 之间的差分。但是必须注意到,这个计算从一开始就有误差。”真的导数“对应函数在 x 处的斜率(称为切线),但上述实现中计算的导数对应的是 (x+h) 和 x 之间的斜率。因此,真的导数于上述实现中得到的导数的值在严格意义上并不一致。这个差异的出现是因为 h 不可能无限接近 0 。

如下图所示,数值微分含有误差。为了减小这个误差,我们可以计算函数 f 在 (x+h) 和 (x-h) 之间的差分。因为这种计算方法以 x 为中心,计算它左右两边的差分,所以也称为中心差分(而 (x+h) 和 x 之间的差分称为前向差分)。

下面,我们基于上述2个要改进的点来实现数值微分。

1
2
3
def numerical_diff(f, x):
h = 1e-4
return (f(x+h) - f(x-h)) / (2*h)

数值微分的例子

现在我们试着用上述的数值微分对简单函数进行求导。先看一个2次函数如下

用 python 来实现如下

1
2
def function_1(x):
return 0.01*(x**2) + 0.1*x

接下来,我们绘制这个函数的图像。代码如下

1
2
3
4
5
6
7
8
9
10
import numpy as np
import matplotlib.pylab as plt

x = np.arange(0.0, 20.0, 0.1) # 以0.1为单位,从0到20的数组x
y = function_1(x)

plt.xlabel("x")
plt.ylabel("f(x)")
plt.plot(x, y)
plt.show()

我们来计算一下这个函数在 x=5 和 x=10 处的导数。可以看到和真实导数误差很小。

1
2
numerical_diff(function_1, 5)
numerical_diff(function_1, 10)

偏导数

接下来,我们看一下下面的函数。这里有2个变量。

这个式子可以用 Python 来实现,如下所示

1
2
3
def function_2(x):
return x[0]**2 + x[1]**2
# return np.sum(x**2)

现在我们求该函数的导数。这里需要注意的是,这里有2个变量,我们需要区分对哪个变量求导。另外,我们把这里讨论的有多个变量的函数的导数称为偏导数,用数学式表达的话,可以写成 这种格式。

怎么求偏导数呢?我们先试着解一下下面2个关于偏导数的问题。

问题1:求 时,关于 的偏导数

1
2
3
4
def function_tmp1(x0):
return x0**2 + 4.0 ** 2

numerical_diff(function_tmp1, 3.0)
1
6.00000000000378

问题1:求 时,关于 的偏导数

1
2
3
4
def function_tmp2(x1):
return 3.0**2 + x1**2

numerical_diff(function_tmp2, 4.0)
1
7.999999999999119

在这些问题中,我们定义了一个只有一个变量的函数,并对这个函数进行求导,使用数值微分计算导数,结果和解析解的导数基本一致。

像这样,偏导数和单变量的导数一样,都是求某个地方的斜率。不过,偏导数需要将多个变量中的某一个变量定为目标变量,并将其它变量固定为某个值。

4.4 梯度

在刚才的例子,我们计算了两个变量的偏导数。现在我们希望一起计算 的偏导数。比如我们考虑 的偏导数 ,像这种由全部变量的偏导数汇总而成的向量称为梯度 (gradient)。

梯度可以像下面这种来实现。这里上面的数值微分的实现基本相同,只是对每一个变量逐个求数值微分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def numerical_gradient(f, x):
h = 1e-4
grad = np.zeros_like(x) # 生成和 x 形状相同的数组

for idx in range(x.size):
tmp_val = x[idx]

# f(x+h)的计算
x[idx] = tmp_val + h
fxh1 = f(x)

# f(x-h)的计算
x[idx] = tmp_val - h
fxh2 = f(x)

grad[idx] = (fxh1 - fxh2) / (2*h)
x[idx] = tmp_val # 还原值

return grad

举个例子看一下,没有问题,这里输出的是 [6., 8.] ,是因为输出 NumPy 数组时,数值会被改成“易读”的形式。

1
2
import numpy as np
numerical_gradient(function_2, np.array([3.0, 4.0]))
1
array([6., 8.])

为了更好的理解,我们把 的梯度画在图上。不过,这里我们画的负梯度的向量(因为负梯度方向才是梯度下降法中变量的更新方向)。

我们看所有梯度均指向函数的最小值,并且离“最低处”越远,箭头越大。

虽然图中的梯度指向了最低处,但并非任何时候都这样。实际上,梯度会指向各点处的函数值下降的方向。更严格地说,梯度指示的方向是各点处的函数值减少最多的方向,这是一个非常重要的性质。

梯度法

神经网络中的最佳参数就是指损失函数取最小值的参数。但是,一般而言,损失函数很复杂,参数空间庞大,我们不知道它在何处能取得最小值。我们可以用梯度法来寻找函数最小值。

梯度表示的是各点处的函数值减少最多的方向。因此,无法保证梯度所指的方向就是函数的最小值或者真正应该前进的方向。实际上,在复杂的函数中,梯度指示的方向基本上都不是函数最小值。

虽然梯度的方向并不一定指向最小值,但沿着它的方向能够最大限度地减少函数中的值,可以决定前进的方向。在梯度法中,每次从当前位置沿着梯度方向前进一定距离,然后在新的地方重新求梯度,这种不断沿着梯度方向前进,逐渐减少函数值的过程就是梯度法 (gradient method)。梯度法是解决机器学习中最优化问题的常用方法,特别是在神经网络的学习中经常被使用。根据目的是寻找最小值还是最大值,梯度法可以分为梯度下降法和梯度上升法,二者本质相同,一般来说,神经网络中梯度法主要是梯度下降法。

注:函数的极小值(局部最小值)、最小值以及鞍点 (saddle point) 的地方,梯度为0。鞍点是从某个方向上看是极大值,从另一个方向上看则是极小值的点。因为梯度法是寻找梯度为 0 的地方,但那个地方不一定是最小值(也可能是极小值或鞍点)。此外,当函数很复杂且呈扁平状时,学习可能会进入一个(几乎)平坦的提取,陷入被称为“学习高原”的无法前进的停滞期。

现在,我们尝试用数学式来表示梯度法,如下式

这里 表示更新量,在神经网络的学习中,称为学习率 (learning rate)。学习率过大或过小,都无法抵达一个“好的位置”。在神经网络的学习中,一般会一边改变学习率的值,一边确认学习是否正确进行了。

下面我们用 Python 来实现梯度下降法。如下所示

1
2
3
4
5
6
7
8
def gradient_descent(f, init_x, lr=0.01, step_num=100):
x = init_x

for i in range(step_num):
grad = numerical_gradient(f, x)
x -= lr * grad

return x

这里参数 f 是要进行最优化的函数,init_x 是初始值, lr 是学习率, step_num 是梯度法的重复次数。

使用这个函数可以求函数的极小值,顺利的话,还可以求函数的最小值。下面,我们来尝试解决这个问题,求解 的最小值的位置。可以看到结果非常接近 (0,0) 。

1
2
3
4
5
6
def function_2(x):
return x[0]**2 + x[1]**2
# return np.sum(x**2)

init_x = np.array([-3.0, 4.0])
gradient_descent(f=function_2, init_x=init_x, lr=0.1, step_num=100)
1
array([-6.11110793e-10,  8.14814391e-10])

前面说过,学习率过大或过小都无法得到好的结果。我们来做个实验验证一下。

这里有一个小细节,init_x 传入函数后,x=init_x 只是建立一个新标签,因此运行完函数后,同样会修改init_x 的内容。再次运行函数前,需要还原 init_x 的内容。

1
2
3
# 学习率过大的例子
init_x = np.array([-3.0, 4.0])
gradient_descent(f=function_2, init_x=init_x, lr=10.0, step_num=100)
1
array([-2.58983747e+13, -1.29524862e+12])
1
2
3
# 学习率过小的例子
init_x = np.array([-3.0, 4.0])
gradient_descent(f=function_2, init_x=init_x, lr=1e-10, step_num=100)
1
array([-2.99999994,  3.99999992])

实验结果表明,学习率过大的话,会发散成一个很大的值;反过来,学习率很小的话,基本上没怎么更新就结束了。也就是说,设定合适的学习率是一个很重要的问题。

注:像学习率这样的参数称为超参数。这种一种和神经网络的参数(权重和偏置)性质不同的参数。神经网络的权重参数是通过数据和学习算法自动获得的,但学习率这样的超参数则是人工设定的。一般来说,超参数需要尝试多个值,以便找到一种可以使学习顺利进行的设定。

神经网络的梯度

神经网络的学习也要求梯度。这里所说的梯度是指损失函数关于权重参数的梯度。比如,有一个形状为 2 × 3的权重 的神经网络,损失函数用 表示。此时梯度可以用 表示。用数学表达式的话,如下所示。

的元素由各个元素关于 的偏导数构成,并且 的形状相同。

下面我们以一个简单的神经网络为例,来实现求梯度的代码。为此,我们要实现一个名为 simpleNet 的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import sys, os
sys.path.append(os.pardir) # 为了导入父目录中的文件而进行的设定
import numpy as np
from common.functions import softmax, cross_entropy_error
from common.gradient import numerical_gradient

class SimpleNet:
def __init__(self):
self.W = np.random.randn(2,3) # 用正态分布进行初始化

def predict(self, x):
return np.dot(x, self.W)

def loss(self, x, t):
z = self.predict(x)
y = softmax(z)
loss = cross_entropy_error(y, t)

return loss

simpleNet 类只有一个实例变量,即形状为 2×3 的权重参数。它由2个方法,一个方法用于预测,另一个用于计算损失函数值。这里参数x 为输入数据,参数t接收正确解标签。

现在我们试着用一下这个 simpleNet 。

1
2
net = SimpleNet()
print(net.W)
1
2
[[ 1.40827669  0.87703568 -0.01796102]
[ 0.91787831 -0.37446688 0.54339771]]
1
2
3
4
5
6
7
8
x = np.array([0.6, 0.9])
p = net.predict(x)
print(p)

np.argmax(p) # 最大值的索引

t = np.array([0,0,1])
net.loss(x, t)

接下来求梯度。和前面一样,我们使用 numerical_gradient(f, x) 求梯度(这里使用的函数内容来自于 common/gradient.py ,和前面同函数内容略有不同,因为之前函数 x 只支持一维数组 )。

这里定义的函数 f(W) 中的参数 W 是一个伪参数,因为 numerical_gradient(f, net.W) 会在内部执行 f(x) ,为了与之兼容而定义了f(W) 。

1
2
3
4
5
def f(W):
return net.loss(x, t)

dW = numerical_gradient(f, net.W)
print(dW)
1
2
[[ 0.3920045   0.0890696  -0.48107409]
[ 0.58800674 0.13360439 -0.72161114]]

这里返回结果 dW 就是梯度矩阵,其形状也是 2×3。

另外,在上面的代码中,定义新函数时使用了 def f(x): 的形式。实际上,Python 中如果定义的时简单的函数,可以使用 lambda 表示法。使用 lambda 的情况下,上述代码可以如下实现。

1
2
3
f = lambda W: net.loss(x, t)
dW = numerical_gradient(f, net.W)
print(dW)

求出神经网络的梯度后,接下来只需根据梯度法,更新权重参数即可。

4.5 学习算法的实现

关于神经网络学习的基础知识,到这里就全部介绍完了。接下来我们确认一下神经网络的学习步骤,如下所示:

  • 前提:调整权重和偏置以便拟合训练数据过程称为“学习”,分为以下步骤。
  • 步骤1(mini-batch):从训练数据中随机选出一部分数据,称为 mini-batch 。我们的目标时减少 mini-batch 的损失函数的值。
  • 步骤2(计算梯度):计算出各个权重参数的梯度。
  • 步骤3(更新参数):将权重参数沿梯度方向进行微小更新。
  • 步骤4(重复):重复步骤1-3

由于这里使用的数据是随机选择的 mini batch 数据,因此称为随机梯度下降法(stochastic gradient descent)。“随机”指的是“随机选择的”意思,因此随机梯度下降法是“对随机选择的数据进行的梯度下降法”。在深度学习的很多框架中,随机梯度下降法一般由一个名为 SGD 的函数来实现,即该方法的首字母。

下面,我们来实现手写数字识别的神经网络。这里以2层神经网络(隐藏层为1层的网络)为对象,使用 MNIST 数据集进行学习。

2层神经网络的类

首先,我们将这个2层神经网络实现一个名为 TwoLayerNet 的类,实现过程如下。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# coding: utf-8
import sys, os
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
from common.functions import *
from common.gradient import numerical_gradient


class TwoLayerNet:

def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01):
# 初始化权重
self.params = {}
self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size)
self.params['b1'] = np.zeros(hidden_size)
self.params['W2'] = weight_init_std * np.random.randn(hidden_size, output_size)
self.params['b2'] = np.zeros(output_size)

def predict(self, x):
W1, W2 = self.params['W1'], self.params['W2']
b1, b2 = self.params['b1'], self.params['b2']

a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
y = softmax(a2)

return y

# x:输入数据, t:监督数据
def loss(self, x, t):
y = self.predict(x)

return cross_entropy_error(y, t)

def accuracy(self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1)
t = np.argmax(t, axis=1)

accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy

# x:输入数据, t:监督数据
def numerical_gradient(self, x, t):
loss_W = lambda W: self.loss(x, t)

grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])

return grads

def gradient(self, x, t):
W1, W2 = self.params['W1'], self.params['W2']
b1, b2 = self.params['b1'], self.params['b2']
grads = {}

batch_num = x.shape[0]

# forward
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
y = softmax(a2)

# backward
dy = (y - t) / batch_num
grads['W2'] = np.dot(z1.T, dy)
grads['b2'] = np.sum(dy, axis=0)

da1 = np.dot(dy, W2.T)
dz1 = sigmoid_grad(a1) * da1
grads['W1'] = np.dot(x.T, dz1)
grads['b1'] = np.sum(dz1, axis=0)

return grads

虽然这个类的实现稍微有点长,但是因为和上一章的神经网络的前向处理的实现有很多共通之处,所以没有太多新东西。我们把这个类中用到的变量和方法整理一下。这里只罗列了重要的变量。

这里有 params 和 grads 两个字典型实例变量。params 变量中保存了权重参数。

这里来看一个例子

1
2
3
4
5
6
net = TwoLayerNet(input_size=784, hidden_size=100, output_size=10)

net.params['W1'].shape # (784, 100)
net.params['b1'].shape # (100,)
net.params['W2'].shape # (100, 10)
net.params['b2'].shape # (10,)

如上所示,params 变量中保存了该神经网络所需的全部参数。并且,params 变量中保存的权重参数会用在推理处理(前向处理)中。顺便说一下,推断处理的实现如下所示

1
2
x = np.random.rand(100, 784) # 伪输入数据(100笔)
y = net.predict(x)

此外,与 params 变量对应,grads 变量中保存了各个参数的梯度。如下所示,使用 numerical_gradient() 方法计算梯度后,梯度的信息将保存在 grads 变量中。实例如下。

1
2
3
4
5
6
7
8
9
x = np.random.rand(100, 784) # 伪输入数据(100笔)
t = np.random.rand(100, 10) # 伪正确解标签(100笔)

grads = net.numerical_gradient(x, t) # 计算梯度

grads['W1'].shape # (784, 100)
grads['b1'].shape # (100,)
grads['W2'].shape # (100, 10)
grads['b2'].shape # (10,)

接着我们看一下 TwoLayerNet 的方法的实现。首先是 __init__() 方法,它是类的初始化方法(生成 TwoLayerNet 实例时被调用的方法)。从第1个参数开始,依次表示输入层的神经元数,隐藏层的神经元数、输出层的神经元数。 在进行手写数字识别时,输入层大小是 784,输出为 10, 至于隐藏层设置为一个合适的值即可。

此外,这个初始化方法会对权重参数进行初始化。如何设置权重参数的初始值这个问题是关系到神经网络能否成功学习的重要问题。后面会详细讨论权重参数的初始化,这里只需要知道,权重使用符合高斯分布的随机数进行初始化,偏置使用0进行初始化。predict(self, x) 和 accuracy(self, x, t) 的实现和上一章的神经网络的推理处理基本一样。loss(self, x, t) 会基于 predict() 函数的结果计算交叉熵误差。

剩下的 numerical_gradient(self, x, t) 方法会计算各个参数的梯度。其会根据数值微分,计算每个参数相对于损失函数的梯度。另外,gradient(self, x, t) 是下一章要实现的方法,该方法使用误差反向传播法高效地计算梯度。

mini-batch的实现

神经网络的学习的实现使用的是前面介绍过的 mini-batch 学习。所谓 mini-batch 学习,就是从训练数据中随机选择一部分数据(称为 mini-batch),再以这些 mini-batch 为对象,使用梯度法更新参数的过程。下面,我们就以 TwoLayerNet 类为对象,使用 MNIST 数据集进行学习。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# coding: utf-8
import sys, os
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
import numpy as np
import matplotlib.pyplot as plt
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet

# 读入数据
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)

network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)

iters_num = 10000 # 适当设定循环的次数
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1

train_loss_list = []
train_acc_list = []
test_acc_list = []

iter_per_epoch = max(train_size / batch_size, 1)

for i in range(iters_num):
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]

# 计算梯度
#grad = network.numerical_gradient(x_batch, t_batch)
grad = network.gradient(x_batch, t_batch)

# 更新参数
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]

loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)

if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
print("train acc, test acc | " + str(train_acc) + ", " + str(test_acc))

# 绘制图形
markers = {'train': 'o', 'test': 's'}
x = np.arange(len(train_acc_list))
plt.plot(x, train_acc_list, label='train acc')
plt.plot(x, test_acc_list, label='test acc', linestyle='--')
plt.xlabel("epochs")
plt.ylabel("accuracy")
plt.ylim(0, 1.0)
plt.legend(loc='lower right')
plt.show()

这里 mini-batch 的大小为 100, 需要每次从 60000 个训练数据中随机取出 100 个数据(图像数据和正确解标签数据)。然后,对这个包含100笔数据的 mini-batch 求梯度,使用随机梯度下降法(SGD)更新参数。这里,梯度法的更新次数(循环的次数)为10000。每更新一次,都对训练数据计算损失函数的值(这里是先更新参数,再用更新后的参数计算损失函数),并把该值添加到数组中,用图像来表示这个损失函数的值的推移,如下图所示(代码没有这个画图的过程)

从这个图可以发现,随着学习的进行,损失函数的值在不断减小。这表示神经网络通过反复学习,正在逐渐向最优参数靠近。

基于测试数据的评价

基于图 4-11 呈现的结果,我们确认了通过反复学习可以使损失函数的值逐渐减小这一事实。不过这个损失函数的值,严格地说是“对训练数据的某个 mini-batch 的损失函数”的值。训练数据的损失函数值减小,虽说是神经网络的学习正常进行的一个信号,但光看这个结果还不能说明该神经网络在其它数据集上也一定能有同等程度的表型。

神经网络的学习中,必须确认是否能够正确识别训练数据以外的其他数据,即确认是否会发生过拟合

神经网络学习的最初目标是掌握泛化能力,因此,要评价神经网络的泛化能力,就必须使用不包含在训练数据中的数据。下面的代码在进行学习的过程中,会定期地对训练数据和测试数据记录识别精度。这里,每经过一个 epoch (epoch是一个单位,表示学习中所有训练数据均被使用过),我们都会记录下训练数据和测试数据的识别精度。

epoch 举例:对于1万笔训练数据,用大小为 100笔数据的 mini-batch 进行学习时,重复随机梯度下降法 100 次,所有训练数据就都被“看过”。此时 100 次就是一个 epoch 。实际上,一般做法时先将训练数据随机打乱,然后按指定的批次大小,按序生成 mini-batch 。这样每个 mini-batch 均有一个索引号,比如 0,1,2,…,99 ,然后用索引号可以遍历所有的 mini-batch ,这里遍历一次所有数据,就称为一个 epoch 。请注意,本书中的 mini-batch 每次都是随机选择的,所以不一定每个数据都会被“看到”。

因此,这里我们每经过一个 epoch ,就对所有的训练数据和测试数据计算识别精度,并记录结果。之所以要计算每一个 epoch 的识别精度,是因为如果在每一次重复随机梯度下降法中均计算识别精度,会花费太多时间,而且也没有必要那么频繁地记录识别精度(只要从大方向上大致把握识别精度的变化就可以了)。因此,我们才会每经过一个 epoch 就记录一次训练数据的识别精度。

把上面的代码中得到的结果用图表示,如下图所示,这说明,随着 epoch 的前进,我们发现使用训练数据和测试数据评价的识别精度都提高了,并且二者基本没有差异,这说明这次的学习中没有发生过拟合的现象(反过来说,如果训练数据和测试数据的识别精度差别很大,那就是存在过拟合了)。

第5章 误差反向传播法

上一章中介绍了通过数值微分计算神经网络的权重参数的梯度(严格来说,是损失函数关于权重参数的梯度)。数值微分虽然简单,也容易实现,但缺点是计算上比较费时间。本章我们将学习一个能够高效计算权重参数的梯度的方法 —— 误差反向传播法。

要正确理解误差反向传播法,有两种方法:一种是基于数学式;另一种是基于计算图(computational graph)。前者是比较常用的方法,因为数学式严密且简洁,但是一上来就围绕数学式进行探讨,会忽略一些根本的东西,止步于式子的罗列。因此,本章希望通过计算图,可以直观地理解误差反向传播法。

5.1 计算图

计算图将计算过程用图形表示出来。这里说的图形是数据结构图,通过多个节点和边表示(连接节点的直线称为“边”)。为了让大家熟悉计算图,本节先用计算图解决一些简单的问题,然后逐步深入。

用计算图求解

现在,我们用计算图解决简单问题。

问题1:太郎在超市买了2个100日元一个的苹果,消费税是10%,请计算支付金额。

计算图通过节点和箭头表示计算过程,节点用⚪表示,⚪中式计算的内容。将计算的中间结果写在箭头的上方,表示各个节点的计算结果从左向右传递。用计算图解问题1,求解过程如下图所示

如图所示,开始时,苹果的100日元流到 “×2” 节点,变成 200 日元,然后传递给下一个节点。接着,这个200日元流向“×1.1”节点,变成220日元。因此,最终答案是220日元。

虽然图中的 “×2” “×1.1” 等作为一个运算整体用⚪括起来了,不过只用⚪表示乘法运算“×”也是可行的。此时,如图所示,可以将“2”和“1.1”分别作为变量“苹果的个数”和“消费税”标记在⚪外面。

再看下一题

问题2: 太郎在超市买了2个苹果、3个橘子。其中,苹果每个100日元,
橘子每个150日元。消费税是10%,请计算支付金额。

同问题1,我们用计算图来求解,如下图所示

这个问题中新增了加法节点 “+” ,用于合计苹果和橘子的金额。构建了计算图后,从左向右进行计算。就像电路中的电流流动一样,计算结果从左向右传递。到达最右边的计算结果后,计算过程就结束了。

综上,用计算图解题的情况下,需要按如下流程进行

  1. 构建计算图
  2. 在计算图上,从左向右进行计算

这里的第2步“从左向右进行计算”是一种正方向上的传播,简称为正向传播 (forward propagation)。正向传播是从计算图出发点到结束点的传播。既然有正向传播这个名称, 当然也可以考虑反向的传播,即反向传播(backward propagation)。

局部计算

计算图的特征是可以通过传递“局部计算”获得最终结果。“局部”这个词的意思是“与自己相关的某个小范围”。局部计算是指,无论全局发生了什么,都能只根据自己相关的信息输出接下来的结果。

我们用一个具体的例子来说明局部计算。比如,在超市买了2个苹果和其他很多东西。此时,可以画出下图所示的计算图。

如图所示,假设(经过复杂的计算)购买的其他很多东西总共花费 4000 日元。这里的重点是,各个节点处的计算都是局部计算。这意味着,例如苹果和其他很多东西的求和运算,并不关心 4000 这个数字是如何计算而来的,只要把两个数字相加就可以了。换言之,各个节点只需进行与自己有关的计算(这里是对两个数字进行加法运算),不用考虑全局。

综上,计算图可以集中精力于局部计算。无论全局计算有多么复杂,各个步骤要做的就是对象节点的局部计算。虽然局部计算非常简单,但是通过传递它的计算结果,可以获得全局的复杂计算的结果。

比如,组装汽车是一个复杂的工作,通过需要进行“流水线”作业。每个工人(机器)所承担的都是被简化了的工作,这个工作的成果会传递给下一个工人,直至汽车组装完成。计算图将复杂的计算分隔成简单的局部计算,和流水线作业一样,将局部计算的结果传递给下一个节点。在复杂的计算分解成简单的计算这一点上与汽车的组装有相似之处。

为何用计算图解题

前面我们用计算图解答了两个问题,那么计算图到底有什么优点呢?一个优点就在于前面所说的局部计算。无论全局是多么复杂的计算,都可以通过局部计算使各个节点致力于简单的计算,从而简化问题。另一个优点是,利用计算图可以将中间的计算结果全部保存起来。但是只有这些理由可能还无法令人信服。实际上,使用计算图最大的原因是,可以通过反向传播高效计算导数。

在介绍计算图的反向传播时,我们再来思考一下问题1。问题1中,我们计算了购买2个苹果时加上消费税最终需要支付的金额。这里,假设我们想知道苹果价格的上涨会在多大程度上影响最终的支付金额,即求“支付金额关于苹果的价格的导数”。设苹果的价格为 ,支付金额为 ,求相当于求 。这个导数的值表示当苹果的价格稍微上涨时,支付金额会增加多少。

如前所述,“支付金额关于苹果的价格的导数”的值可以通过计算图的反向传播求出来。先来看一下结果,如下图所示,可以通过计算图的反向传播求导数。

如图所示,反向传播使用与正方向相反的箭头(粗线)表示。反向传播传递“局部导数”,将导数的值写在箭头的下方。在本例中,反向传播从右到左传递导数的值(1 → 1.1 → 2.2)。从这个结果中,“支付金额关于苹果的价格的导数”的值是 2.2 。这意味着,如果苹果的价格上涨1日元,最终的支付金额会增加 2.2 日元(严格地说,如果苹果的价格增加某个微小值,则最终的支付金额将增加那个微小值的2.2倍)。

这里只求了关于苹果的价格的导数,不过“支付金额关于消费税的导数”,“支付金额关于苹果的个数的导数”等也都可以用同样的方式算出来。并且,计算中途求得的导数的结果(中间传递的导数)可以被共享,从而可以高效地计算多个导数。综上,计算图的优点是,可以通过正向传播和反向传播高效地计算各个变量的导数值。

5.2 链式法则

前面介绍的计算图的正向传播将计算结果正向传递,其计算过程是我们日常接触的计算过程,所以感觉上可能比较自然。而反向传播将局部导数向正方向的反方向(从右向左)传递,一开始可能会让人感到困惑。传递这个局部导数的原理,是基于链式法则 (chain rule) 的。下面将介绍链式法则,以及它是如何对应计算图上的反向传播的。

计算图的方向传播

话不多说,让我们先来看一个使用计算图的反向传播的例子。假设存在 的计算,这个计算的反向传播如下图所示。

如图所示,反向传播的计算顺序是,将信号E乘以节点的局部导数 ( ) ,然后将结果传递给下一个节点。这里所说的局部导数是指正向传播中 的导数,也就是 y 关于 x 的导数,例如 ,则局部导数为 。把这个局部导数乘以上游传递过来的值(本例中为 E )。然后传递给前面的节点。

这就是反向传播的计算顺序。通过这样的计算,可以高效地求出导数的值,这是反向传播的要点。那么这是如何实现的呢?我们可以从链式法则的原理进行解释。下面我们就来介绍链式法则。

什么是链式法则

介绍链式法则时,我们需要先从复合函数说起。复合函数是由多个函数构成的函数。比如 是由下面的两个式子构成的

链式法则是关于复合函数的导数的性质,定义如下。

如果某个函数由复合函数表示,则该复合函数的导数可以用构成复合函数的各个函数的导数的乘积表示。

这就是链式法则的原理,乍一看可能比较难理解,但实际上它是一个非常简单的性质。数学式如下

式子中的 正好可以像下面这样“互相抵消”,所以记起来很简单。

现在我们使用链式法则,试着求 , 为此,我们要先求局部导数。

然后我们由导数的乘积计算出来

链式法则和计算图

现在我们尝试用链式法则的计算用计算图表示出来。如果用 “**2” 表示平方运行的话,则计算图如下所示

如图所示,计算图的反向传播从右到左传播信号。反向传播的计算顺序是,先将节点的输入信号乘以节点的局部导数(偏导数),然后再传递给下一个节点。比如,反向传播是,“**2” 节点的输入是 ,将其乘以局部导数 (因为正向传播时输入是 t ,输出是 z ,所以这个节点的局部导数是 ) ,然后传递给下一个节点。另外,图中最开始的信号 在前面的数学式中没有出现,这是因为 ,所以在刚才的式子中被省略了。

图中需要注意的是最左边的反向传播的结果。根据链式法则, ,对应 “z 关于 x 的导数”。也就是说,反向传播是基于链式法则的。

将上面式子的结果带入到图中,结果如下图所示,得到 的结果为

5.3 反向传播

上一节介绍了计算图的反向传播是基于链式法则成立的。本节将以 “+” 和 “×” 等运算为例,介绍反向传播的结构。

加法节点的反向传播

首先来考虑加法节点的反向传播。这里以 为对象,观察它的反向传播。 的导数可由下式(解析性地)计算出来

可以看到,两个变量的偏导数均为1。因此,用计算图表示的话,如下图所示。加法节点的反向传播只乘以1,所以输入的值会原封不动地流向下一个节点。

另外,本例中把上游传过来的导数的值设为 。这是因为,如图所示,我们假定了一个一个最终输出值为 的大型计算图。 的计算位于这个大型计算图的某个地方,从上游会传来 的值,并向下游传递

现在来看一个加法的反向传播的具体例子。假设有 “10+5=15”这一计算,反向传播时,从上游会传来值 1.3 。用计算图来表示的话,如下图所示

因为加法节点的反向传播只是将输入信号输出到下一个节点,所以如图所示,反向传播将 1.3 向下一个节点传递。

乘法节点的反向传播

接下来,我们看一下乘法节点的反向传播。这里我们考虑 。这个式子的导数用下式表示

因此我们可以得到下面的计算图和例子。乘法的反向传播会将上游的值乘以正向传播时的输入信号的“翻转值”后传递给下游。翻转值表示一种翻转关系,输入是 ,反向传播则是

在具体例子中,假设有 “10 × 5 = 50” 这一计算,反向传播时,从上游会传来值 1.3 ,此时按照反向传播,所以各自得到 “1.3×5=6.5,1.3×10=13” 计算。由于实现乘法节点的反向传播需要正向传播时的输入信号值,因此需要保存正向传播时的输入信号。

苹果的例子

再来思考一下一开始买苹果的例子,这里要解的问题是苹果的价格、苹果的个数、消费税这3个变量各自如何影响最终支付的金额。这个问题相当于求“支付金额关于苹果的价格的导数”,“支付金额关于苹果的个数的导数” “支付金额关于消费税的导数”。用计算图的反向传播来解的话,求解过程如下所示

如前所述,乘法节点的反向传播会见输入信号翻转后传给下游。因此从上图的结果可知,苹果的价格的导数是 2.2 ,苹果的个数的导数是 110,消费税的导数是 200 。这可以解释为,如果消费税和苹果的价格增加相同的值,则消费税将对最终价格产生 200 倍的影响,苹果的价格将产生 2.2 倍大小的影响。不过,因为这个例子中消费税和苹果的价格的量纲不同,所以才形成了这样的结果(消费税的 1 是100% ,苹果的价格的1是1日元)。

5.4 简单层的实现

本节将用 Python 实现前面的购买苹果的例子。这里,我们将要实现的计算图的乘法节点成为“乘法层”(MulLayer),加法节点称为“加法层”(AddLayer)。这里的“层”在神经网络中是功能的单位,如 sigmoid 函数,负责矩阵乘积的 Affine 等,都以层为单位进行实现。

乘法层的实现

层的实现中有两个共通的方法(接口)forward()backward()forward() 对应正向传播,backward() 对应反向传播。

现在来实现乘法层。乘法层作为 MulLayer 类,其实现过程如下(问了 DS ,一般而言,值固定不变的变量放在 __init__()函数的输入参数中,值可能改变的参数则放在 __init__()函数的正文中,所有需要存储的属性均要在 __init__()函数中定义 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MulLayer:
def __init__(self):
self.x = None
self.y = None

def forward(self, x, y):
self.x = x
self.y = y
out = x * y

return out

def backward(self, dout):
dx = dout * self.y
dy = dout * self.x

return dx, dy

__init__() 会初始化实例变量 x 和 y ,它们用于保存正向传播时的输入值。froward() 接收 x 和 y 两个参数,将它们相乘后输出。backward() 将上游传来的导数(dout)乘以正向传播的翻转值,然后传给下游。

上面就是 MulLayer 的实现。现在我们使用 MulLayer 实现前面的购买评估的例子(2个苹果和消费税)。上一节中我们使用计算图的正向传播和反向传播,如下图。

使用这个乘法层的话,那个例子的正向传播可以像下面这样实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
apple = 100
apple_num = 2
tax = 1.1

# layer
mul_apple_layer = MulLayer()
mul_tax_layer = MulLayer()

# forward
apple_price = mul_apple_layer.forward(apple, apple_num)
price = mul_tax_layer.forward(apple_price, tax)

print(price)
1
220.00000000000003

关于各个变量的导数可由 backward() 求出。

1
2
3
4
5
# 反向传播
dprice=1
dapple_price, dtax = mul_tax_layer.backward(dprice)
dapple, dapple_num = mul_apple_layer.backward(dapple_price)
print(dapple, dapple_num, dtax) # 2.2 110 20

这里,这里调用 backward() 的顺序与调用 forward() 的顺序相反。此外,要注意 backward() 的参数中需要输入“关于正向传播时的输出变量的导数”。比如,mul_apple_layer() 乘法层在正向传播时会输出 apple_price , 在反向传播时,则会将 apple_price 的导数 dapple_price 设为参数。

加法层的实现

类似地,我们实现加法节点的加法层,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AddLayer:
def __init__(self):
pass

def forward(self, x, y):
out = x + y

return out

def backward(self, dout):
dx = dout * 1
dy = dout * 1

return dx, dy

加法层不需要特意进行初始化,所以 __init__() 中什么也不运行(因为不需要保留 x 和 y,反向传播用不上)。

现在我们用加法层和乘法层,实现上面购买2个苹果和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
27
28
29
30
31
32
33
34
# coding: utf-8
from layer_naive import *

apple = 100
apple_num = 2
orange = 150
orange_num = 3
tax = 1.1

# layer
mul_apple_layer = MulLayer()
mul_orange_layer = MulLayer()
add_apple_orange_layer = AddLayer()
mul_tax_layer = MulLayer()

# forward
apple_price = mul_apple_layer.forward(apple, apple_num) # (1)
orange_price = mul_orange_layer.forward(orange, orange_num) # (2)
all_price = add_apple_orange_layer.forward(apple_price, orange_price) # (3)
price = mul_tax_layer.forward(all_price, tax) # (4)

# backward
dprice = 1
dall_price, dtax = mul_tax_layer.backward(dprice) # (4)
dapple_price, dorange_price = add_apple_orange_layer.backward(dall_price) # (3)
dorange, dorange_num = mul_orange_layer.backward(dorange_price) # (2)
dapple, dapple_num = mul_apple_layer.backward(dapple_price) # (1)

print("price:", int(price))
print("dApple:", dapple)
print("dApple_num:", int(dapple_num))
print("dOrange:", dorange)
print("dOrange_num:", int(dorange_num))
print("dTax:", dtax)

这个实现稍微有点参,但是每一条命令都很简单。首先,生成必要的层,以合适的顺序调用正向传播的 forward() 方法。然后,与正向传播相反的顺序调用反向传播的 backward() 方法,就可以求出想要的导数。

综上,计算图中层的实现(这里是加法层和乘法层)非常简单,使用这些层可以进行复杂的导数计算。下面,我们来实现神经网络中使用的层。

5.5 激活函数层的实现

现在,我们将计算图的思路应用到神经网络中。这里,我们把构成神经网络的层实现一个类。先来实现激活函数的 ReLU 层和 Sigmoid 层。

ReLU 层

激活函数 ReLU 函数,其式子如下

其导数如下

在这个式子中,如果正向传播的输入 大于0,则反向传播会将上游的值原封不动地传给下游。反过来,如果正向传播时的 小于等于0,则反向传播中传给下游的信号将停在此处。用计算图表示的话,如下图所示。

现在我们来实现 ReLU 层,在神经网络的层的实现中,一般假定 forward() 和 backward() 的参数是 NumPy 数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Relu:
def __init__(self):
self.mask = None

def forward(self, x):
self.mask = (x <= 0)
out = x.copy()
out[self.mask] = 0

return out

def backward(self, dout):
dout[self.mask] = 0
dx = dout

return dx

Relu 类有实例变量 mask 。这个变量 mask 是由 True/False 构成的 NumPy 数组,它把正向传播时的输入 x 的变量中小于等于0的地方保存为 True ,其他地方保存为 False 。如下例所示,mask 变量保存了由 True/False 构成的 NumPy 数组。

Relu 层的作用就像电路中的开关一样。正向传播时,有电流通过的话,就将开关设为 ON ;没有电流通过的话,就将开关设为 OFF。反向传播时,开关为 ON 的时候,电流会直接通过;开关为 OFF 的话,则不会有电流通过。

Sigmoid 层

接下来,我们来实现 sigmoid 函数。sigmoid 函数由下式表示:

用计算图来表示的话,如下图所示

这里除了 “×” 和 “+” 节点外,还出现了新的 “exp” 和 “/” 节点。“exp” 节点会进行 的计算,“/” 节点会进行 的计算。

这里我们进行计算图的反向传播,如下图所示(具体步骤我省略了)

简化一下,就可以得到简洁版的 “sigmoid” 节点。

并且其反向传播公式可以进一步整理如下

因此,其实 Sigmoid 的反向传播,只根据正向传播的输出就能计算出来。

现在,我们用 Python 实现 Sigmoid 层,如下,这里将正向传播时的输出值保存为实例变量 out 。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Sigmoid:
def __init__(self):
self.out = None

def forward(self, x):
out = sigmoid(x)
self.out = out
return out

def backward(self, dout):
dx = dout * (1.0 - self.out) * self.out

return dx

5.6 Affine/Softmax 层的实现

Affine 层

神经网络的正向传播中,为了计算矩阵的乘积运算。而这种 的计算在几何学领域被称为“仿射变换”(仿射变换包括一次线性变换和一次平移,分别对应着神经网络中的加权和加偏置运算),因此这里将进行仿射变换的处理实现为 “Affine层”。

采用计算图的方式描述如下图,不过这里各个节点传播的是矩阵。

现在我们来考虑其反向传播。以矩阵为对象的反向传播,按矩阵的各个元素进行计算时,步骤和以标量为对象的计算图相同。实际写一下的话,可以得到下式(缺证明过程)

这里式子中的 T 表示转置,举例如下

现在,我们根据上面的式子,尝试写出计算图的反向传播,如下所示

我们来看各个变量的形状。要注意, 形状相同, 形状相同。从下面的数学式可以明确地看出 形状相同。

为什么要注意矩阵的形状呢?因为矩阵的乘积运算要求对应维度的元素个数保持一致。

批版本的 Affine 层

前面介绍的 Affine 层的输入 是以单个数据为对象的。现在我们考虑 个数据一起进行正向传播的情况,也就是批版本的 Affine 层,如下图所示

与刚刚不同的是,现在输入 的形状是 。之后就和前面一样,在计算图上进行单纯的矩阵计算。

加上偏置时,需要特别注意。正向传播时,偏置被加到 的各个数据上。比如, (数据为 2个)时,偏置会被分别加到这 2 个数据(各自的计算结果)上,具体的例子如下所示

1
2
3
4
5
X_dot_W = np.array([[0, 0, 0], [10, 10, 10]])
B = np.array([1, 2, 3])

X_dot_W
X_dot_W + B
1
2
3
4
5
array([[ 0,  0,  0],
[10, 10, 10]])

array([[ 1, 2, 3],
[11, 12, 13]])

正向传播时,偏置会被加到每一个数据(第1个,第2个…)上。因此,反向传播时,各个数据的反向传播的值需要汇总为偏置的元素 (缺证明过程)。用代码表示如下

1
2
3
4
5
dY = np.array([[1, 2, 3,], [4, 5, 6]])
dY

dB = np.sum(dY, axis=0)
dB
1
2
3
4
array([[1, 2, 3],
[4, 5, 6]])

array([5, 7, 9])

这个例子中,假定数据有 2 个 (N=2)。偏置的反向传播会对这2个数据的导数按元素进行求和。因此这里使用了 np.sum() 对第0轴(以数据为单位的轴)方向上的元素进行求和。

综上所述,Affine 的实现如下所示。(common/layers.py 中的 Affine 的实现考虑了输入数据为张量的情况,即四维数据,与这里稍有差别)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Affine:
def __init__(self, W, b):
self.W = W
self.b = b
self.x = None
self.dW = None
self.db = None

def forward(self, x):
self.x = x
out = np.dot(x, self.W) + self.b
return out

def backward(self, dout):
dx = np.dot(dout, self.W.T)
self.dW = np.dot(self.x.T, dout)
self.db = np.sum(dout, axis=0)
return dx

Softmax-with-Loss 层

最后介绍一下输出层的 softmax 函数。softmax 函数会将输入值正规化之后再输出。比如手写数字识别时,softmax 层的输出如下所示

图中,softmax 层将输入值正规化(将输出值的和调整为1)之后再输出。另外,因为手写数字识别要进行10类分类,所以向 softmax 层的输入也有 10 个。

下面来实现 softmax 层,考虑到这里也包含作为损失函数的交叉熵误差,所以称为 “softmax-with-loss层”。softmax-with-loss 层的计算图如下所示

可以看到,softmax-with-loss 层有些复杂。这里只给出了最终结果,对 Softmax-with-Loss 层的导出过程感兴趣的读者,请参照附录A。

其计算图可以简化成下图

这里要注意的是反向传播的结果。softmax 层得到了 这样“漂亮”的结果,这是神经网络学习中的重要性质(实际上,这样“漂亮”的结果并不是偶然的,而是为了得到这样的结果,特意设计了交叉熵误差函数。回归问题中输出层中使用“恒等函数”,损失函数使用“平方和误差”,也是出于同样的理由)。

神经网路学习的目的就是通过调整权重参数,使神经网络的输出(softmax的输出)接近标签。因此,必须将神经网络的输出与训练标签的误差高效地传递给前面的层。刚刚的 正是 softmax 层的输出与教师标签的差,直截了当地表示了当前神经网路的输出与训练标签的误差。

如果神经网路的输出结果与训练标签相差很大,那么反向传播会传递一个很大的误差,因此下次学习会从这个误差中学习到较“大”的内容。反过来如果神经网路的输出结果与训练标签相差很小,那么反向传播会传递一个很小的误差,因此下次学习会从这个误差中学习到较“小”的内容。

现在来进行 softmax 层的实现,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SoftmaxWithLoss:
def __init__(self):
self.loss = None # 损失
self.y = None # softmax的输出
self.t = None # 监督数据(one-hot vector)

def forward(self, x, t):
self.t = t
self.y = softmax(x)
self.loss = cross_entropy_error(self.y, self.t)
return self.loss

def backward(self, dout=1):
batch_size = self.t.shape[0]
dx = (self.y - self.t) / batch_size
return dx

注意这里反向传播时,将要传播的值除以批的大小(batch_size)后,传递给前面的层的是单个数据的误差。

为什么要除以 batch_size

之前的推导是基于单个样本,但是批处理的损失函数是所有样本的平均误差,因此要除以 ,其公式如下

其中:

  • :batch_size(批量大小)
  • :分类类别数
  • :第i个样本第k类的真实标签(one-hot)
  • :第i个样本第k类的预测概率

因此批处理和单个样本损失函数就差在在 上了,批处理的梯度如下:

对输入x求偏导可得梯度:

  • :损失对第i个样本第j类输入的梯度

误差反向传播法的实现

通过像组装乐高积木一样组装上一节中实现的层,可以构建神经网络。本节我们将通过组装已经实现的层来构建神经网络。

神经网络学习的全貌图

在进行具体的实现之前,我们再来确认一下神经网络学习的全貌图。步骤如下

前提:神经网络中有合适的权重和偏置,调整权重和偏置以便拟合训练数据的过程称为学习。神经网络的学习分为以下4个步骤。

  1. mini-batch : 从训练数据中随机选择一部分数据。
  2. 计算梯度: 计算损失函数关于各个权重参数的梯度。
  3. 更新参数:将权重参数沿着梯度方向进行微小的更新。
  4. 重复:重复步骤1-3。

之前介绍的误差方向传播法会在步骤2中出现。上一章中,我们利用数值微分求得了这个梯度。数值微分虽然实现简单,但是计算要耗费较多的时间,与之相比,误差反向传播法可以快速高效地计算梯度。

对应误差反向传播法地神经网络的实现

现在来进行神经网络的实现。这里我们把 2 层神经网络实现为 TwoLayerNet 。首先,把这个类的实例变量和方法整理成下面2个表。

具体代码如下,这里和 4.5 节的学习算法的实现有很多共通的部分,不同点主要在于这里使用了层。通过使用层,获得识别结果的处理 (predict()) 和计算梯度的处理(gradient())只需通过层之间的传递就能完成(?)。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# coding: utf-8
import sys, os
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
import numpy as np
from common.layers import *
from common.gradient import numerical_gradient
from collections import OrderedDict


class TwoLayerNet:

def __init__(self, input_size, hidden_size, output_size, weight_init_std = 0.01):
# 初始化权重
self.params = {}
self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size)
self.params['b1'] = np.zeros(hidden_size)
self.params['W2'] = weight_init_std * np.random.randn(hidden_size, output_size)
self.params['b2'] = np.zeros(output_size)

# 生成层
self.layers = OrderedDict()
self.layers['Affine1'] = Affine(self.params['W1'], self.params['b1'])
self.layers['Relu1'] = Relu()
self.layers['Affine2'] = Affine(self.params['W2'], self.params['b2'])

self.lastLayer = SoftmaxWithLoss()

def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x)

return x

# x:输入数据, t:监督数据
def loss(self, x, t):
y = self.predict(x)
return self.lastLayer.forward(y, t)

def accuracy(self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1)
if t.ndim != 1 : t = np.argmax(t, axis=1)

accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy

# x:输入数据, t:监督数据
def numerical_gradient(self, x, t):
loss_W = lambda W: self.loss(x, t)

grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])

return grads

def gradient(self, x, t):
# forward
self.loss(x, t)

# backward
dout = 1
dout = self.lastLayer.backward(dout)

layers = list(self.layers.values())
layers.reverse()
for layer in layers:
dout = layer.backward(dout)

# 设定
grads = {}
grads['W1'], grads['b1'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
grads['W2'], grads['b2'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

return grads

注意这里 gradient() 函数中的实现部分,尤其是将神经网络的层保存位 OrderDict 这一点非常重要,因为其是有序字典,可以记住往字典中添加元素的顺序。因此,神经网络的正向传播中只需按照添加元素的顺序调用各层的 forward() 方法就可以完成处理,而反向传播只需要按照相反的顺序调用各层即可。因为 Affine 层 和 ReLU 层的内部会正确处理正向和反向传播,所以这里要做的事情仅仅是以正确的顺序连接各层,再按顺序(或者逆序)调用各层。

像这样通过将神经网络的组成元素以层的方式实现,可以轻松地构建神经网络。这个用层进行模块化的实现具有很大优点。因为想另外构建一个神经网络(比如5层、10层、20层…大的神经网络)时,只需像组装乐高积木那样添加必要的层就可以了。之后通过各个层内部实现的正向传播和反向传播,就可以正确计算进行识别处理或学习所需的梯度。

具体解释一下 gradient() 函数部分

  1. 核心作用
1
def gradient(self, x, t):

这个函数实现反向传播算法,高效计算所有权重参数(W1, b1, W2, b2)关于损失函数的梯度

  1. 前向传播阶段
1
2
# forward
self.loss(x, t)

首先执行前向传播:

  • 数据通过网络各层传递
  • 计算最终损失值
  • 关键作用:在传递过程中,每一层都保存了反向传播所需的中间结果
  1. 反向传播启动
1
2
3
# backward
dout = 1
dout = self.lastLayer.backward(dout)
  • dout = 1:从损失函数开始反向传播,导数初始化为1(损失函数对自身偏导为1)

  • 调用

    1
    SoftmaxWithLoss.backward()

    :计算损失层关于输入的梯度

    • 如前所述,这会返回 (y - t)/batch_size
    • 这个梯度将作为反向传播的起始点
  1. 逐层反向传播
1
2
3
4
layers = list(self.layers.values())
layers.reverse()
for layer in layers:
dout = layer.backward(dout)

这是反向传播的核心:

  1. 获取所有网络层(从输入到输出):

    1
    2
    3
    4
    5
    self.layers = OrderedDict([
    'Affine1': Affine(...),
    'Relu1': Relu(...),
    'Affine2': Affine(...)
    ])
  2. 反转网络层顺序(从输出到输入):

    1
    ['Affine2', 'Relu1', 'Affine1']  # 反向传播顺序
  3. 逐层计算梯度:

    1
    2
    for layer in reversed_layers:
    dout = layer.backward(dout)
    • 每次backward()接收上层回传的梯度dout
    • 计算当前层参数的梯度
    • 返回对输入的梯度,作为下层backward()的输入
  4. 反向传播过程详解

以下表格展示反向传播在神经网络中的流动过程:

传播顺序 层类型 反向传播计算 输出梯度
1 SoftmaxWithLoss dx = (y - t)/batch_size 损失对Affine2输出的梯度
2 Affine2 (全连接) dW = dout · x^T
db = sum(dout)
dx = W^T · dout
损失对ReLu1输出的梯度
3 ReLu1 (激活函数) dx = dout * mask
(mask是前向传播的"开关")
损失对Affine1输出的梯度
4 Affine1 (全连接) dW = dout · x^T
db = sum(dout)
dx = W^T · dout
损失对网络输入的梯度(不需要)
  1. 提取梯度结果
1
2
3
grads = {}
grads['W1'], grads['b1'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
grads['W2'], grads['b2'] = self.layers['Affine2'].dW, self.layers['Affine2'].db
  • 从每个Affine层获取计算好的梯度

  • 组织成字典形式返回:

    1
    2
    3
    4
    5
    6
    grads = {
    'W1': Affine1的权重梯度,
    'b1': Affine1的偏置梯度,
    'W2': Affine2的权重梯度,
    'b2': Affine2的偏置梯度
    }

梯度反向传播法的梯度确认

到目前为止,我们介绍了两种求梯度的方法。一种是基于数值微分的方法,另一种是解析性地求解数学式的方法。后一种方法通过使用误差反向传播法,即使存在大量的参数,也可以高效地计算梯度。因此,后文将不再使用耗费时间的数值微分,而是使用误差反向传播法求梯度。

数值微分的计算很耗费时间,而且如果有误差反向传播法的(正确的)实现的话,就没有必要使用数值微分的实现了。那么数值微分有什么用呢?实际上,在确认误差反向传播法的实现是否正确时,是需要用到数值微分的。

数值微分的优点是实现简单,因此,一般情况下不太容易出错。而误差反向传播法的实现很复杂,容易出错。所以,经常会比较数值微分的结果和误差反向传播法的结果,以确认误差反向传播法求出的结果是否一致(严格地讲,是非常相近)的操作称为梯度确认(gradient check)。梯度确认的代码实现如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# coding: utf-8
import sys, os
sys.path.append(os.pardir) # 为了导入父目录的文件而进行的设定
import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet

# 读入数据
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)

network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)

x_batch = x_train[:3]
t_batch = t_train[:3]

grad_numerical = network.numerical_gradient(x_batch, t_batch)
grad_backprop = network.gradient(x_batch, t_batch)

for key in grad_numerical.keys():
diff = np.average( np.abs(grad_backprop[key] - grad_numerical[key]) )
print(key + ":" + str(diff))

这里以以前一样,读入 MNIST 数据集。然后,使用训练数据的一部分(这里只用了3个样本),确认数值微分求出的梯度和误差反向传播法求出的梯度的误差。这里误差的计算方法是求各个权重参数中对应元素的差的绝对值,并计算其平均值,得到以下结果

1
2
3
4
W1:3.391634193486504e-10
b1:2.055832223869726e-09
W2:5.087280809061959e-09
b2:1.4000537296027237e-07

从这个结果可以看出,通过数值微分和误差反向传播法求出的梯度的差非常小,这样一来,我们就知道了通过误差反向传播法求出的梯度是正确的,误差反向传播法的实现没有错误。

使用误差反向传播法的学习

最后我们来看一下使用误差反向传播法的神经网络的学习的实现。和之前的实现相比,不同之处仅在于通过误差反向传播法求梯度这一点。这里只列出了代码,省略了说明

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# coding: utf-8
import sys, os
sys.path.append(os.pardir)

import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet

# 读入数据
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)

network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)

iters_num = 10000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1

train_loss_list = []
train_acc_list = []
test_acc_list = []

iter_per_epoch = max(train_size / batch_size, 1)

for i in range(iters_num):
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]

# 梯度
#grad = network.numerical_gradient(x_batch, t_batch)
grad = network.gradient(x_batch, t_batch)

# 更新
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]

loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)

if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
print(train_acc, test_acc)

5.8 小结

本章我们介绍了将计算过程可视化的计算图,并使用计算图,介绍了神经网络中的误差反向传播法,并以层为单位实现了神经网络中的处理。我们学过的层有 ReLU 层,Softmax-with-Loss 层,Affine 层,Softmax 层等,这些层中实现了 forward 和 backward 方法,通过将数据正向和反向地传播,可以高效地计算权重参数的梯度。通过使用层进行模块化,神经网络中可以自由地组装层,轻松地构建出自己喜欢的网络。

第6章 与学习相关的技巧

本章将介绍神经网络的学习中的一些重要观点,主题设计寻找最优权重参数的最优化方法、权重参数的初始值、超参数的设定方法等。此外,为了应对过拟合,本章还将介绍权值衰减、Dropout 等正则化方法,并进行实现。最后对近年来众多研究中使用的 Batch Normalization 方法进行简单的介绍。使用本章介绍的方法,可以高效地进行神经网络的学习,提高识别精度。

6.1 参数的更新

神经网络的学习的目的是找到使损失函数的值尽可能小的参数。这是寻找最优参数的问题,解决这个问题的过程称为最优化 (optimization) 。遗憾的是,神经网络的最优化问题非常难。这是因为参数空间非常复杂,无法轻易找到最优解(无法使用那种通过数学式一下子就求得最小值的方法)。而且,在深度神经网络中,参数的数量非常庞大,导致最优化问题更加复杂。

在前几章中,为了找到最优参数,我们将参数的梯度(导数)作为了线索。使用参数的梯度,沿着梯度方向更新参数,并重复这个步骤多次,从而逐渐靠近最优参数,这个过程称为随机梯度下降法,简称SGD 。SGD 是一个简单的方法,不过比起胡乱地搜索参数空间,也算是“聪明”的方法。但是,根据不同的问题,也存在比 SGD 更加聪明的方法。本节中,我们将指出SGD的缺点,并介绍 SGD 以外的其他最优化方法。

探险家的故事

进入正题前,我们先打一个比方,来说明关于最优化我们所处的状况。

有一个性情古怪的探险家。他在广袤的干旱地带旅行,坚持寻找幽深的山谷。他的目标是要到达最深的谷底(他称之为“至深之地”)。这也是他旅行的目的。并且,他给自己制定了两个严格的“规定”:一个是不看地图;另一个是把眼睛蒙上。因此,他并不知道最深的谷底在这个广袤的大地的何处,而且什么也看不见。在这么严苛的条件下,这位探险家如何前往“至深之地”呢?他要如何迈步,才能迅速找到“至深之地”呢?

寻找最优参数时,我们所处的状况和这位探险家一样,是一个漆黑的世界。我们必须在没有地图、不能睁眼的情况下,在广袤、复杂的地形中寻找“至深之地”。大家可以想象这是一个多么难的问题。

在这么困难的状况下,地面的坡度显得尤为重要。探险家虽然看不到周围的情况,但是能够知道当前所在位置的坡度(通过脚底感受地面的倾斜状况)。于是,朝着当前所在位置的坡度最大的方向前进,就是 SGD 的策略。勇敢的探险家心里可能想着只要重复这一策略,总有一天可以达到“至深之地”。

SGD

让大家感受了最优化问题的难度之后,我们再来复习一下 SGD 。用数学式可以将 SGD 写成下式

这里 表示学习率,实际上会取 0.01 或 0.001 这些事先决定好的值。

现在,我们将 SGD 实现为一个 Python 类

1
2
3
4
5
6
7
class SGD:
def __init__(self, lr=0.01):
self.lr = lr

def update(self, params, grads):
for key in params.keys():
params[key] -= self.lr * grads[key]

这里,进行初始化的参数 lr 表示学习率(learning rate)。这个学习率会保存为实例变量。此外,代码段中还定义了 update() 方法,这个方法在 SGD 中会被反复调用。参数 params 和 grads 是字典型变量,按照params[‘W1’]、grads[‘W1’] 的形式,分别保存了权重参数和它们的梯度。

使用这个 SGD 类,可以按如下方式进行神经网络的参数的更新(下面的代码是不能实际运行的伪代码)。

1
2
3
4
5
6
7
8
9
10
network = TwoLayerNet(...)
optimizer = SGD()

for i in range(10000):
...
x_batch, t_batch = get_mini_batch(...) # mini-batch
grads = network.gradient(x_batch, t_batch)
params = network.params
optimizer.update(params, grads)
...

这里首次出现的变量名 optimizer 表示“进行最优化的人” 的意思,这里由 SGD 承担这个角色。参数的更新由 optimizer 负责完成。我们在这里需要做的只是将参数和梯度的信息传给 optimizer 。

像这样,通过单独实现进行最优化的类,功能的模块化变得更简单。比如,后面我们马上会实现另一个最优化方法 Momentum ,它同样会实现成拥有 update(params, grads) 这个共同方法的形式。这样一来,只需要将 optimizer = SGD() 这一语句改成 optimizer = Momentum() ,就可以从 SGD 切换成 Momentum 。

SGD 的缺点

虽然 SGD 简单,并且容易实现,但是在解决某些问题时可能没有效率。这里,在指出SGD的缺点之际,我们来思考一下求下面这个函数的最小值的问题。

如图所示,这个函数是向 轴方向延伸的“碗”状函数。实际上,这个函数的登高线呈向 轴方向延伸的椭圆状。

现在看一下这个函数的梯度。如果用图表示梯度的话,则如下图所示。这个梯度的特征是 轴方向上大, 轴方向上小。换句话说,就是 轴方向的梯度大,而 轴方向的梯度小。这里需要注意的是,虽然这个式子的最小值在 ,但是很多地方的梯度并没有指向

我们来尝试对这个函数应用SGD ,从 处(初始值)开始搜索,结果如下图所示。在图中,SGD 呈“之”字形移动。这是一个相当低效的路径。也就是说,SGD 的缺点是,如果函数的形状非均向(anisotropic),比如呈延伸状,搜索的路径就会非常低效。因此,我们需要比单纯朝梯度方向前进的SGD更聪明的方法。SGD低效的根本原因是,梯度的方向并没有指向最小值的方向。

为了改正 SGD 的缺点。下面我们将介绍 Momentum、AdaGrad、Adam这3种方法来取代 SGD 。我们会简单介绍各个方法,并用数学式和 Python 进行实现。

Momentum

Momentum 是“动量”的意思,和物理有关。用数学式表示 Momentum 方法,如下所示

和前面的 SGD 一样, 表示要更新的权重参数, 表示学习率。这里新出现了一个变量 ,对应物理上的速度。第一个式子表示了物体在梯度方向上受力,在这个力的作用下,物体的速度增加这一物理法则。如下图所示,Momentum 方法给人的感觉就像是小球在地面上滚动。

式中有 这一项。在物体不受任何力时,该项承担使物体逐渐减速的任务( 设定为 0.9之类的值),对应物理上的地面摩擦或空气阻力。下面是 Momentum 的代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Momentum:

"""Momentum SGD"""

def __init__(self, lr=0.01, momentum=0.9):
self.lr = lr
self.momentum = momentum
self.v = None

def update(self, params, grads):
if self.v is None:
self.v = {}
for key, val in params.items():
self.v[key] = np.zeros_like(val)

for key in params.keys():
self.v[key] = self.momentum*self.v[key] - self.lr*grads[key]
params[key] += self.v[key]

这里实例变量 会保存物体的速度。初始化时, 中什么都不保存,但当第一次调用 update() 时, 会以字典型变量的形式保存与参数结构相同的数据。

现在尝试用 Momentum 解决示例函数的最优化问题,如下图所示。其中更新路径就像小球在碗中滚动一样。和 SGD 相比,我们发现“之”字形的“程度”减轻了。这是因为虽然 轴方向上受到的力非常小,但是一直在同一方向上受力,但是一直在同一方向上受力,所以朝同一个方向会有一定的加速。反过来,虽然 轴方向上受到的力很大,但是因为交互地受到正方向和反方向地力,它们会互相抵消,所以 轴方向上的速度不稳定。因此,和 SGD 时的情形相比,可以更快地朝 轴方向靠近,减弱“之”字形的变动程度。

AdaGrad

在神经网络的学习中,学习率( )的值很重要。学习率过小,会导致学习花费过多时间;反过来,学习率过大,则会导致学习发散而不能正确进行。

在关于学习率的有效技巧中,有一种被称为学习率衰减 (learning rate decay) 的方法,即随着学习的进行,使学习率逐渐减小。实际上,一开始“多“学,然后逐渐”少“学的方法,在神经网络的学习中经常被使用。

逐渐减小学习率的想法,相当于”全体“参数的学习率一起降低。而 AdaGrad 进一步发展了这个想法,针对”一个一个“的参数,赋予其”定制“的值。

AdaGrad 会为参数的每个元素适当地调整学习率,与此同时进行学习(其中地 Ada 来自英文单词 Adaptive ,即”适当的“的意思)。下面,我们用数学式来表示 AdaGrad 的更新方法。

这里出现了新变量 ,它保存了以前的所有梯度值的平方和(式子中的 表示对应矩阵元素的乘法)。然后在更新参数时,通过乘以 ,就可以调整学习的尺度。这意味着,参数的元素变动较大(较大幅度更新)的元素的学习率将变化。也就是说,可以按参数的元素进行学习率衰减,使变动大的参数的学习率逐渐减小。

注意:AdaGrad 会记录过去所有梯度的平方和。因此,学习越深入,更新的幅度就越小。实际上,如果无止境地学习,更新量就会变成0,完全不再更新。为了改善这个问题,可以使用 RMSProp 方法。RMSProp 方法并不是将过去所有的梯度一视同仁地相加,而是逐渐地遗忘过去的梯度,在做加法运算时将新梯度的信息更多地反映出来。这种操作从专业上讲,称为”指数移动平均“,呈指数函数式地减小过去的梯度的尺度。

现在来实现 AdaGrad ,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AdaGrad:

"""AdaGrad"""

def __init__(self, lr=0.01):
self.lr = lr
self.h = None

def update(self, params, grads):
if self.h is None:
self.h = {}
for key, val in params.items():
self.h[key] = np.zeros_like(val)

for key in params.keys():
self.h[key] += grads[key] * grads[key]
params[key] -= self.lr * grads[key] / (np.sqrt(self.h[key]) + 1e-7)

这里需要注意的是,最后一行加上了微小值 1e-7 。这是为了防止当0存在时,将0作为除数的情况。在很多深度学习的框架中,这个微小值也可以设定为参数,但这里我们用的是 1e-7 这个固定值。

现在,让我们试着使用 AdaGrad 来解决示例函数的最优化问题,结果如下图所示,这里可以看到,函数的取值高效地向着最小值移动。由于 轴方向上的梯度较大,因此刚开始变动较大,但是后面会根据这个较大的变动按比例调整,减小更新的步伐。因此, 轴方向上的更新程度被减弱,”之“字形的变动程度有所衰减。

Adam

Momentum参照小球在碗中滚动的物理规则进行移动,AdaGrad为参数的每个元素适当地调整更新步伐。如果将这两个方法融合在一起会怎么样呢?这就是 Adam 方法的基本思路。

其理论优点复杂,直观说就是通过组合前面两个方法的优点,有望实现高效搜索参数空间。此外,进行超参数的”偏置校正“也是 Adam 的特征。这里不再进行过多的说明,详细内容请参考原作者的论文。关于 Python 的实现,common/optimizer.y 中将其实现为了 Adam 类,可以参考。

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
27
28
29
30
31
32
33
34
class Adam:

"""Adam (http://arxiv.org/abs/1412.6980v8)"""

def __init__(self, lr=0.001, beta1=0.9, beta2=0.999):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.iter = 0
self.m = None
self.v = None

def update(self, params, grads):
if self.m is None:
self.m, self.v = {}, {}
for key, val in params.items():
self.m[key] = np.zeros_like(val)
self.v[key] = np.zeros_like(val)

self.iter += 1
lr_t = self.lr * np.sqrt(1.0 - self.beta2**self.iter) / (1.0 - self.beta1**self.iter)

for key in params.keys():
#self.m[key] = self.beta1*self.m[key] + (1-self.beta1)*grads[key]
#self.v[key] = self.beta2*self.v[key] + (1-self.beta2)*(grads[key]**2)
self.m[key] += (1 - self.beta1) * (grads[key] - self.m[key])
self.v[key] += (1 - self.beta2) * (grads[key]**2 - self.v[key])

params[key] -= lr_t * self.m[key] / (np.sqrt(self.v[key]) + 1e-7)

#unbias_m += (1 - self.beta1) * (grads[key] - self.m[key]) # correct bias
#unbisa_b += (1 - self.beta2) * (grads[key]*grads[key] - self.v[key]) # correct bias
#params[key] += self.lr * unbias_m / (np.sqrt(unbisa_b) + 1e-7)

使用 Adam 方法解决示例函数的最优化问题,如下所示。可以看到基于 Adam 的更新过程就像小球在碗里滚动一样。相比于 Momentun ,Adam 的小球左右摇晃的程度有所减轻,这得益于学习的更新程度被适当地调整了。

Adam 会设置3个超参数,第一个是学习率 (论文中以 出现),另外两个是一次momentum系数β1和二次momentum系数β2。根据论文,标准的设定值是β1为0.9,β2为0.999。设置了这些值后,大多数情
况下都能顺利运行。

使用哪种更新方法呢

到目前为止,我们已经学习了4中更新参数的反复。这里我们来比较一下这 4 种反复。

如图所示,根据使用的方法不同,参数更新的路径也不同。只看这个图的话,AdaGard 似乎是最好的,不过也要注意,结果会根据要解决的问题而变。并且,很显然,超参数的设定值不同,结果也会发生变化。

目前并不存在能在所有问题中都表型良好的方法。这4中方法各有各的特点,都有各自擅长解决的问题和不擅长解决的问题。

很多研究中至今仍在使用SGD。Momentum 和 AdaGrad 也是值得一试的方法。最近,很多研究人员和技术人员都喜欢用Adam。本书将主要使用 SGD 或者 Adam,读者可以根据自己的喜好多多尝试。

基于MNIST数据集的更新方法的比较

我们以手写数字识别为例,比较前面的4种方法,并确认不同的方法在学习进展上有多大程度上的差异。先来看一下结果,如下图所示

这个实验以一个5层神经网络为对象,其中每层有 100 个神经元。激活函数使用的是 ReLU 。

从图中的结果可知,与SGD相比,其他3种方法学习得更快,而且速度基本相同,仔细看的话,AdaGrad 的学习进行得稍微快一点。这个实验需要注意的地方是,实验结果会随学习率等超参数、神经网络的结构(几层深等)的不同而发生变化。不过,一般而言,与SGD相比,其他3种方法可以学习得更快,有时最终的识别精度也更高。

6.2 权重的初始值

在神经网络的学习中,权重的初始值特别重要。实际上,设定什么样的权重初始值,经常关系到神经网络的学习能否成功。本节将介绍权重初始值的推荐值,并通过实验确认神经网络的学习是否会快速进行。

可以将权重初始值设为0吗

后面我们会介绍抑制过拟合、提高泛化能力的技巧——权值衰减(weight decay)。简单地说,权值衰减就是一种以减小权重参数的值为目的进行学习的方法。通过减小权重参数的值来抑制过拟合的发生。

如果想减小权重的值,一开始就将初始值设为较小的值才是正途。实际中,在这之前的权重初始值都是像 0.01 * np.random.randn(10, 100) 服从标准差是 0.01 的高斯分布。

如果我们将权重初始值全部设为0以减小权重的值,会怎么样呢?从结论来看,将权重初始值设为0不是一个好主意。事实上,将权重初始值设为0的话,将无法正确进行学习。

为什么不能将权重初始值设为0呢?严格地说,为什么不能将权重初始值设为一样的值呢?这是因为在误差反向传播法中,所有的权重值都会进行相同的更新。比如,在2层神经网络中,假设第1层和第2层的权重为0。这样一来,正向传播时,因为输入层的权重为0,所以第2层的神经元全部会被传递相同的值。第2层的神经元中全部输入相同的值,这意味着反向传播时第2层的全部会进行相同的更新(回忆一下”乘法节点的反向传播“的内容)。因此,权重被更新为相同的值,并拥有了对称的值(重复的值)。这使得神经网络拥有许多不同的权重的意义丧失了。为了防止”权重均一化“(严格地说,是为了瓦解权重的对称结构),必须随机生成初始值。

隐藏层的激活值的分布

观察激活层的激活值(激活函数的输出数据)的分布,可以获得很多启发。这里,我们来做一个简单的实验,观察权重初始值是如何影响隐藏层的激活值的分布的。这里,我们来做一个简单的实验,观察权重初始值是如何影响隐藏层的激活值的分布的。这里要做的实验是,向一个5层神经网络(激活函数使用 sigmoid 函数)传入随机生成的输入数据,用直方图绘制各层激活值的数据分布。

进行实验的源代码在ch06/weight_init_activation_histogram.py 中,下面展示部分代码。

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
27
input_data = np.random.randn(1000, 100)  # 1000个数据
node_num = 100 # 各隐藏层的节点(神经元)数
hidden_layer_size = 5 # 隐藏层有5层
activations = {} # 激活值的结果保存在这里

x = input_data

for i in range(hidden_layer_size):
if i != 0:
x = activations[i-1]

# 改变初始值进行实验!
w = np.random.randn(node_num, node_num) * 1
# w = np.random.randn(node_num, node_num) * 0.01
# w = np.random.randn(node_num, node_num) * np.sqrt(1.0 / node_num)
# w = np.random.randn(node_num, node_num) * np.sqrt(2.0 / node_num)


a = np.dot(x, w)


# 将激活函数的种类也改变,来进行实验!
z = sigmoid(a)
# z = ReLU(a)
# z = tanh(a)

activations[i] = z

这里假设神经网络共有5层,每层有 100 个神经元。然后,用高斯分布随机生成 1000 个数据作为输入数据,并把它们传给5层神经网络。激活函数使用 sigmoid 函数,各层的激活值的结果保存在 activations 变量中。这个代码段中需要注意的是权重的尺度。虽然这次我们使用的标准差为1的高斯分布,但实验的目的是通过改变这个尺度(标准差),观察激活值的分布如何变化。现在,我们将保存在 activations 中的各层数据画成直方图。

1
2
3
4
5
6
7
8
for i, a in activations.items():
plt.subplot(1, len(activations), i+1)
plt.title(str(i+1) + "-layer")
if i != 0: plt.yticks([], [])
# plt.xlim(0.1, 1)
# plt.ylim(0, 7000)
plt.hist(a.flatten(), 30, range=(0,1))
plt.show()

运行这段代码后,可以得到下面的直方图

从图可知,各层的激活值呈偏向 0 和 1 的分布。这里使用的 sigmoid 函数是 S 型函数,随着输出不断地偏向0(或者靠近1),它的导数的值逐渐接近0。因此,偏向0和1的数据分布会造成反向传播中的梯度的值不断变小,最后完全消失。这个问题称为梯度消失 (gradient vanishing) 。层次加深的深度学习中,梯度消失的问题可能会更加严重。

下面,将权重的标准差设为 0.01,进行相同的实验。实验的代码只需要把设定权重初始值的地方换成下面的代码即可。

1
2
# w = np.random.randn(node_num, node_num) * 1
w = np.random.randn(node_num, node_num) * 0.01

来看一下结果。使用标准差为 0.01的高斯分布时,各层的激活值的分布如下所示

这次呈集中在 0.5 附近的分布。因为不像刚才的例子那样偏向0和1,所以不会发生梯度消失的问题。但是,激活值的分布有所偏向,说明在表现力上有很大的问题。为什么这么说呢个?因为如果有多个神经元都输出几乎相同的值,那它们就没有存在的意义了。比如,如果100个神经元都输出几乎相同的值,那么也可以由1个神经元来表达基本相同的事情。因此,激活值在分布上有所偏向会出现”表现力受限“的问题。

各层的激活值的分布都要求有适当的广度。为什么呢?因为通过在各层间传递多样性的数据,神经网络可以进行高效的学习。反过来,如果传递的是有所偏向的数据,就会出现梯度消失或者“表现力受限”的问题,导致学习可能无法顺利进行。

接着,我们尝试使用Xavier Glorot等人的论文中推荐的权重初始值(俗称“Xavier初始值”)。现在,在一般的深度学习框架中,Xavier 初始值已被作为标准使用。比如,Caffe框架中,通过在设定权重初始值时赋予xavier参数,就可以使用Xavier初始值。

( Xavier的论文中提出的设定值,不仅考虑了前一层的输入节点数量,还考虑了下一层的输出节点数量。但是,Caffe等框架的实现中进行了简化,只使用了这里所说的前一层的输入节点进行计算。)

Xavier 的论文中,为了使各层的激活值呈现处具有相同广度的分布,推荐了合适的权重尺度。推导出的结论是,如果前一层的节点数为n ,这初始值使用标准差为 的分布。

使用 Xavier 初始值后,前一层的节点数越多,要设定为目标节点的初始值的权重尺度就越小。现在,我们使用Xavier 初始值进行实验。进行实验的代码只需要设定权重初始值的地方换成下面内容即可 (因此此处所有层的节点数都是 100, 所以简化了实现)。

1
2
node_num = 100 # 前一层的节点数
w = np.random.randn(node_num, node_num) / np.sqrt(node_num)

使用 Xavier 初始值后的结果见下图。从这个结果可知,越是后面的层,图像变得越歪斜,但是呈现了比之前更有广度的分布。因为各层间传递的数据有适当的广度,所以sigmoid函数的表现力不受限制,有望进行高效的学习。

注:图6-13的分布中,后面的层的分布呈稍微歪斜的形状。如果用tanh函数(双曲线函数)代替sigmoid函数,这个稍微歪斜的问题就能得到改善。实际上,使用tanh函数后,会呈漂亮的吊钟型分布。tanh函数和sigmoid函数同是S型曲线函数,但tanh函数是关于原点(0,0) 对称的S型曲线,而sigmoid函数是关于(x,y)=(0,0.5)对称的S型曲线。众所周知,用作激活函数的函数最好具有关于原点对称的性质。

ReLU的权重初始值

Xavier初始值是以激活函数是线性函数为前提而推导出来的。因为sigmoid函数和tanh函数左右对称,且中央附近可以视为线性函数,所以适当使用 Xavier 初始值。但当激活函数使用 ReLU 时,一般推荐使用 ReLU 专用的初始值 ,也就是 Kaiming He等人推荐的初始值,也称为 ”He 初始值“。当前一层的节点数为 时,He初始值使用标准差为 的高斯分布。当 Xavier 初始值是 时,(直观上)可以解释为,因为 ReLU 的负值区域的值为 0 ,为了使它更有广度,所以需要2倍的系数。

现在来看一下激活函数使用 ReLU 时激活值的分布。我们给出了3个实验的结果

观察结果可知,当”std = 0.01“ 时,各层的激活值非常小。神经网络传递的是非常小的值,说明逆向传播时权重的梯度也同样很小。这是非常严重的问题,实际上学习基本上没有进展。

接下来是初始值为 Xavier 初始值时的结果。在这种情况下,随着层的加深,偏向一点点变大。实际上,层加深后,激活值的偏向变大,学习时会出现梯度消失的问题。而当初始值为 He 初始值时,各层中分布的广度相同。由于即便层加深,数据的广度也能保持不变,因此逆向传播时,也会传递合适的值。

总结一下,当激活函数使用 ReLU 时,权重初始值使用 He 初始值,当激活函数为 sigmoid 或 tanh 等S型曲线函数时,初始值使用 Xavier 初始值。这是目前的最佳实践。

基于 MNIST 数据集的权重初始值的比较

下面通过实际的数据,观察不同的权重初始值的赋值方法会在多大程度上影响神经网络的学习。这里,我们基于std=0.01, Xavier初始值、He初始值进行实验(源代码在ch06/weight_init_compare.py中)。先来看一下结果,如下图所示

这个实验中,神经网络有5层,每层有100个神经元,激活函数使用的是ReLU。从图6-15的结果可知,std=0.01时完全无法进行学习。这和刚才观察到的激活值的分布一样,是因为正向传播中传递的值很小(集中在0附近的数据)。因此,逆向传播时求得的梯度也很小,权重几乎不进行更新。相反,当权重初始值为 Xavier 初始值和 He 初始值时,学习进行得很顺利。并且,我们发现 He 初始值时得学习进度更快一些。

综上,在神经网络的学习中,权重初始值非常重要,关系到神经网络的学习能否成功。

6.3 Batch Normalization

在上一节,我们观察了各层的激活值分布,并从中了解到如何设定了合适的权重初始值,则各层的激活值分布会有适当的广度,从而可以顺利地进行学习。那么,为了使各层拥有适当地广度,”强制性“地调整激活值地分布会怎样呢?实际上,Batch Normalization 方法就是基于这个想法而产生地。

Batch Normalization 的算法

Batch Normalization(下文简称 Batch Norm)是2015年提出的方法。Batch Norm 虽然是一个问世不久的新方法,但已经被很多研究人员和技术人员广泛使用。实际上,看一下机器学习竞赛的结果,就会发现很多通过使用这个方法而获得优异结果的例子。

为什么 Batch Norm 这么惹人注目呢?因为 Batch Norm 有以下优点。

  • 可以使学习快速进行(可以增大学习率)
  • 不那么依赖初始值
  • 抑制过拟合(降低 Droupout 等的必要性)

考虑到深度学习要花费很多时间,第一个优点令人非常开心。另外,后两点也可以帮我们消除深度学习中的很多烦恼。

如前所述,Batch Norm 的思路是调整各层的激活值分布使其拥有适当的广度。为此,要向神经网络中插入对数据分布进行正规化的层,即 Batch Normalization 层(下面简称 Batch Norm 层),如下图所示

Batch Norm ,顾名思义,以进行学习时的 mini-batch 为单位,按 mini-batch 进行正规化。具体而言,就是进行数据分布的值为0、方差为1的正规化。用数学式表示的话,如下所示

这里对 mini-batch 的 个输入数据的集合 求均值和方差,然后对输入数据进行均值为0、方差为1(合适的分布)的正规化。式子中的 是一个微小值(比如,1e-7),它时为了防止出现除以0的情况。

通过将这个正规化的处理插入到激活函数的前面(或者后面),可以减小数据分布的偏向。

接着,Batch Norm 层会对正规化的数据进行缩放和平移的变换,用数学式可以如下表示

这里 是参数,一开始 ,然后再通过学习调整到合适的值。

上面就是 Batch Norm 的算法。这个算法是神经网络上的正向传播。如果使用计算图,可以表示为下图。

Batch Norm 的反向传播的推导有些复杂,这里我们不进行介绍。不过如果使用上图的计算图来思考的话,Batch Norm 的反向传播或许可以轻松地推导出来。Frederik Kratzert的博客“Understanding the backward
pass through Batch Normalization Layer” 里有详细说明,可以参考一下

Batch Normalization 的评估

现在我们使用 Batch Norm 层进行实验。首先,使用 MNIST 数据集,观察使用 Batch Norm 层和不适用 Batch Norm 层时学习的过程会如何变化,结果如下所示

从图中的结果可知,使用 Batch Norm 后,学习进行得更快了。接着,给予不同的初始值尺度,观察学习的过程如何变化。下图是权重初始值的标准差为各种不同的值时的学习过程图。我们发现,几乎所有的情况下都是使用 Batch Norm时学习进行得更快。同时也可以发现,实际上,在不使用 Batch Norm 的情况下,如果不赋予一个尺度好的初始值,学习将完全无法进行。

综上,通过使用 Batch Norm ,可以推动学习的进行。并且,对权重初始值变得健壮(不那么依赖初始值)。Batch Norm 具备了如此优良的性质,一定能应用在更多场合中。

6.4 正则化

机器学习的过程中,过拟合是一个很常见的问题。过拟合指的是只能拟合训练数据,但不能很好地拟合不包含在训练数据中的其他数据的状态。机器学习的目标是提高泛化能力,因此抑制过拟合的技巧也很重要。

过拟合

发生过拟合的原因,主要有以下两个

  • 模型拥有大量参数,表现力强。
  • 训练数据少。

这里,我们故意满足这两个条件,制造过拟合现象。为此,要从 MNIST 数据集原本的 60000 个训练数据中只选定300个,并且,为了增加网络的复杂度,使用7层网络(每层有100个神经元,激活函数为 ReLU)。

下面是用于实验的部分代码,首先是用于读入数据的代码。

1
2
3
4
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True)
# 为了再现过拟合,减少学习数据
x_train = x_train[:300]
t_train = t_train[:300]

接着是进行训练的代码。和之前的代码一样,按 epoch 分别算出所有训练数据和所有测试数据的识别精度,如下所示

过了 100 个 epoch 左右后,用训练数据测量到的识别精度几乎都为 100% 。但是,对于测试数据,离100%的识别精度还有较大的差距。如此大的识别精度差距,是只拟合了训练数据的结果,而对没有使用的一般数据(测试数据)拟合得不是很好。

权值衰减

权值衰减是一直以来经常使用的一种抑制过拟合的方法。该方法通过在学习的过程中对大的权重进行惩罚,来抑制过拟合。很多过拟合原本就是因为权重参数取值过大才发生的。

复习一下,神经网络的学习目的是减小损失函数的值。这时,例如为损失函数加上权重的平方范数(L2范数)。这样一来,就可以抑制权重变大。用符号来表示的话,如果将权重记为 ,L2范数的权值衰减就是 ,然后将这个 加到损失函数上。这里, 是控制正则化程度的超参数, 是用于将求导结果变成 的调整用常量。

对于所有权重,权值衰减方法都会为损失函数加上 。因此,在求权重梯度的计算中,要为之前的误差反向传播法的结果加上正则化项的导数

我的理解是这样的,把原来的损失函数定义为 ,现在的L2范数部分定义为 ,因此,对于某个权重参数 ,其偏导数为 ,第一部分就还是按照的方式计算,第二部分就是 ,也就是说,此时偏导数的变化就是增加了

偏导数增加了 ,按照梯度下降法更新参数时,参数的绝对值会变小。证明如下,如果 是正数,那么减去 会降低其值(当 在0-1之间 ),反过来也一样。因此实现了减小权重参数绝对值的目的。

现在我们来进行实验,对于刚刚进行的实验,应用 的权值衰减,结果如下图所示

如图所示,虽然训练数据和测试数据的识别精度之间还有差距,但与之前结果相比差距变小了。这说明过拟合受到了抑制。此外,还要注意,训练数据的识别精度没有达到100% 。

Dropout

作为抑制过拟合的方法,前面我们介绍了为损失函数加上权重的 L2 范数的权值衰减方法。该方法可以简单地实现,在某种程度上能够抑制过拟合。但是,如果网络的模型变得很复杂,只用权值衰减就难以应付了。在这种情况下,我们经常会使用 Dropout 方法。

Dropout 是一种在学习的过程中随机删除神经元的方法。训练时,随机选出隐藏层的神经元,然后将其删除。被删除的神经元不在进行信号的传递,如图所示。训练时,每传递一次数据,都会随机选择要删除的神经元。然后,测试时,虽然会传递所有的神经元信号,但是对于各个神经元的输出,要乘以训练时的删除比例后再输出。

下面我们来实现 Dropout 。这里的实现重视易理解性。不过,因为训练时如果进行恰当的计算的话,正向传播时单纯地传递数据就可以了(不用乘以删除比例),所以深度学习的框架中进行了这样的实现。关于高效的实现,可以参考 Chainer 中实现的 Dropout 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Dropout:
def __init__(self, dropout_ratio=0.5):
self.dropout_ratio = dropout_ratio
self.mask = None

def forward(self, x, train_flg=True):
if train_flg:
self.mask = np.random.rand(*x.shape) > self.dropout_ratio
return x * self.mask
else:
return x * (1.0 - self.dropout_ratio)

def backward(self, dout):
return dout * self.mask

这里的要点是,每次正向传播时,self.mask 中都会以 False 的形式保存要删除的神经元。self.mask 会随机生成和 x 形状相同的数组,并将值比 dropout_ratio 大的元素设为 True 。反向传播时的行为和 ReLU 相同。也就是说,正向传播时传递了信号的神经元,反向传播时按原样传递信号;正向传播时没有传递信号的神经元,反向传播时信号将停在那里。

现在,我们使用 MNIST 数据集进行验证,以确认 Dropout 的效果。源代码在ch06/overfit_dropout.py中。另外,源代码中使用了Trainer类来简化实现(这个类可以负责前面所进行的网络的学习)。

Dropout 的实验和前面的实验一样,使用 7 层网络(每层有100个神经元,激活函数为 ReLU),一个使用Dropout,另一个不使用 Dropout ,实验结果如下图所示

我们可以看到,通过使用 Dropout ,训练数据和测试数据的识别精度的差距变小了。并且,训练数据也没有到达100%的识别精度。像这样,通过使用 Dropout ,即便是表现力强的网络,也可以抑制过拟合。

机器学习中经常使用集成学习。所谓集成学习,就是让多个模型单独进行学习,推理时再取多个模型的输出的平均值。用神经网络的语境来说,比如,准备5个结构相同(或者类似)的网络,分别进行学习,测试时,以这5个网络的输出的平均值作为答案。实验告诉我们,通过进行集成学习,神经网络的识别精度可以提高好几个百分点。这个集成学习与 Dropout 有密切的关系。这是因为可以将 Dropout 理解为,通过在学习过程中随机删除神经元,从而让每一次都让不同的模型进行学习。并且,推理时,通过对神经元的输出乘以删除比例,可以取得模型的平均值。也就是说,可以理解成,Dropout 将集成学习的效果(模拟地)通过一个网络实现了。

6.5 超参数的验证

神经网络中,除了权重和偏置等参数,超参数 (hyper-parameter) 也经常出现。这里所说的超参数是指,比如各层的神经元数量、batch 大小、参数更新时的学习率或权值衰减等。如果这些超参数没有设置合适的值,模型的性能就会很差。虽然超参数的取值非常重要,但是在决定超参数的过程中一般会伴随很多的试错。本节将介绍尽可能高效地寻找超参数的值的方法。

验证数据

之前我们使用的数据集分成了训练数据和测试数据,训练数据用于学习,测试数据用于评估泛化能力。由此,就可以评估是否只过度拟合了训练数据(是否发生过拟合),以及泛化能力如何等。

下面我们要对超参数设置各种各样的值以进行验证。这里要注意的是,不能使用测试数据评估超参数的性能。因为如果使用测试数据调整超参数,超参数的值会对测试数据发生过拟合。换句话说,用测试数据确认超参数的值的“好坏”,就会导致超参数的值被调整为只拟合测试数据。这样的话,可能就会得到不能拟合其他数据、泛化能力低的模型。

因此,调整超参数时,必须使用超参数专用的确认数据。用于调整超参数的数据,一般称为验证数据(validation data)。我们使用这个验证数据来评估超参数的好坏。

训练数据用于参数的学习,验证数据用于超参数的性能评估。为了确认泛化能力,要在最后使用(比较理想的是只用一次)测试数据。

根据不同的训练集,有的会事先分成训练数据、验证数据、测试数据三部分,有的只分成训练数据和测试数据两部分,有的则不进行分隔。在这种情况下,用户需要自行进行分割。如果是 MNIST 数据集,获得验证数据的最简单的方法就是从训练数据中事先分隔20%作为验证数据,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
(x_train, t_train), (x_test, t_test) = load_mnist()

# 打乱训练数据
x_train, t_train = shuffle_dataset(x_train, t_train)

# 分割验证数据
validation_rate = 0.20
validation_num = int(x_train.shape[0] * validation_rate)

x_val = x_train[:validation_num]
t_val = t_train[:validation_num]
x_train = x_train[validation_num:]
t_train = t_train[validation_num:]

这里,分割训练数据前,先打乱了输入数据和标签。这是因为数据集的数据可能存在偏向(比如,数据从0到10按顺序排列等)。这里使用的 shuffle_dataset 函数利用了 np.random.shuffle 函数,在 common/util.py 中有它的实现。

接下来,我们使用验证数据观察超参数的最优化方法。

超参数的最优化

进行超参数的最优化时,逐渐缩小超参数的“好值”的存在范围非常重要。所谓逐渐缩小范围,是指一开始先大致设定一个范围,从这个范围中随机选出一个超参数(采样),用这个采样到的值进行识别精度的评估;然后,多次重复该操作,观察识别精度的结果,根据这个结果缩小超参数的“好值”的范围。通过重复这一操作,就可以逐渐确定超参数的合适范围。

有报告显示,在进行神经网络的超参数的最优化时,与网格搜索等有规律的搜索相比,随机采样的搜索方式效果更好。这是因为在多个超参数中,各个超参数对最终的识别精度的影响程度不同。

超参数的范围只要“大致地指定”就可以了。所谓“大致地指定”,是指像 这样,以10的阶乘的尺度指定范围(也表述为“用对数尺度指定”)。

在超参数的最优化中,要注意的是深度学习需要很长时间(比如,几天或几周)。因此,在超参数的搜索中,需要尽早放弃那些不符合逻辑的超参数。于是,在超参数的最优化中,减少学习的 epoch ,缩短一次评估所需的时间是一个不错的办法。

以上就是超参数的最优化的内容,简单归纳一下,如下所示。

反复进行上述操作,不断缩小超参数的范围,在缩小到一定程度时,从该范围中选出一个超参数的值。这就是进行超参数的最优化的一种方法。

这里介绍的超参数的最优化方法是实践性的方法。不过,这个方法与其说是科学方法,倒不如说有些实践者的经验的感觉。在超参数的最优化中,如果需要更精炼的方法,可以使用贝叶斯最优化(Bayesian optimization)。贝叶斯最优化运用以贝叶斯定理为中心的数学理论,能够更加严密、高效地进行最优化。详细内容请参 考 论 文“Practical Bayesian Optimization of Machine Learning Algorithms”等。

超参数最优化的实现

现在,我们使用 MNIST 数据集进行超参数的最优化。这里我们将学习率和控制权值衰减强度的系数(下文称为“权值衰减系数”)这两个超参数的搜索问题作为对象。

如前所述,通过从 这样的对数尺度的范围中随机采样进行超参数的验证。这在 Python 中可以写成 10 ** np.random.uniform(-3, 3) 。在该实验中,权重衰减和学习率的初始范围如下

1
2
weight_decay = 10 ** np.random.uniform(-8, -4)
lr = 10 ** np.random.uniform(-6, -2)

注:对比对数均匀 vs 线性均匀

方法 采样范围 小值(如 0.01~0.1)概率 大值(如 100~1000)概率
10**np.random.uniform(-3,3) [0.001, 1000] ✅ 高(对数均匀) ✅ 高(对数均匀)
np.random.uniform(0.001, 1000) [0.001, 1000] ❌ 极低(概率 < 0.01%) ✅ 高

像这样进行随机采样后,再使用这些值进行学习,重复这一过程,观察合乎逻辑的超参数在哪里。

结果如下图所示,这里按照识别精度从高到低的顺序排列了验证数据的学习的变化。

从图中可知,直到 “Best-5” 左右,学习进行得都很顺利。因此,我们来观察一下 “Best-5” 之前的超参数的值,结果如下

1
2
3
4
5
Best-1 (val acc:0.83) | lr:0.0092, weight decay:3.86e-07
Best-2 (val acc:0.78) | lr:0.00956, weight decay:6.04e-07
Best-3 (val acc:0.77) | lr:0.00571, weight decay:1.27e-06
Best-4 (val acc:0.74) | lr:0.00626, weight decay:1.43e-05
Best-5 (val acc:0.73) | lr:0.0052, weight decay:8.97e-06

从这个结果可以看出,学习率在 0.001 到 0.01,权值衰减系数在 之间时,学习可以顺利进行。像这样,观察可以使学习顺利进行的超参数的范围,从而缩小值的范围。然后,在这个缩小的范围中重复相同的操作。这样就能缩小到合适的超参数的存在范围,然后在某个阶段,选择一个最终的超参数的值。

6.6 小结

本章我们介绍了神经网路的学习中的几个重要技巧。参数的更新方法,权重初始值的赋值方法,Batch Normalization、Dropout 等,这些都是现代神经网络中不可或缺的技术。另外,这里介绍的技巧,在最先进的深度学习中也被频繁使用。

第7章 卷积神经网络

本章的主题是卷积神经网络(Convolutional Neural Network, CNN)。CNN被用于图像识别、语音识别等各种场合中。

7.1 整体结构

首先,来看一下CNN的网络结构,了解CNN的大致框架。CNN和之前介绍的神经网络一样,可以像乐高积木一样通过组装层来构建。不过,CNN新出现了卷积层(Convolution 层)和池化层(Pooling 层)。

之前介绍的神经网络中,相邻层的所有神经元之间都有连接,这称为全连接 (fully-connected)。另外,我们用 Affine层实现了全连接层。如果使用这个 Affine 层,一个5层的全连接的神经网络就可以通过下图所示的网络结构来实现。其中 Affine 层后面跟着激活函数 ReLU 层,但是第5层的 Affine 层后面是由 Softmax 层输出最终结果。

那么,CNN 会是什么样的结构呢?如下图所示,这里 CNN 新增了卷积层和池化层,其顺序是 Conv-ReLU-(Pooling)(Pooling 层有时会被省略)。这里还需要注意的是,这里靠近输出的层中使用了之前的 “Affine - ReLU” 组合。此外,最后的输出层中使用了之前的 “Affine - softmax” 组合。这些都是一般的 CNN 中比较常见的结构

7.2 卷积层

CNN中出现了一些特有的术语,比如填充、步幅等。此外,各层中传递的数据是有形状的数据(比如,3维数据),这与之前的全连接网络不同,因此刚开始学习 CNN 时可能会感到难以理解。本节我们将花点时间,认真学习一些CNN中使用的卷积层的结构。

全连接层存在的问题

之前介绍的全连接的神经网络中使用了全连接层(Affine 层)。在全连接层中,相邻层的神经元全部连接在一起,输出的数量可以任意决定。

全连接层存在什么问题呢?那就是数据的形状被“忽视”了。比如,输入数据是图像时,图像通常时高、长、通道方向上的3维形状。但是,向全连接层输入时,需要将3维数据拉平维1维数据。实际上,前面提到的使用了 MNIST 数据集的例子中,输入数据就是1通道,高28像素,长28像素的(1,28,28)形状,但却被排成1列,以784个数据的形式输入到最开始的 Affine 层。

图像是 3 维形状,这个形状中应该含有重要的空间信息。比如,空间上相邻的像素为相似的值,RBG的各个通道之间分别有密切的关联性、相距较远的像素之间没有什么关联等,3维形状中可能隐藏有值得提取的本质模式。但是,因为全连接层会忽视形状,将全部的输入数作为相同的神经元(同一维度的神经元)处理,所以无法利用与形状相关的信息。

而卷积层可以保持形状不变。当输入数据是图像时,卷积层会以3维数据的形式接收输入数据,并同样以3维数据的形状输出至下一层。因此,在CNN中,可以(有可能)正确理解图像等具有形状的数据。

另外,CNN中,有时将卷积层的输入输出数据称为特征图 (feature map)。其中,卷积层的输入数据称为输入特征图 (input feature map),输出数据称为输出特征图 (output feature map)。本书中将“输入输出数据”和“特征图”作为含义相同的词使用。

卷积运算

卷积层进行的处理就是卷积运算。卷积运算相当于图像处理中的“滤波器运算”。在介绍卷积运算时,我们来看一个具体的例子。

如图所示,卷积运算对输入数据应用滤波器。在这个例子中,输入数据是有高长方向的数据,滤波器也一样,有高长方向上的维度。假设用 (height, width) 表示数据和滤波器的形状,则在本例中,输入大小是 (4,4) ,滤波器大小是 (3,3) ,输出大小是 (2,2) 。另外,有的文献中也会用 “核” 这个词来表示这里所说的“滤波器”。

现在来解释一些图中的卷积运算的例子中都进行了什么样的运算。下图展示了卷积运算的计算顺序。卷积运算是以一定间隔滑动滤波器的窗口并应用。如图所示,这里是将各个位置上滤波器的元素与输入的对应元素相乘,然后再求和(有时将这个计算称为乘积累加运算),然后输出到相应位置。将这个过程在所有位置都进行一遍,就可以得到卷积运算的输出。

在全连接中的神经网络中,除了权重参数,还存在偏置。CNN中,滤波器的参数就对应之前的权重。并且,CNN中也存在偏置,包含偏置的卷积运算的处理流如下图所示。其中向应用了滤波器的数据加上了偏置。偏置通常只有一个 (1 × 1),这个值会被加到应用了滤波器的所有元素上。

填充

在进行卷积层的处理之前,有时要向输入数据的周围填入固定的数据(比如0等),这称为填充 (padding) ,是卷积运算中经常会用到的处理。比如,在下图的例子中,对大小为 (4,4) 的输入数据应用了幅度为1的填充。“幅度为1的填充”是指用幅度为1像素的0填充周围。

如图所示,通过填充,大小为 (4,4) 的输入数据变成了 (6,6) 的形状。然后,应用大小为 (3,3) 的滤波器,生成了大小为 (4,4) 的输出数据。这个例子中将填充设为了1,不过填充的值也可以设置为2,3等任意的整数。

使用填充主要是为了调整输出的大小。如果输出一直比输入的规模小,这在反复进行多次卷积运算的深度网络中会成为问题。如果每次进行卷积运算都会缩写空间,那么在某个时刻输出大小就有可能变成1,导致无法再应用卷积运算。

步幅

应用滤波器的位置间隔称为步幅 (stride) 。之前例子中步幅都是 1,如果将步幅设为2,如下所示,此时应用滤波器的窗口的间隔变成2个元素。

我们看一下对于填充和步幅,如何计算输出大小。

这里,假设输入大小为 ,滤波器大小为 ,输出大小为 ,填充为 ,步幅为 。此时,输出大小可以按照下式计算。

当上面式子中的除法式子不能除尽时(结果是小数时),需要采取报错等对策(顺便说一下,根据深度学习的框架的不同,有时会向最接近的整数四舍五入,不进行报错而继续运行)。

3维数据的卷积运算

之前的卷积运算的例子都是以有高、长方向的2维形状为对象的。但是,图像是3维数据,除了高、长方向之外,还需要处理通道方向。这里,我们按照与之前相同的顺序,看一下对加上了通道方向的3维数据进行卷积运算的例子。

下图是卷积运算的例子,和2维数据时相比,可以发现纵深方向(通道方向)上特征图增加了。通道方向上有多个特征图时,会按通道进行输入数据和滤波器的卷积运算,并将结果相加,从而得到输出。

需要注意的是,在3维数据的卷积运算中,输入数据和滤波器的通道数要设为相同的值。在这个例子中,输入数据和滤波器的通道数一致,均为3。滤波器大小可以设定为任意值(不过,每个通道的滤波器大小要全部相同)。这个例子中滤波器大小为 (3,3) ,但也可以设定为 (2,2) ,(1,1) ,(5,5) 等任意值。再强调一下,通道数只能设定为和输入数据的通道数相同的值。

结合方块思考

将数据和滤波器结合长方体的方块来考虑,3维数据的卷积运算会很容易理解。方块是如下图所示的3维长方体。把3维数据表示维多维数组时,书写顺序为 (channel, weight, width) 。比如,通道数为 C,高度为 H,长度为 W 的数据的形状可以写成 。滤波器也一样,要按照 (channel, weight, width) 的顺序书写,如

在这个例子中,数据输出是 1 张特征图。所谓1张特征图,换句话说,就是通道数为1的特征图。那么,如果要在通道方向上也拥有多个卷积运算的输出,该怎么做呢?为此,就需要用到多个滤波器(权重)。用图表示的话,如下图所示

图中,通过应用 个滤波器,输出特征图也生成了 个。如果将这 个特征图汇集在一起,就得到了形状为 的方块。将这个方块传给下一层,就是 CNN 的处理流。

因此,关于卷积运算的滤波器,也必须考虑滤波器的数量。因此,作为4维数据,滤波器的权重数据要按 (output_channel, input_channel, height, width) 的顺序书写。比如,通道数为3, 大小为 5 × 5 的滤波器有20个时,可以写成 (20, 3, 5, 5) 。

卷积运算中存在偏置,在上图的例子中,如果进一步追加偏置的加法运算处理,则结果如下图所示。其中每个通道只有一个偏置,偏置的形状是 ,滤波器的输出结果的形状是 。这里不同形状的方块相加时,可以基于 NumPy 的广播功能轻松实现。

批处理

神经网络的处理中进行了将输入数据打包的批处理。为此,需要将在各层间传递的数据保存为4维数据。具体地将,就是按 (batch_num, channel, height, width) 的顺序保存数据。比如,将之前的处理改成为 个数据进行批处理时,数据的形状如下图所示。

下图的批处理版的数据流中,在各个数据的开头添加了批用的维度。像这样,数据作为4维的形状在各层的形状在各层间传递。这里需要注意的是,网络间传递的是4维数据,对这 个数据进行了卷积运算。也就是说,批处理将 次的处理汇总成了1次进行。

7.3 池化层

池化是缩小高、长方向上的空间的运算。比如,如下图所示,进行将 2×2 的区域集约成1个元素的处理,缩小空间大小。

这里的例子是按步幅2进行 2×2 的 Max 池化时的处理顺序。“Max池化”是获取最大值的运算,"2×2"表示目标区域的大小。如图所示,从2×2的区域中取出最大的元素。此外,这个例子中将步幅设为了2,所以2×2的窗口的移动间隔为2个元素。另外,一般来说,池化的窗口大小会和步幅设定成相同的值。比如,3×3 的窗口的步幅都会设为3,4×4 的窗口的步幅会设为4等。

除了 Max 池化之外,还有 Average 池化等。Average 池化是计算目标区域的平均值。在图像识别领域,主要使用 Max 池化。因此,本书中说明“池化层”时,指的是 Max 池化。

池化层有以下特征

  1. 没有要学习的参数
  2. 输入数据和输出数据的通道数不会发生变化,二者通道数相同,如下图所示,计算是按照通道独立进行的。如下图所示

  1. 对微小的位置变化具有鲁棒性(健壮):当输入数据发生微小偏差时,池化仍会返回相同的结果,如下图所示

7.4 卷积层和池化层的实现

前面我们详细介绍了卷积层和池化层,本节我们就用 Python 来实现这两个层。和第5章一样,也给进行实现的类赋予 forward 和 backward 方法,并使其可以作为模块使用。

大家可能会感觉卷积层和池化层的实现很复杂,但实际上,通过使用某种技巧,就可以轻松地实现。本节将介绍这种技巧,将问题简化,然后再进行卷积层的实现。

4维数组

如前所述,CNN中各层间传递的数据是4维数据。所谓4维数据,比如数据的形状是 (10,1,28,28) ,这它对应10个高为28,长为28,通道为1的数据。用Python来实现的话,如下所示。

1
2
x = np.random.rand(10, 1, 28, 28) # 随机生成数据
x.shape

这里,如果要访问第1个数据,只要写 就可以了。

1
x[0].shape
1
(1, 28, 28)

如果要范围第1个数据的第1个通道的空间数据,可以写成下面这样。

1
x[0, 0] # 或者x[0][0]

像这样,CNN中处理 的是4维数据,因此卷积运算的实现看上去会很复杂,但是通过使用下面要介绍的 im2col 这个技巧,问题就会变得很简单。

基于 im2col 的展开

如果老老实实地实现卷积运算,估计要重复好几层的 for 语句。这样的实现有点麻烦,而且,NumPy 中存在使用 for 语句后处理变慢的特点(NumPy中,访问元素时最好不要用 for 语句)。这里,我们不适用 for 语句,而是使用 im2col 这个便利的函数进行简单的实现。

im2col 是一个函数,将输入数据展开以合适滤波器(权重)。如图所示,对3维的输入数据应用 im2col 后,数据转为 2 维矩阵(正确地讲,是把包含批数量的4维数据转换成了2维数据)。

im2col 会把输入数据展开以适合滤波器。具体的说,如下图所示,对输入数据,将应用滤波器的区域(3维方块)横向展开为1列。im2col 会在所有应用滤波器的地方进行这个展开处理。

在这个图中,为了便于观察,将步幅设置得很大,以使滤波器的应用区域不重叠。而在实际的卷积运算中,滤波器的应用区域几乎都是重叠的。在滤波器的应用区域重叠的情况下,使用 im2col 展开后,展开的元素个数会多余原方块的元素个数。因此,使用 im2col 的实现存在比普通的实现消耗更多内存的缺点。但是,汇总成一个大的矩阵进行计算,对计算机的计算颇有益处。比如,在矩阵计算的库等中,矩阵计算的实现已被高度最优化,可以高速的进行大矩阵的乘法运算。因此,通过归结到矩阵计算上,可以有效地利用线性代数库。

im2col 这个名称是 “image to column” 的缩写,翻译过滤就是“从图像到矩阵”的意思。Caffe, Chainer 等深度学习框架中有名为 im2col 的函数,并且在卷积层的实现中,都使用了 im2col 。

使用 im2col 展开输入数据后,之后就只需将卷积层的滤波器纵向展开为 1列,并计算2个矩阵的乘积即可。这和全连接层的 Affine 层进行的处理基本相同。

如下图所示,基于 im2col 方式的输出结果是2维矩阵。因为CNN中数据会保存为4维数组,所以要将2维输出数据转换为合适的形状。以上就是卷积层的实现流程。

卷积层的实现

本书提供了 im2col 函数,并将这个 im2col 函数作为黑盒(不关心内部实现)使用。im2col的实现内容在common/util.py中,它的实现(实质上)是一个10行左右的简单函数。有兴趣的读者可以参考。

im2col 这一便捷函数具有以下接口。

im2col 会考虑滤波器大小、步幅、填充,将输入数据慷慨成2维数组。现在我们实际使用一下这个im2col 。

1
2
3
4
5
6
7
8
9
10
11
import sys, os
sys.path.append(os.pardir)
from common.util import im2col

x1 = np.random.rand(1, 3, 7, 7)
col1 = im2col(x1, 5, 5, stride=1, pad=0)
print(col1.shape) # (9, 75)

x2 = np.random.rand(10, 3, 7, 7) # 10个数据
col2 = im2col(x2, 5, 5, stride=1, pad=0)
print(col2.shape) # (90, 75)

这里举了两个例子。第一个是批大小为1、通道为3的7×7的数据,第二个的批大小为10,数据形状和第一个相同。分别对其应用 im2col 函数,在这两种情形下,第2维的元素个数均为 75 。这是滤波器(通道为3, 大小为5×5)的元素个数的总和。批大小为1时,im2col 的结果是 (9,75) 。而第2个例子中批大小为 10,所以保存了10倍的数据,即 (90, 75)。

现在使用 im2col 来实现卷积层。我们这里将卷积层实现名为 Convolution 的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Convolution:

def __init__(self, W, b, stride=1, pad=0):
self.W = W
self.b = b
self.stride = stride
self.pad = pad

def forward(self, x):
FN, C, FH, FW = self.W.shape
N, C, H, W = x.shape
out_h = int(1 + (H + 2*self.pad - FH) / self.stride)
out_w = int(1 + (W + 2*self.pad - FW) / self.stride)

col = im2col(x, FH, FW, self.stride, self.pad)
col_W = self.W.reshape(FN, -1).T # 滤波器的展开
out = np.dot(col, col_W) + self.b

out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)

return out

卷积层的初始化方法将滤波器、偏置、步幅、填充作为参数接收。滤波器是 (FN, C, FH, FW) 的4维形状。另外,FN, C, FH, FW 分别时 Filter Number(滤波器数量)、Channel、Filter Height、Filter width 的缩写。

这里用 im2col 展开输入数据,并用 reshape 将滤波器展开为 2 维数组。然后,计算展开后的矩阵的乘积。展开滤波器的部分如下图所示,将各个滤波器的方块纵向展开为1列。这里通过 reshape(FN, -1) 将参数指定为-1,会自动计算 -1 维度上的元素个数,以使多维数组的元素个数前后一致。比如,(10,3,5,5) 形状的数组的元素个数共有 750 个,指定 reshape(10, -1) 后,就会转换成 (10, 75) 形状的数组。最后通过 NumPy 的 transpose 函数,更改多维数组的轴的顺序。

forward的实现中,最后会将输出大小转换为合适的形状。转换时使用了 NumPy 的 transpose 函数。transpose 会更改多维数组的轴的顺序(没太看懂)。

以上就是卷积层的 forward 处理的实现。通过使用 im2col 进行展开,基本上可以像实现全连接层的 Affine 层一样来实现。接下来是卷积层的方向传播的实现,因为和 Affine 层的实现有很多共通的地方,所以就不再介绍了。但有一点需要注意,在进行卷积层的反向传播时,必须进行 im2col 的逆处理。这可以使用本书提供的 col2im 函数(在common/util.py 中)来进行。除了使用 col2im 这一点,卷积层的反向传播和 Affine 层的实现方式都一样。卷积层的反向传播的实现在 common/layer.py 中。

池化层的实现

池化层的实现和卷积层相同,都使用 im2col 展开输入数据。不过,池化的情况下,在通道方向上是独立的,这一点和卷积层不同。具体地讲,如下图所示,池化的应用区域按通道单独展开。

像这样展开后,只需要对展开的矩阵求各行的最大值,并转换为合适的形状即可。

上面就是池化层的 forward 处理的实现流程。下面来看一下 Python 的实现示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Pooling:
def __init__(self, pool_h, pool_w, stride=1, pad=0):
self.pool_h = pool_h
self.pool_w = pool_w
self.stride = stride
self.pad = pad

def forward(self, x):
N, C, H, W = x.shape
out_h = int(1 + (H - self.pool_h) / self.stride)
out_w = int(1 + (W - self.pool_w) / self.stride)

# 展开(1)
col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
col = col.reshape(-1, self.pool_h*self.pool_w)

# 最大值(2)
out = np.max(col, axis=1)
# 转换(3)
out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)

return out

如上图所示,池化层的实现按下面3个阶段进行。

  1. 展开输入数据
  2. 求各行的最大值
  3. 转换为合适的输出大小

各阶段的实现都很简单,只有一两行代码。

以上就是池化层的 forward 层处理的介绍。如上所述,通过将输入数据展开为容易进行池化的形状,后面的实现就会变得非常简单。

关于池化层的 backward 处理,之前已经介绍过相关内容,这里就不再介绍了(没介绍啊)。另外,池化层的 backward 处理可以参考 ReLU 层的实现中使用的 max 的反向传播。池化层的实现在 common/layers.py 中。

7.5 CNN的实现

我们已经实现了卷积层和池化层,现在来组合这些层,搭建进行手写数字识别的CNN。这里要实现如下图所示的CNN。

如图所示,网络的构成是 “Convolution - ReLU - Pooling - Affine - ReLU - Affine - Softmax” 。我们将它实现为名为 SimpleConvNet 的类。

首先来看一下 SimpleConvNet 的初始化(__init__ ),取下面这些参数。

这里,卷积层的超参数通过名为 conv_param 的字典传入。我们设想它会像 {‘filter_num’:30,‘filter_size’:5,‘pad’:0, ‘stride’:1} 这样,保存必要的超参数值。

SimpleConvNet 的初始化的实现稍长,我们分为3部分来说明,首先是初始化的最开始部分。这里由初始化参数传入的卷积层的超参数从字典中取了出来(以方便后面使用),然后,计算卷积层的输出大小。

1
2
3
4
5
6
7
8
9
10
def __init__(self, input_dim=(1, 28, 28), 
conv_param={'filter_num':30, 'filter_size':5, 'pad':0, 'stride':1},
hidden_size=100, output_size=10, weight_init_std=0.01):
filter_num = conv_param['filter_num']
filter_size = conv_param['filter_size']
filter_pad = conv_param['pad']
filter_stride = conv_param['stride']
input_size = input_dim[1]
conv_output_size = (input_size - filter_size + 2*filter_pad) / filter_stride + 1
pool_output_size = int(filter_num * (conv_output_size/2) * (conv_output_size/2))

接下来是权重参数的初始化部分。学习所需要的参数是第1层的卷积层和剩余两个全连接层的权重和偏置。将这些参数保存在实例变量的 params 字典中。将第1层的卷积层的权重设为关键字 W1 ,偏置设为关键字 b1 。同样,分别用关键字 W2, b2 和 关键字 W3,b3 来保存第2个和第3个全连接层的权重和偏置。

1
2
3
4
5
6
7
8
9
10
11
# 初始化权重
self.params = {}
self.params['W1'] = weight_init_std * \
np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
self.params['b1'] = np.zeros(filter_num)
self.params['W2'] = weight_init_std * \
np.random.randn(pool_output_size, hidden_size)
self.params['b2'] = np.zeros(hidden_size)
self.params['W3'] = weight_init_std * \
np.random.randn(hidden_size, output_size)
self.params['b3'] = np.zeros(output_size)

最后生成必要的层

1
2
3
4
5
6
7
8
9
10
11
# 生成层
self.layers = OrderedDict()
self.layers['Conv1'] = Convolution(self.params['W1'], self.params['b1'],
conv_param['stride'], conv_param['pad'])
self.layers['Relu1'] = Relu()
self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
self.layers['Affine1'] = Affine(self.params['W2'], self.params['b2'])
self.layers['Relu2'] = Relu()
self.layers['Affine2'] = Affine(self.params['W3'], self.params['b3'])

self.last_layer = SoftmaxWithLoss()

从最前面开始按顺序向有序字典(OrderDict)的 layers 中添加层。只有最后的 SoftmaxWithLoss 层被添加到别的变量 last_layer 中。

以上就是 SimpleConvNet 的初始化中进行的处理。像这样初始化后,进行推理的 predict 方法和求损失函数的 loss 方法就可以像下面这样实现。

1
2
3
4
5
6
7
8
9
10
11
12
def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x)

return x

def loss(self, x, t):
"""求损失函数
参数x是输入数据、t是教师标签
"""
y = self.predict(x)
return self.last_layer.forward(y, t)

这里,参数 x 是输入数据,t 是标签。由于推理的 predict 方法从头开始依次调用已经添加的层,并将结果传递给下一层。在求损失函数的 loss 方法中,除了使用 predict 方法进行的 forward 处理之外,还会继续进行 forward 方法,直到到大最后的 SoftmaxWithLoss 层。

接下来是基于误差反向传播法求梯度的代码实现。参数的梯度通过误差反向传播法求出,通过把正向传播和反向传播组装在一起完成。因为已经在各层正确实现了正向传播和反向传播的功能。所以这里只需要以合适的顺序调用即可。最后,把各个权重参数的梯度保存到 grads 字典中。这就是 SimpleConvNet 的实现。

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
27
28
29
30
31
32
33
def gradient(self, x, t):
"""求梯度(误差反向传播法)

Parameters
----------
x : 输入数据
t : 教师标签

Returns
-------
具有各层的梯度的字典变量
grads['W1']、grads['W2']、...是各层的权重
grads['b1']、grads['b2']、...是各层的偏置
"""
# forward
self.loss(x, t)

# backward
dout = 1
dout = self.last_layer.backward(dout)

layers = list(self.layers.values())
layers.reverse()
for layer in layers:
dout = layer.backward(dout)

# 设定
grads = {}
grads['W1'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
grads['W2'], grads['b2'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
grads['W3'], grads['b3'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

return grads

现在,使用这个 SimpleConvNet 学习 MNIST 数据集。用于学习的代码和 4.5 节介绍的代码基本相同,因此这里不再罗列。

如果使用 MNIST 数据集训练 SimpleConvNet ,则训练数据的识别率为 99.82% ,测试数据的识别率为 98.96% (每次学习的识别精度都会发生一些误差)。就小型网络来说,这是一个非常高的识别率。下一章,我们会通过进一步叠加层来加深网络,实现测试数据的识别率超过 99% 的网络。

如上所述,卷积层和池化层是图像识别中必备的模块。CNN可以有效读取图像中的某种特性,在手写数字识别中,还可以实现高精度的识别。

7.6 CNN的可视化

CNN用到的卷积层在“观察”什么呢?本节将通过卷积层的可视化,探索CNN中到底进行了什么处理。

第1层权重的可视化

刚才我们对 MNIST 数据集进行了简单的 CNN 学习。当时,第1层的卷积层的权重的性状是(30,1,5,5),即30个大小为 5×5,通道为1的滤波器。滤波器大小是5×5,通道数是1,意味着滤波器可以可视化为1通道的灰度图像。现在,我们将卷积层(第1层)的滤波器显示为图像。这里,我们来比较一下学习前和学习后的权重,结果如下图所示(源代码在ch07/visualize_filter.py中)。

图中,学习前的滤波器是随机进行初始化的,所以在黑白的浓淡上没有规律可循,但学习后的滤波器变成了有规律的图像。我们发现,通过学习,滤波器被更新成了有规律的滤波器,比如从白到黑渐变的滤波器,含有块状区域(称为blob)的滤波器等。

如果要问图7-24中右边的有规律的滤波器在“观察”什么,答案就是它在观察边缘(颜色变化的分界线)和斑块(局部的块状区域)等。比如左半部分为白色,右半部分为黑色的滤波器的情况下,如下图所示,会对垂直方向上的边缘有响应。

图中显示了选择两个学习完的滤波器对输入图像进行卷积处理时的结果。我们发现“滤波器1”对垂直方向上的边缘有响应,“滤波器2”对水平方向上的边缘有响应。

由次可知,卷积层的滤波器会提取边缘或斑块等原始信息。而刚才实现的 CNN 会将这些原始信息传递给后面的层。

基于分层结构的信息提取

上面的结果是针对第1层的卷积层得到的。第1层的卷积层中提取了边缘或斑块等“低级”信息,那么在堆叠了多层的CNN中,各层中又会提取什么样的信息呢?根据深度学习的可视化相关的研究,随着层次加深,提取的信息(正确地讲,是反映强烈的神经元)也越来越抽象。

下图中展示了进行一般物体识别(车或狗等)的8层CNN。这个网络结构的名称是下一节要介绍的 AlexNet 。 AlexNet 网络中堆叠了多层卷积层和池化层,最后经过全连接层输出结果。下图中的方块表示的是中间数据,对于这些中间数据,会连续应用卷积运算。

如图所示,如果堆叠了多层卷积层,则随着层次加深,提取的信息也愈加复杂、抽象,这是深度学习中很有意思的一个地方。最开始的层对简单的边缘有响应,再后面的层对更加复杂的物体部件有响应。也就是说,随着层次加深,神经元从简单的形状向“高级”信息变化。换句话说,就像我们理解东西的“含义”一样,响应的对象在逐渐变化。

7.7 具有代表性的CNN

关于CNN,迄今为止已经提出了各种网络结构。这里,我们介绍其中特别重要的两个网络,一个是在 1998年首次被提出的CNN元组 LeNet ,另一个是在深度学习受到关注的 2012 年被提出的 AlexNet 。

LeNet

LeNet 在1998年被提出,是进行手写数字识别的网络。如下图所示,它有连续的卷积层和池化层(正确地讲,是只“抽取元素”的子采样层),最后经过全连接层输出结果。

和“现在的CNN”相比,LeNet 有几个不同点。第一个不同点在于激活函数。LeNet 中使用 sigmoid 函数,而现在的 CNN 中主要使用 ReLU 函数。此外,原始的 LeNet 中采用子采样(subsampling)缩小中间数据的大小,而现在的 CNN 中 Max 池化是主流。

综上,LeNet 与现在的 CNN 虽然有些许不同,但差别并不是那么大。想到 LeNet 是20多年前提出的最早的 CNN,还是令人称奇的。

AlexNet

在 LeNet 问世 20 多年后,AlexNet 被发布出来。AlexNet 是引发深度学习热潮的导火线,不过它的网络结构和 LeNet 基本上没有什么不同,如下图所示

AlexNet 叠有多个卷积层和池化层,最后经由全连接层输出结果。虽然结构上 AlexNet 和 LeNet 没有大的不同,但有以下几点差异。

  • 激活函数使用 ReLU
  • 使用局部正规化的 LRN (Local Response Normalization) 层(?)
  • 使用 Dropout

如上所述,关于网络结构,LeNet 和 AlexNet 没有太大的不同。但是,围绕它们的环境和计算机技术有了很大的进步。具体地说,现在任何人都可以获得大量的数据。而且,擅长大规模并行计算的 GPU 得到普及,高速并行大量的运算已经成为可能。大数据和GPU已称为深度学习发展的巨大的原动力。

第8章 深度学习

深度学习是加深了层的深度神经网络。基于之前介绍的网络,只需通过叠加层,就可以创建深度网络。本章我们将看一下深度学习的性质、课题和可能性,然后对当前的深度学习进行概括性的说明。

8.1 加深网络

关于神经网路,我们已经学了很多东西,比如构建神经网络的各种层,学习时的有效技巧、对图像特别有效的 CNN 、参数的最优化方法等,这些都是深度学习中的重要技术。本节我们将这些已经学过的技术汇总起来,创建一个深度网络,调整MNIST数据集的手写数字识别。

向更深的网络出发

这里我们创建一个如下图所示的 CNN ,这个网络参考了下一节要介绍的 VGG 。

如图所示,这个网络的层比之前实现的网络都要深。这里使用的卷积层全都是 3×3 的小型滤波器,特点是随着层的加深,通道数变大(卷积层的通道数从前面的层开始按顺序以16、16、32、32、64、64的方式增加)。此外,如图所示,插入了池化层,以逐渐减小中间数据的空间大小;并且,后面的全连接层中使用了 Dropout 层。

这个网络使用 He 初始值作为权重的初始值,使用 Adam 更新权重参数。把上述内容总结起来,这个网络有如下特点。

  • 基于 3×3 的小型滤波器的卷积层
  • 激活函数是 ReLU
  • 全连接层的后面使用 Dropout 层
  • 基于 Adam 的最优化
  • 使用 He 初始值作为权重初始值

从这些特征中可以看出,这个网络使用了多个之前介绍的神经网络技术。现在,我们使用这个网络进行学习。先说一下结果,这个网络的识别精度为 99.38% ,可以说是非常优秀的性能了。

这个网络的错误识别率只有 0.62% 。这里我们实际看一下在什么样的图像上发生了识别错误。下图中显示了识别错误的例子。

可以看到,这些图像对于人类而言也很难判断。实际上,这里有几个图像很难判断是哪个数字,即使是我们人类,也同样会犯“识别错误”。

这次的深度CNN尽管识别精度很高,但是对于某些图像,也犯了和人类同样的“识别错误”。从这一点上,我们也可以感受到深度 CNN 中蕴藏着巨大的可能性。

进一步提高识别精度

在一个标题为“What is the class of this image ?”的网站上,以排行榜的形式刊登了目前为止通过论文等渠道发表的针对各种数据集的方法的识别精度。榜单上的前几名大都是基于 CNN 的方法。顺便说一下,截止到2016年6月,对MNIST数据集的最高识别精度是99.79%(错误识别率为0.21%),该方法也是以CNN为基础的。不过,它用的CNN并不是特别深层的网络(卷积层为2层、全连接层为2层的网络)。

参考刚才排行榜中前几名的方法,可以发现进一步提高识别精度的技术和线索。比如,集成学习,学习率衰减,Data Augmentation (数据扩充)等都有助于提高识别精度。尤其是数据扩充,虽然方法简单,但在提高识别精度上效果显著。

数据扩充基于算法“人为地”扩充输入图像(训练图像)。具体地说,如下图所示,对于输入图像,通过施加旋转、垂直或水平方向上的移动等微小变化,增加图像的数量。这在数据集的图像数量有限时尤其有效。

除了上面的变形话,数据扩充还可以通过其他方法扩充图像,比如裁剪图像的 “crop处理”、将图像左右翻转的 “flip处理”等。对于一般的图像,施加亮度等外观上的变化、放大缩小等尺度上的变化也是有效的。不管怎样,通过数据扩充巧妙地增加训练图像,就可以提高深度学习的识别精度。虽然这个看上去只是一个简单的技巧,不过经常会有很好的效果。

加深层的动机

关于加深层的重要性,现状是理论研究还不够透彻。尽管目前相关理论还比较贫乏,但是有几点可以从过往的研究和实验中得以解释(虽然有一些直观)。本节就加深层的重要性,给出一些增补性的数据和说明。

首先,加深层可以减少网络的参数数量。说得详细一点,就是与没有加深层的网络相比,加深了层的网络可以用更少的参数达到同等水平的表现力。这一点结合卷积运算中的滤波器大小来思考就好理解了。比如,下图展示了由 5 × 5 的滤波器构成的卷积层。

这里每个输出节点都是从输入数的某个 5 × 5 的区域算出来的。接下来我们思考一下下图中重复两次 3 × 3 的卷积运算的情形。此时,每个输出数据均是由中间数据的某个 3 × 3 的区域算出来的,而中间数据的 3 × 3 的区域又是由前一个输入数据的 5 × 5的区域算出来的。也就是说,下图中的输出数据是“观察”了输入数据的某个5×5的区域后计算出来的。

相比于前面的参数数量 25 ,后者一共是 18 ,通过叠加卷积层,参数数量减少了。并且,这个参数数量之差会随着层的加深而变大。比如,重复三次 3×3 的卷积运算时,参数的数量总共是 27 。而为了用依次卷积运算“观察”与之相同的区域,需要一个7×7的滤波器,此时的参数数量是 49。

加深层的另一个好处是使学习更加高效。与没有加深层的网络相比,通过加深层,可以减少学习数据,从而高效地进行学习。例如 CNN 的卷积层会分层次地提取信息,会从对边缘等简单的形状有响应,到对纹理、物体部件等更加复杂的东西有响应。

比如我们考虑一下“狗”的识别问题,如果要用浅层神经网络解决这个问题的话,卷积层需要一下子理解很多“狗”的特征,因此需要大量富有差异性的学习数据,这会导致学习需要花费很多时间。

不过,通过加深网络,就可以分层次地分解学习的问题。因此,各层需要学习的问题就变成了更简单的问题。比如,最开始的层只要专注于学习边缘就好,这样一来,只需用较少的学习数据就可以高效地进行学习。

通过加深层,可以分层次地传递信息,这一点很重要。因为提取了边缘地层地下一层能够使用边缘的信息,所以应该能够高效地学习更加高级的模式。也就是说,通过加深层,可以将各层要学习的问题分解成容易解决的简单问题,从而可以进行高效的学习。

8.2 深度学习的小历史

一般认为,现在深度学习之所以受到大量关注,其契机是2012年举办的大规模图像识别大赛ILSVRC(ImageNet Large Scale Visual Recognition Challenge)。在那年的比赛中,基于深度学习的方法(通称AlexNet)以压倒性的优势胜出,彻底颠覆了以往的图像识别方法。2012年深度学习的这场逆袭成为一个转折点,在之后的比赛中,深度学习一直活跃在舞台中央。本节我们以ILSVRC这个大规模图像识别比赛为轴,看一下深度学习最近的发展趋势。

ImageNet

ImageNet 是拥有超过100万张图像的数据集。它包含了各种各样的图像,并且每张图像都被关联了标签。每年都会举办使用这个巨大数据集的 ILSVRC 图像识别大赛。

ILSVRC大赛有多个测试项目,其中之一是“类别分类”(classification),在该项目中,会进行1000个类别的分类,比试识别精度。我们来看一下最近几年的ILSVRC大赛的类别分类项目的结果。图8-8中展示了从2010年到
2015年的优胜队伍的成绩。这里,将前5类中出现正确解的情况视为“正确”,此时的错误识别率用柱形图来表示。

图8-8中需要注意的是,以2012年为界,之后基于深度学习的方法一直居于首位。实际上,我们发现2012年的AlexNet大幅降低了错误识别率。并且,此后基于深度学习的方法不断在提升识别精度。特别是2015年的ResNet(一个超过150层的深度网络)将错误识别率降低到了3.5%。据说这个结果甚至超过了普通人的识别能力。

这些年深度学习取得了不斐的成绩,其中VGG、GoogLeNet、ResNet 已广为人知,在与深度学习有关的各种场合都会遇到这些网络。下面我们就来简单地介绍一下这3个有名的网络。

VGG

VGG是由卷积层和池化层构成的基础的CNN。不过,如图8-9所示,它的特点在于将有权重的层(卷积层或者全连接层)叠加至16层(或者 19层),具备了深度(根据层的深度,有时也称为“VGG16”或“VGG19”)。
VGG中需要注意的地方是,基于3×3的小型滤波器的卷积层的运算是连续进行的。如图8-9所示,重复进行“卷积层重叠2次到4次,再通过池化层将大小减半”的处理,最后经由全连接层输出结果。

VGG在2014年的比赛中最终获得了第2名的成绩(下一节介绍的GoogleNet是2014年的第1名)。虽然在性能上不及GoogleNet,但因为VGG结构简单,应用性强,所以很多技术人员都喜欢使用基于 VGG 的网络。

GoogLeNet

GoogLeNetde 网络结构如下图所示。图中的矩形表示卷积层、池化层等。

只看图的话,这似乎是一个看上去非常复杂的网络结构,但实际上它基本上和之前介绍的CNN结构相同。不过,GoogLeNet的特征是,网络不仅在纵向上有深度,在横向上也有深度(广度)。

GoogLeNet在横向上有“宽度”,这称为“Inception结构”,以图8-11所示的结构为基础。如图8-11所示Inception结构使用了多个大小不同的滤波器(和池化),最后再合并它们的结果。GoogLeNet的特征就是将这个Inception结构用作一个构件(构成元素)。此外,在GoogLeNet中,很多地方都使用了大小为 1× 1 的滤波器的卷积层。这个 1×1的卷积运算通过在通道方向上减小大小,有助于减少参数和实现高速化处理(具体请参数原始论文)。

ResNet

ResNet 是微软团队开发的网络。它的特征在于具有比以前的网络更深的结构。

们已经知道加深层对于提升性能很重要。但是,在深度学习中,过度加深层的话,很多情况下学习将不能顺利进行,导致最终性能不佳。ResNet中,为了解决这类问题,导入了“快捷结构”(也称为“捷径”或“小路”)。导入这个快捷结构后,就可以随着层的加深而不断提高性能了(当然,层的加深也是有限度的)。

如图8-12所示,快捷结构横跨(跳过)了输入数据的卷积层,将输入x合计到输出。

图8-12中,在连续2层的卷积层中,将输入x跳着连接至2层后的输出。这里的重点是,通过快捷结构,原来的2层卷积层的输出F(x)变成了F(x)+x。通过引入这种快捷结构,即使加深层,也能高效地学习。这是因为,通过快捷结构,反向传播时信号可以无衰减地传递(?)。

因为快捷结构只是原封不动地传递输入数据,所以反向传播时会将来自上游的梯度原封不动地传向下游。这里的重点是不对来自上游的梯度进行任何处理,将其原封不动地传向下游。因此,基于快捷结构,不用担心梯度会变小(或变大),能够向前一层传递“有意义的梯度”。通过这个快捷结构,之前因为加深层而导致的梯度变小的梯度消失问题就有望得到缓解。

ResNet 以前面介绍过的 VGG 网络为基础,引入快捷结构以加深层,其结果如下图所示

如图8-13所示,ResNet通过以2个卷积层为间隔跳跃式地连接来加深层。另外,根据实验的结果,即便加深到150层以上,识别精度也会持续提高。并且,在ILSVRC大赛中,ResNet的错误识别率为3.5%(前5类中包含正确解这一精度下的错误识别率),令人称奇。

实践中经常会灵活应用使用ImageNet这个巨大的数据集学习到的权重数据,这称为迁移学习,将学习完的权重(的一部分)复制到其他神经网络,进行再学习(fine tuning)。比如,准备一个和VGG相同
结构的网络,把学习完的权重作为初始值,以新数据集为对象,进行再学习。迁移学习在手头数据集较少时非常有效。

8.3 深度学习的高速化

本节我们将焦点放在深度学习的计算的高速化上,然后逐步展开。深度学习的实现在8.1节就结束了,本节要讨论的高速化(支持GPU等)并不进行实现。

需要努力解决的问题

在介绍深度学习的高速化之前,我们先来看一下深度学习中什么样的处理比较耗时。图8-14中以AlexNet的forward处理为对象,用饼图展示了各层所耗费的时间。

从图中可知,AlexNex中,大多数时间都被耗费在卷积层上。实际上,卷积层的处理时间加起来占GPU整体的95%,占CPU整体的89%!因此,如何高速、高效地进行卷积层中的运算是深度学习的一大课题。虽然图8-14
是推理时的结果,不过学习时也一样,卷积层中会耗费大量时间。

正如7.2节介绍的那样,卷积层中进行的运算可以追溯至乘积累加运算。因此,深度学习的高速化的主要课题就变成了如何高速、高效地进行大量的乘积累加运算。

基于GPU的高速化

GPU原本是作为图像专用的显卡使用的,但最近不仅用于图像处理,也用于通用的数值计算。由于GPU可以高速地进行并行数值计算,因此GPU计算的目标就是将这种压倒性的计算能力用于各种用途。所谓GPU计算,
是指基于GPU进行通用的数值计算的操作。

深度学习中需要进行大量的乘积累加运算(或者大型矩阵的乘积运算)。这种大量的并行运算正是GPU所擅长的(反过来说,CPU比较擅长连续的、复杂的计算)。因此,与使用单个CPU相比,使用GPU进行深度学习的运算可以达到惊人的高速化。下面我们就来看一下基于GPU可以实现多大程度的高速化。图8-15是基于CPU和GPU进行AlexNet的学习时分别所需的时间。从图中可知,使用CPU要花40天以上的时间,而使用GPU则可以将时间缩短至6天。此外,还可以看出,通过使用cuDNN这个最优化的库,可以进一步实现高速化。

GPU主要由NVIDIA和AMD两家公司提供。虽然两家的GPU都可以用于通用的数值计算,但与深度学习比较“亲近”的是NVIDIA的GPU。实际上,大多数深度学习框架只受益于NVIDIA的GPU。这是因为深度学习的框架中使用了NVIDIA提供的CUDA这个面向GPU计算的综合开发环境。

图8-15中出现的cuDNN是在CUDA上运行的库,它里面实现了为深度学习最优化过的函数等。

通过im2col可以将卷积层进行的运算转换为大型矩阵的乘积。这个im2col方式的实现对GPU来说是非常方便的实现方式。这是因为,相比按小规模的单位进行计算,GPU更擅长计算大规模的汇总好的数据。也就是说,通过基于im2col以大型矩阵的乘积的方式汇总计算,更容易发挥出GPU的能力。

分布式学习

虽然通过GPU可以实现深度学习运算的高速化,但即便如此,当网络较深时,学习还是需要几天到几周的时间。并且,前面也说过,深度学习伴随着很多试错。为了创建良好的网络,需要反复进行各种尝试,这样一来就必然会产生尽可能地缩短一次学习所需的时间的要求。于是,将深度学习的学习过程扩展开来的想法(也就是分布式学习)就变得重要起来。

为了进一步提高深度学习所需的计算的速度,可以考虑在多个GPU或者多台机器上进行分布式计算。现在的深度学习框架中,出现了好几个支持多GPU或者多机器的分布式学习的框架。其中,Google的TensorFlow、微
软的CNTK(Computational Network Toolki)在开发过程中高度重视分布式学习。以大型数据中心的低延迟·高吞吐网络作为支撑,基于这些框架的分布式学习呈现出惊人的效果。

基于分布式学习,可以达到何种程度的高速化呢?图8-16中显示了基于
TensorFlow的分布式学习的效果。

如图8-16所示,随着GPU个数的增加,学习速度也在提高。实际上,与使用1个GPU时相比,使用100个GPU(设置在多台机器上,共100个)似乎可以实现56倍的高速化!这意味着之前花费7天的学习只要3个小时就能完成,充分说明了分布式学习惊人的效果。

关于分布式学习,“如何进行分布式计算”是一个非常难的课题。它包含了机器间的通信、数据的同步等多个无法轻易解决的问题。可以将这些难题都交给TensorFlow等优秀的框架。这里,我们不讨论分布式学习的细节。关于分布式学习的技术性内容,请参考TensorFlow的技术论文(白皮书)等。

运算精度的位数缩减

在深度学习的高速化中,除了计算量之外,内存容量、总线带宽等也有可能成为瓶颈。关于内存容量,需要考虑将大量的权重参数或中间数据放在内存中。关于总线带宽,当流经GPU(或者CPU)总线的数据超过某个限制时,就会成为瓶颈。考虑到这些情况,我们希望尽可能减少流经网络的数据的位数。

计算机中为了表示实数,主要使用64位或者32位的浮点数。通过使用较多的位来表示数字,虽然数值计算时的误差造成的影响变小了,但计算的处理成本、内存使用量却相应地增加了,还给总线带宽带来了负荷。

关于数值精度(用几位数据表示数值),我们已经知道深度学习并不那么需要数值精度的位数。这是神经网络的一个重要性质。这个性质是基于神经网络的健壮性而产生的。这里所说的健壮性是指,比如,即便输入图像附有一些小的噪声,输出结果也仍然保持不变。可以认为,正是因为有了这个健壮性,流经网络的数据即便有所“劣化”,对输出结果的影响也较小。

计算机中表示小数时,有32位的单精度浮点数和64位的双精度浮点数等格式。根据以往的实验结果,在深度学习中,即便是16位的半精度浮点数(half float),也可以顺利地进行学习。实际上,NVIDIA的下一代GPU
框架Pascal也支持半精度浮点数的运算,由此可以认为今后半精度浮点数将被作为标准使用。

NVIDIA的Maxwell GPU虽然支持半精度浮点数的存储(保存数据的功能),但是运算本身不是用16位进行的。下一代的Pascal框架,因为运算也是用16位进行的,所以只用半精度浮点数进行计算,就有望实现超过上一代GPU约2倍的高速化。

以往的深度学习的实现中并没有注意数值的精度,不过Python中一般使用64位的浮点数。NumPy中提供了16位的半精度浮点数类型(不过,只有16位类型的存储,运算本身不用16位进行),即便使用NumPy的半精度
浮点数,识别精度也不会下降。相关的论证也很简单,有兴趣的读者请参考 ch08/half_float_network.py。

关于深度学习的位数缩减,到目前为止已有若干研究。最近有人提出了用1位来表示权重和中间数据的Binarized Neural Networks方法。为了实现深度学习的高速化,位数缩减是今后必须关注的一个课题,特别是在面向嵌入式应用程序中使用深度学习时,位数缩减非常重要。

8.4 深度学习的应用案例

物体检测

物体检测是从图像中确定物体的位置,并进行分类的问题。如图8-17所示,要从图像中确定物体的种类和物体的位置。

观察图8-17可知,物体检测是比物体识别更难的问题。之前介绍的物体识别是以整个图像为对象的,但是物体检测需要从图像中确定类别的位置,而且还有可能存在多个物体。

对于这样的物体检测问题,人们提出了多个基于CNN的方法。这些方法展示了非常优异的性能,并且证明了在物体检测的问题上,深度学习是非常有效的。

图像分隔

图像分割是指在像素水平上对图像进行分类。如图8-19所示,使用以像素为单位对各个对象分别着色的监督数据进行学习。然后,在推理时,对输入图像的所有像素进行分类。

图像标题的生成

有一项融合了计算机视觉和自然语言的有趣的研究,该研究如图8-21所示,给出一个图像后,会自动生成介绍这个图像的文字(图像的标题)。

一个基于深度学习生成图像标题的代表性方法是被称为NIC(Neural Image Caption)的模型。如图8-22所示,NIC由深层的CNN和处理自然语言的RNN(Recurrent Neural Network)构成。RNN是呈递归式连接的网络,经常被用于自然语言、时间序列数据等连续性的数据上。

NIC基于CNN从图像中提取特征,并将这个特征传给RNN。RNN以CNN提取出的特征为初始值,递归地生成文本。这里,我们不深入讨论技术上的细节,不过基本上NIC是组合了两个神经网络(CNN和RNN)的简单结构。基于NIC,可以生成惊人的高精度的图像标题。我们将组合图像和自然语言等多种信息进行的处理称为多模态处理。多模态处理是近年来备受关注的一个领域。

8.5 深度学习的未来

图像风格变换

有一项研究是使用深度学习来 “绘制”带有艺术气息的画。如图8-23所示,输入两个图像后,会生成一个新的图像。两个输入图像中,一个称为“内容图像”,另一个称为“风格图像”。如图8-23所示,如果指定将梵高的绘画风格应用于内容图像,深度学习就会按照指示绘制出新的画作。

图像的生成

生成新的图像时不需要使用任何图像,从零生成新的图像(虽然需要先用大量的图像进行学习,但在“画”新图像时不需要任何图像)。

之前我们见到的机器学习问题都是被称为监督学习(supervised learning)的问题。这类问题就像手写数字识别一样,使用的是图像数据和标签成对给出的数据集。不过这里讨论的问题,并没有给出监督数据,只给了大量的图像(图像的集合),这样的问题称为无监督学习(unsupervised learning)。无监督学习虽然是很早之前就开始研究的领域(Deep Belief Network、Deep Boltzmann Machine
等很有名),但最近似乎并不是很活跃。今后,随着使用深度学习的DCGAN等方法受到关注,无监督学习有望得到进一步发展。

自动驾驶

自动驾驶技术中,正确识别周围环境的技术据说尤其重要。这是因为要正确识别时刻变化的环境、自由来往的车辆和行人是非常困难的。

Deep Q-Network(强化学习)

就像人类通过摸索试验来学习一样(比如骑自行车),让计算机也在摸索试验的过程中自主学习,这称为强化学习(reinforcement learning)。强化学习和有“教师”在身边教的“监督学习”有所不同。

强化学习的基本框架是,代理(Agent)根据环境选择行动,然后通过这个行动改变环境。根据环境的变化,代理获得某种报酬。强化学习的目的是决定代理的行动方针,以获得更好的报酬。

这里需要注意的是,报酬并不是确定的,只是“预期报酬”。比如,在《超级马里奥兄弟》这款电子游戏中,让马里奥向右移动能获得多少报酬不一定是明确的。这时需要从游戏得分(获得的硬币、消灭的敌人等)或者游戏结束等明确的指标来反向计算,决定“预期报酬”。如果是监督学习的话,每个行动都可以从“教师”那里获得正确的评价。

在使用了深度学习的强化学习方法中,有一个叫作Deep Q-Network(通称DQN)的方法。该方法基于被称为Q学习的强化学习算法。这里省略学习的细节,不过在Q学习中,为了确定最合适的行动,需要确定一个被称为最优行动价值函数的函数。为了近似这个函数,DQN使用了深度学习(CNN)。

人工智能AlphaGo击败围棋冠军的新闻受到了广泛关注。这个AlphaGo技术的内部也用了深度学习和强化学习。AlphaGo学习了3000万个专业棋手的棋谱,并且不停地重复自己和自己的对战,积累了大量的学习经验。AlphaGo和DQN都是Google的Deep Mind公司进行的研究,该公司今后的研究值得密切关注。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2019-2025 Vincere Zhou
  • 访问人数: | 浏览次数:

请我喝杯茶吧~

支付宝
微信