第 7 章卷积神经网络
CNN 被用于图像识别、语音识别等各种场合,在图像识别的比赛中,基于深度学习的方法几乎都以 CNN 为基础。
7.1 整体结构
之前介绍的神经网络中,相邻层的所有神经元之间都有连接,这称为全连接(fully-connected)
7.2 卷积层
7.2.1 全连接层存在的问题
之前介绍的全连接的神经网络中使用了全连接层(Affine 层)。在全连接层中,相邻层的神经元全部连接在一起,输出的数量可以任意决定。
全连接层存在的问题就是数据的形状被“忽视”了。比如,输入数据是图像时,图像通常是高、长、通道方向上的 3 维形状。但是,向全连接层输入时,需要将 3 维数据拉平为 1 维数据。实际上,前面提到的使用了 MNIST 数据集的例子中,输入图像就是 1 通道、高 28 像素、长 28 像素的(1, 28, 28)形状,但却被排成 1 列,以 784 个数据的形式输入到最开始的Affine 层。
图像是 3 维形状,这个形状中应该含有重要的空间信息。比如,空间上邻近的像素为相似的值、RBG 的各个通道之间分别有密切的关联性、相距较远的像素之间没有什么关联等,3 维形状中可能隐藏有值得提取的本质模式。但是,因为全连接层会忽视形状,将全部的输入数据作为相同的神经元(同一维度的神经元)处理,所以无法利用与形状相关的信息。
而卷积层可以保持形状不变。当输入数据是图像时,卷积层会以 3 维数据的形式接收输入数据,并同样以 3 维数据的形式输出至下一层。因此,在 CNN 中,可以(有可能)正确理解图像等具有形状的数据。
另 外,CNN 中,有 时 将 卷 积 层 的 输 入 输 出 数 据 称 为 特 征 图(featuremap)。其中,卷积层的输入数据称为输入特征图(input feature map),输出数据称为输出特征图(output feature map)。
7.2.2 卷积运算
卷积层进行的处理就是卷积运算。卷积运算相当于图像处理中的“滤波器运算”。
将各个位置上滤波器的元素和输入的对应元素相乘,然后再求和(有时将这个计算称为乘积累加运算)。然后,将这个结果保存到输出的对应位置。将这个过程在所有位置都进行一遍,就可以得到卷积运算的输出。在全连接的神经网络中,除了权重参数,还存在偏置。CNN 中,滤波器的参数就对应之前的权重。并且,CNN 中也存在偏置。
7.2.3 填充
在进行卷积层的处理之前,有时要向输入数据的周围填入固定的数据(比如 0 等),这称为填充(padding),是卷积运算中经常会用到的处理。例如“幅度为 1 的填充”是指用幅度为 1 像素的 0 填充周围。
7.2.4 步幅
应用滤波器的位置间隔称为步幅(stride)。之前的例子中步幅都是 1,如果将步幅设为 2,则如图 7-7 所示,应用滤波器的窗口的间隔变为 2 个元素。
在图 7-7 的例子中,对输入大小为 (7, 7) 的数据,以步幅 2 应用了滤波器。通过将步幅设为 2,输出大小变为 (3, 3)。像这样,步幅可以指定应用滤波器的间隔。综上,增大步幅后,输出大小会变小。而增大填充后,输出大小会变大。
当值无法除尽时,有时会向最接近的整数四舍五入,不进行报错而继续运行。
7.2.5 3 维数据的卷积运算
需要注意的是,在 3 维数据的卷积运算中,输入数据和滤波器的通道数要设为相同的值。在这个例子中,输入数据和滤波器的通道数一致,均为 3。滤波器大小可以设定为任意值(不过,每个通道的滤波器大小要全部相同)。这个例子中滤波器大小为 (3, 3),但也可以设定为 (2, 2)、(1, 1)、(5, 5) 等任意值。再强调一下,通道数只能设定为和输入数据的通道数相同的值(本例中为 3)。
7.2.6 批处理
神经网络的处理中进行了将输入数据打包的批处理。之前的全连接神经网络的实现也对应了批处理,通过批处理,能够实现处理的高效化和学习时对 mini-batch 的对应。
我们希望卷积运算也同样对应批处理。为此,需要将在各层间传递的数据保存为 4 维数据。具体地讲,就是按 (batch_num, channel, height, width)的顺序保存数据。
7.3 池化层
图 7-14 的例子是按步幅 2 进行 2 × 2 的 Max 池化时的处理顺序。“Max池化”是获取最大值的运算,“2 × 2”表示目标区域的大小。如图所示,从2 × 2 的区域中取出最大的元素。此外,这个例子中将步幅设为了 2,所以2 × 2 的窗口的移动间隔为 2 个元素。另外,一般来说,池化的窗口大小会和步幅设定成相同的值。比如,3 × 3 的窗口的步幅会设为 3,4 × 4 的窗口的步幅会设为 4 等。
池化层有以下特征。
(1)没有要学习的参数
池化层和卷积层不同,没有要学习的参数。池化只是从目标区域中取最大值(或者平均值),所以(2)不存在要学习的参数。
通道数不发生变化经过池化运算,输入数据和输出数据的通道数不会发生变化。计算是按通道独立进行的。 (3)对微小的位置变化具有鲁棒性(健壮)
输入数据发生微小偏差时,池化仍会返回相同的结果。因此,池化对输入数据的微小偏差具有鲁棒性.
7.4 卷积层和池化层的实现
7.4.1 4 维数组
CNN 中各层间传递的数据是 4 维数据。所谓 4 维数据,比如数据的形状是 (10, 1, 28, 28),则它对应 10 个高为 28、长为 28、通道为 1 的数据。用 Python 来实现的话,如下所示。
>>> x = np.random.rand(10, 1, 28, 28) # 随机生成数据
>>> x.shape
(10, 1, 28, 28)
如果要访问第 1 个数据,只要写 x[0] 就可以了(注意 Python 的索引是从 0 开始的)。同样地,用 x[1] 可以访问第 2 个数据。
>>> x[0].shape # (1, 28, 28)
>>> x[1].shape # (1, 28, 28)
如果要访问第 1 个数据的第 1 个通道的空间数据,可以写成下面这样。
>>> x[0, 0] # 或者 x[0][0]
像这样,CNN 中处理的是 4 维数据,因此卷积运算的实现看上去会很复杂,但是通过使用下面要介绍的 im2col 这个技巧,问题就会变得很简单。
7.4.2 基于 im2col 的展开
im2col 是一个函数,将输入数据展开以适合滤波器(权重)。如图 7-17 所示,对 3 维的输入数据应用 im2col 后,数据转换为 2 维矩阵(正确地讲,是把包含批数量的 4 维数据转换成了 2 维数据)。im2col 会把输入数据展开以适合滤波器(权重)。具体地说,对于输入数据,将应用滤波器的区域(3 维方块)横向展开为 1 列。im2col 会在所有应用滤波器的地方进行这个展开处理。
为了便于观察,将步幅设置得很大,以使滤波器的应用区域不重叠。而在实际的卷积运算中,滤波器的应用区域几乎都是重叠的。在滤波器的应用区域重叠的情况下,使用 im2col 展开后,展开后的元素个数会多于原方块的元素个数。因此,使用 im2col 的实现存在比普通的实现消耗更多内存的缺点。但是,汇总成一个大的矩阵进行计算,对计算机的计算颇有益处。比如,在矩阵计算的库(线性代数库)等中,矩阵计算的实现已被高度最优化,可以高速地进行大矩阵的乘法运算。因此,通过归结到矩阵计算上,可以有效地利用线性代数库。
使用 im2col 展开输入数据后,之后就只需将卷积层的滤波器(权重)纵向展开为 1 列,并计算 2 个矩阵的乘积即可。这和全连接层的Affi ne层进行的处理基本相同。基于 im2col 方式的输出结果是 2 维矩阵。因为 CNN 中数据会保存为 4 维数组,所以要将 2 维输出数据转换为合适的形状。以上就是卷积层的实现流程。
7.4.3 卷积层的实现
im2col 这一便捷函数具有以下接口。
im2col (input_data, filter_h, filter_w, stride=1, pad=0)
• input_data―由(数据量,通道,高,长)的4维数组构成的输入数据
• filter_h―滤波器的高
• filter_w―滤波器的长
• stride―步幅
• pad―填充
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的类。
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 分 别 是 FilterNumber(滤波器数量)、Channel、Filter Height、Filter Width 的缩写。
展开滤波器的部分(代码段中的粗体字)如图 7-19 所示,将各个滤波器的方块纵向展开为 1 列。这里通过 reshape(FN,-1) 将参数指定为 -1,这是reshape 的一个便利的功能。通过在 reshape 时指定为 -1,reshape 函数会自动计算 -1 维度上的元素个数,以使多维数组的元素个数前后一致。比如,(10, 3, 5, 5) 形状的数组的元素个数共有 750 个,指定 reshape(10,-1) 后,就会转换成 (10, 75) 形状的数组。forward 的实现中,最后会将输出大小转换为合适的形状。转换时使用了NumPy 的 transpose 函数。transpose 会更改多维数组的轴的顺序。
以上就是卷积层的 forward 处理的实现。通过使用 im2col 进行展开,基本上可以像实现全连接层的 Affine 层一样来实现(5.6 节)。接下来是卷积层的反向传播的实现,和 Affine 层的实现有很多共通的地方。但有一点需要注意,在进行卷积层的反向传播时,必须进行 im2col的逆处理。
7.4.4 池化层的实现
池化层的实现和卷积层相同,都使用 im2col 展开输入数据。不过,池化的情况下,在通道方向上是独立的,这一点和卷积层不同。具体地讲,如图7-21 所示,池化的应用区域按通道单独展开。
像这样展开之后,只需对展开的矩阵求各行的最大值,并转换为合适的形状即可
上面就是池化层的 forward 处理的实现流程。下面 Python 的实现示例。
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. 转换为合适的输出大小。
7.5 CNN 的实现
网络的构成是“Convolution - ReLU - Pooling -Affine -ReLU - Affine - Softmax”,我们将它实现为名为 SimpleConvNet 的类。
首先来看一下 SimpleConvNet 的初始化(__init__),取下面这些参数。
参数
• input_dim―输入数据的维度:(通道,高,长)
• conv_param―卷积层的超参数(字典)。字典的关键字如下:
filter_num―滤波器的数量
filter_size―滤波器的大小
stride―步幅
pad―填充
• hidden_size―隐藏层(全连接)的神经元数量
• output_size―输出层(全连接)的神经元数量
• weitght_int_std―初始化时权重的标准差
这里,卷积层的超参数通过名为 conv_param 的字典传入。我们设想它会
像 {'filter_num':30,'filter_size':5, 'pad':0, 'stride':1} 这样,保存必要的超参数值。
SimpleConvNet 的初始化的实现稍长,我们分成 3 部分来说明,首先是初始化的最开始部分。
class SimpleConvNet:
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))
这里将由初始化参数传入的卷积层的超参数从字典中取了出来(以方便后面使用),然后,计算卷积层的输出大小。接下来是权重参数的初始化部分。
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 层的卷积层和剩余两个全连接层的权重和偏置。将这些参数保存在实例变量的 params 字典中。将第 1 层的卷积层的权重设为关键字 W1,偏置设为关键字 b1。同样,分别用关键字 W2、b2 和关键字 W3、b3来保存第 2 个和第 3 个全连接层的权重和偏置。
最后,生成必要的层。
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()
从最前面开始按顺序向有序字典(OrderedDict)的 layers 中添加层。只有最后SoftmaxWithLoss 层被添加到别的变量 lastLayer 中。以上就是 SimpleConvNet 的初始化中进行的处理。像这样初始化后,进行推理的 predict 方法和求损失函数值的 loss 方法就可以像下面这样实现。
def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x)
return x
def loss(self, x, t):
y = self.predict(x)
return self.lastLayer.forward(y, t)
这里,参数 x 是输入数据,t 是教师标签。用于推理的 predict 方法从头开始依次调用已添加的层,并将结果传递给下一层。在求损失函数的 loss方 法 中,除 了 使 用 predict 方 法 进 行 的 forward 处 理 之 外,还 会 继 续 进 行forward 处理,直到到达最后的 SoftmaxWithLoss 层。接下来是基于误差反向传播法求梯度的代码实现。
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'] = self.layers['Conv1'].dW
grads['b1'] = self.layers['Conv1'].db
grads['W2'] = self.layers['Affine1'].dW
grads['b2'] = self.layers['Affine1'].db
grads['W3'] = self.layers['Affine2'].dW
grads['b3'] = self.layers['Affine2'].db
return grads
参数的梯度通过误差反向传播法(反向传播)求出,通过把正向传播和反向传播组装在一起来完成。因为已经在各层正确实现了正向传播和反向传播的功能,所以这里只需要以合适的顺序调用即可。最后,把各个权重参数的梯度保存到 grads 字典中。这就是 SimpleConvNet 的实现。
卷积层和池化层是图像识别中必备的模块。CNN 可以有效读取图像中的某种特性,在手写数字识别中,还可以实现高精度的识别。
7.6 CNN 的可视化
7.6.1 第 1 层权重的可视化
对 MNIST 数据集进行了简单的 CNN 学习。当时,第 1 层的卷积层的权重的形状是 (30, 1, 5, 5),即 30 个大小为 5 × 5、通道为 1 的滤波器。滤波器大小是 5 × 5、通道数是 1,意味着滤波器可以可视化为 1 通道的灰度图像。现在,我们将卷积层(第 1 层)的滤波器显示为图像。
图 7-24 中,学习前的滤波器是随机进行初始化的,所以在黑白的浓淡上没有规律可循,但学习后的滤波器变成了有规律的图像。我们发现,通过学习,滤波器被更新成了有规律的滤波器,比如从白到黑渐变的滤波器、含有块状区域(称为 blob)的滤波器等。
图 7-25 中显示了选择两个学习完的滤波器对输入图像进行卷积处理时结果。我们发现“滤波器 1”对垂直方向上的边缘有响应,“滤波器 2”对水平方向上的边缘有响应。由此可知,卷积层的滤波器会提取边缘或斑块等原始信息。而刚才实现的 CNN 会将这些原始信息传递给后面的层。
7.7 具有代表性的 CNN
7.7.1 LeNet
LeNet 在 1998 年被提出,是进行手写数字识别的网络。如图 7-27 所示,它有连续的卷积层和池化层(正确地讲,是只“抽选元素”的子采样层),最后经全连接层输出结果。
和“现在的 CNN”相比,LeNet 有几个不同点。第一个不同点在于激活函数。LeNet 中使用 sigmoid 函数,而现在的 CNN 中主要使用 ReLU 函数。此外,原始的 LeNet 中使用子采(subsampling)缩小中间数据的大小,而现在的 CNN 中 Max 池化是主流。
7.7.2 AlexNet
AlexNet 叠有多个卷积层和池化层,最后经由全连接层输出结果。虽然结构上 AlexNet 和 LeNet 没有大的不同,但有以下几点差异。
• 激活函数使用 ReLU。
• 使用进行局部正规化的 LRN(Local Response Normalization)层。
• 使用 Dropout(6.4.3 节)。
如上所述,关于网络结构,LeNet 和 AlexNet 没有太大的不同。但是,围绕它们的环境和计算机技术有了很大的进步。具体地说,现在任何人都可以获得大量的数据。而且,擅长大规模并行计算的 GPU 得到普及,高速进行大量的运算已经成为可能。大数据和 GPU 已成为深度学习发展的巨大的原动力。
分割损失(Segmentation Loss)在GAN中的实现方式
1. 语义分割中的GAN损失实现
在语义分割任务中,分割网络作为生成器,其输出为语义标签的概率图,鉴别器则用于区分真实标签图和分割网络生成的预测概率图。
2、代码展示
鉴别器损失函数:
def discriminator_loss(self, real_output, fake_output):
# 真实样本的损失
real_loss = torch.mean((real_output - 1) ** 2)
# 伪造样本的损失
fake_loss = torch.mean(fake_output ** 2)
# 总损失
d_loss = real_loss + fake_loss
return d_loss
生成器损失函数:
def generator_loss(self, fake_output):
# 生成器希望最大化鉴别器对生成样本的评分
g_loss = torch.mean((fake_output - 1) ** 2)
return g_loss
分割网络训练:
# 假设seg_net是分割网络,disc_net是鉴别器网络
# real_labels是真实标签,fake_labels是分割网络生成的预测标签
# 计算分割网络的交叉熵损失
seg_loss = cross_entropy_loss(seg_net(input_images), real_labels)
# 计算对抗性损失
adv_loss = generator_loss(disc_net(fake_labels))
# 总损失
total_loss = seg_loss + lambda_adv * adv_loss
# 反向传播和优化
total_loss.backward()
optimizer.step()