本文涉及到的是中国大学慕课《人工智能实践:Tensorflow笔记》第五讲第15节的内容,对tensorflow环境下经典卷积神经网络的搭建进行介绍,其基础是DL with python(14)——tensorflow实现CNN的“八股”中的代码,将其中第三步的代码替换为本文中的代码均可直接运行,其他部分无需改变。
经典的卷积神经网络有以下几种,这里介绍结构较为复杂的ResNet,其实现的方法也相对困难。
ResNet
ResNet即深度残差网络,由何恺明及其团队于2015年提出,是当年的ImageNet竞赛冠军,Top5错误率为3.57%。ResNet 是深度学习领域又一具有开创性的工作,通过对残差结构的运用,ResNet 使得训练数百层的网络成为了可能,从而具有非常强大的表征能力。
Kaiming He, Xiangyu Zhang, Shaoqing Ren. Deep Residual Learning for Image Recognition. In CPVR,2016.
主要特点:层间残差跳连,引入前方信息,减少梯度消失,使神经网络层数变深成为可能。
残差结构
ResNet 的核心是残差结构,如下图所示。在残差结构中,ResNet 不再让下一层直接拟合我们想得到的底层映射,而是令其对一种残差映射进行拟合。若期望得到的底层映射为H(x),我们令堆叠的非线性层拟合另一个映射 F(x) := H(x) – x,则原有映射变为 F(x) + x。对这种新的残差映射进行优化时,要比优化原有的非相关映射更为容易。不妨考虑极限情况,如果一个恒等映射是最优的,那么将残差向零逼近显然会比利用大量非线性层直接进行拟合更容易。
值得一提的是,这里的相加与 InceptionNet 中的相加是有本质区别的,Inception 中的相加是沿深度方向叠加,像“千层蛋糕”一样,对层数进行叠加;ResNet 中的相加则是特征图对应元素的数值相加,类似于 python 语法中基本的矩阵相加。
深度网络的问题
ResNet 引入残差结构最主要的目的是解决网络层数不断加深时导致的梯度消失问题,从之前介绍的 4 种 CNN 经典网络结构可以看出(详情见笔者的往期博客),网络层数的发展趋势是不断加深的。这是由于深度网络本身集成了低层/中层/高层特征和分类器,以多层首尾相连的方式存在,所以可以通过增加堆叠的层数(深度)来丰富特征的层次,以取得更好的效果。
但如果只是简单地堆叠更多层数,就会导致梯度消失(爆炸)问题,它从根源上导致了函数无法收敛。然而,通过标准初始化(normalized initialization)以及中间标准化层(intermediate normalization layer),已经可以较好地解决这个问题了,这使得深度为数十层的网络在反向传播过程中,可以通过随机梯度下降(SGD)的方式开始收敛。
但是,当深度更深的网络也可以开始收敛时,网络退化的问题就显露了出来:随着网络深度的增加,准确率先是达到瓶颈(这是很常见的),然后便开始迅速下降。需要注意的是,这种退化并不是由过拟合引起的。对于一个深度比较合适的网络来说,继续增加层数反而会导致训练错误率的提升,下图是何恺明在其论文中给出的一个例子。对于相同的数据,56层网络和20层网络的错误率对比。
残差结构的代码实现
ResNet 解决的正是上述问题,其核心思路为:对一个准确率达到饱和的浅层网络,在它后面加几个恒等映射层(即 y = x,输出等于输入),增加网络深度的同时不增加误差。这使得神经网络的层数可以超越之前的约束,提高准确率。下图展示了 ResNet 中残差结构的具体用法。
上图中的实线和虚线均表示恒等映射,实线表示通道相同,计算方式为 H(x) = F(x) + x;虚线表示通道不同,计算方式为 H(x) = F(x) + Wx,其中 W 为卷积操作,目的是调整 x 的维度(通道数)。1*1卷积操作可通过步长改变特征图尺寸,通过卷积核个数改变特征图深度。
我们同样可以借助 tf.keras 来实现这种残差结构,定义一个新的 ResnetBlock 类,调用一次就生成一个残差结构。残差结构的代码中,卷积操作仍然采用典型的 C、B、A 结构,激活采用 Relu 函数;为了保证 F(x)和 x 可以顺利相加,二者的维度必须相同,这里利用的是 1 * 1 卷积来实现(1 * 1 卷积具有改变输出维度的作用)。
## 残差结构的类,每次调用生成一个残差结构,含有2个CBA组合
# 可以实现两种连接方式,通过residual_path确定2个CBA之间是否经过1*1卷积
class ResnetBlock(Model):
def __init__(self, filters, strides=1, residual_path=False): # 设置默认参数,卷积核,步长,虚线或实线
super(ResnetBlock, self).__init__()
self.filters = filters # 卷积核大小
self.strides = strides # 卷积步长
self.residual_path = residual_path # 是否进行1*1操作
# 残差结构的第一步卷积,其中卷积操作的步长由参数决定
self.c1 = Conv2D(filters, (3, 3), strides=strides, padding='same', use_bias=False)
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
# 残差结构的第二步卷积,其中卷积操作的步长为1,全0填充,输入输出尺寸一致
self.c2 = Conv2D(filters, (3, 3), strides=1, padding='same', use_bias=False)
self.b2 = BatchNormalization()
# residual_path为True时,对输入进行下采样,即用1x1的卷积核做卷积操作,保证x能和F(x)维度相同,顺利相加
if residual_path:
self.down_c1 = Conv2D(filters, (1, 1), strides=strides, padding='same', use_bias=False)
self.down_b1 = BatchNormalization()
self.a2 = Activation('relu')
def call(self, inputs):
residual = inputs # residual等于输入值本身,即residual=x
# 将输入通过卷积、BN层、激活层,计算F(x)
x = self.c1(inputs)
x = self.b1(x)
x = self.a1(x)
x = self.c2(x)
y = self.b2(x)
if self.residual_path: # 判断是否将residual=x替换为residual=Wx
residual = self.down_c1(inputs)
residual = self.down_b1(residual)
out = self.a2(y + residual) # 最后输出的是两部分的和,即F(x)+x或F(x)+Wx,再过激活函数
return out
ResNet的代码实现
利用上面的类,就可以构建出 ResNet 模型,如下图所示。18层ResNet,包括1个卷积层+8个残差结构(16个卷积层)+1个全连接层。
实现代码如下,具体内容看注释,这种网络深层并且实现较为方便。
class ResNet18(Model):
def __init__(self, block_list, initial_filters=64): # block_list表示每个block有几个餐叉结构
super(ResNet18, self).__init__()
self.num_blocks = len(block_list) # 共有几个block
self.block_list = block_list
self.out_filters = initial_filters # 卷积核个数
# 第一个CPA结构
self.c1 = Conv2D(self.out_filters, (3, 3), strides=1, padding='same', use_bias=False)
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
# 中间的几个block,1个block含有2个残差结构,即4个CBA,且4个卷积层的卷积核个数相同(弄清楚这一行的内容很重要)
self.blocks = tf.keras.models.Sequential()
# 构建ResNet网络结构
for block_id in range(len(block_list)): # 第几个block
for layer_id in range(block_list[block_id]): # 第几个残差结构
if block_id != 0 and layer_id == 0: # 除第一个block的第一个残差结构外
block = ResnetBlock(self.out_filters, strides=2, residual_path=True) # 其余3个block的第1个残差结构都用虚线连接
else:
block = ResnetBlock(self.out_filters, residual_path=False) # 其它的残差结构都用实线连接
self.blocks.add(block) # 将构建好的block加入resnet
self.out_filters *= 2 # 下一个block的卷积核数是上一个block的2倍
# 最后的池化层和全连接层(算1层)
self.p1 = tf.keras.layers.GlobalAveragePooling2D()
self.f1 = tf.keras.layers.Dense(10, activation='softmax', kernel_regularizer=tf.keras.regularizers.l2())
def call(self, inputs):
x = self.c1(inputs)
x = self.b1(x)
x = self.a1(x)
x = self.blocks(x)
x = self.p1(x)
y = self.f1(x)
return y
model = ResNet18([2, 2, 2, 2]) # 4个block,各有2个残差结构