本章目的:用图卷积神经网络实现离散数据的分类 ( 以图像分类为例 ) .
5.1 卷积计算过程
在实际项目中,输入神经网络的是具有更高分辨率的彩色图片,使得送入全连接网络的输入特征数过多,随着隐藏层层数的增加,网络规模过大,待优化参数过多,很容易使模型过拟合. 为了减少待训练参数,在实际应用时,会先对原始图片进行特征提取,把提取来的特征送入全连接网络,让其输出识别结果.
卷积计算是一种有效的特征提取方法,一般会用一个正方形的卷积核,按指定步长在输入特征图上滑动,遍历这种输入特征图中的每个像素点,每滑动一个步长,卷积核会与输入特征图部分像素点重合,重合区域对应元素相乘、求和再加上偏置项,得到输出特征的一个像素点.
如果输入特征是单通道灰度图,使用深度为 1 的单通道卷积核;如果输入特征是三通道彩色图,使用 3*3*3 的卷积核或 5*5*3 的卷积核. 总之,要使卷积核的通道数与输入特征图的通道数一致. 这是因为,如果想要让卷积核与输入特征图对应点匹配上,必须让卷积核的深度与输入特征图的深度一致. 所以,输入特征图的深度决定了当前层卷积核的深度.
由于每个卷积核在卷积计算后会得到一个输出特征图,所以当前层使用了几个卷积核,就会有几张输出特征图. 所以,当前层卷积核的个数,决定了当前层输出特征图的深度.
卷积核立体图如上图所示,每个小颗粒都存储了一个待训练参数,在执行卷积计算时,卷积核中的参数是固定的,在每次反向传播时,这些待训练参数会被梯度下降法更新. 卷积就是利用立体卷积核实现了参数的空间共享.
- 具体计算过程:
对于输入特征图是单通道的,选择单通道卷积核,上图为 5 行 5 列单通道,选用 3*3 单通道卷积核,滑动步长为 1 . 在输入特征图上滑动,每滑动一步输入特征图与卷积核里的 9 个元素重合,对应元素相乘求和再加上偏置项 b ,如卷积核滑动到图上位置时,计算为:(-1)*1+0*0+1*2+(-1)*5+0*4+1*2+(-1)*3+0*4+1*5+1 = 1.
对于输入特征图是三通道的,选择三通道卷积核,计算过程如上图.
5.2 感受野 ( Receptive Field )
感受野指输出特征图 ( feature map ) 中的一个像素点映射到原始输入图片的区域大小.
如上图所示,原始输入图片大小为 5 * 5 ,用黄色的 3 * 3 卷积核作用,则会输出一个 3 * 3 的输出特征图,输出特征图上每个像素点映射到原始输入图片是 3 * 3 的区域,因此,它的感受野是 3 ;如果再对这个 3 * 3 的特征图用绿色的 3 * 3 卷积核作用,会输出一个 1 * 1 的输出特征图,那么,这个输出特征图上的像素点映射到原始图片是 5 * 5 的区域,因此,它的感受野是 5 .
如上图所示,对原始图片直接用蓝色的 5 * 5 卷积核作用,则会输出一个 1 * 1 的输出特征图,它的感受野为 5 .
对于同样一个 5 * 5 的原始图片,经过两层 3 * 3 卷积核作用和经过一层 5 * 5 卷积核作用都可以得到一个感受野是 5 的 1 * 1 输出特征图,因此,两层 3 * 3 卷积核和一层 5 * 5 卷积核的特征提取能力是一样的,但如何做选择,就需要考虑他们所承载的待训练参数和计算量. 假设输入特征宽和高都为 x ,卷积计算步长均为 1 ,具体计算如下:
- 对于两层 3 * 3 卷积核:
参数量:9 + 9 = 18
计算量:每个 3 * 3 卷积核计算得到一个输出像素点需要做 9 次乘加计算,两层则需要:
- 对于一层 5 * 5 卷积核:
参数量:25
计算量:每个 5 * 5 卷积核计算得到一个输出像素点需要做 25 次乘加计算,一层则需要:
因此,可以比较出,当输入特征图边长大于 10 个像素点时,两层 3 * 3 卷积核要比一层 5 * 5 卷积核性能好.
5.3 全零填充 ( Padding )
当希望卷积计算保持输入特征图的尺寸不变,可以使用全零填充,在输入特征图周围填充 0 .
卷积输出特征图维度的计算公式为:
其中,入长为输入特征图边长.
# 在 tf 中, 描述全零填充
padding = 'SAME' 或 padding = 'VALID'
5.4 TF 描述卷积计算层
- Tensorflow 给出了计算卷积的函数:
tf.keras.layers.Conv2D(
filters = 卷积核个数,
kenerl_size = 卷积核尺寸, # 正方形写核长整数, 或 ( 核高 h ,核宽 w )
strides = 滑动步长, # 横纵向相同写步长整数或 ( 纵向步长 h, 横向步长 w ), 默认 1
padding = 'same' or 'valid' # 默认是 valid
activation = 'relu' or 'sigmoid' or 'tanh' of 'softmax' ... # 如有 BN ( batch normalization ) 此处不写
input_shape = ( 高, 宽, 通道数 ) # 输入特征图维度, 可省略
)
- 实例:描述三层卷积计算,每层用了一种表示形式
model = tf.keras.models.Sequential([
Conv2D(6, 5, padding='valid', activation='sigmoid'),
MaxPool2D(2, 2)
Conv2D(6, (5, 5), padding='valid', activation='sigmoid'),
MaxPool2D(2, (2, 2))
Conv2D(filters=6, kernel_size=(5, 5), padding='valid', activation='sigmoid'),
MaxPool2D(pool_size=(2, 2), strides=2),
Flatten(),
Dense(10, activation='softmax')
])
5.5 批标准化 ( Batch Normaliztion, BN )
- 神经网络对 0 附近的数字更敏感,但随着网络层数的增加,特征数据会出现偏离 0 均值的情况,标准化可以使数据符号以 0 为均值,1 为标准差的标准正态分布,把偏移的特征数据,重新拉回到 0 附近;批标准化是对一个 batch 的数据做标准化处理,使数据回归标准正态分布,常用在卷积操作和激活操作之间. 批标准化操作会让每个像素点进行减均值除以标准差的自更新计算,可利用公式 ( 1 ) 计算批标准化后的输出特征图.
( 1 )
其中:
表示批标准化后,第 k 个卷积核,输出特征图中的第 i 个像素点;
表示批标准化前,第 k 个卷积核,输出特征图中第 i 个像素点;
表示批标准化前,第 k 个卷积核,batch 张输出特征图中所有像素点平均值;
表示批标准化前,第 k 个卷积核,batch 张输出特征图中所有像素点标准差.
如上面两张图所示,激活函数为 sigmoid ,左图为偏移的特征数据分布,经过公式 ( 1 ) 操作,将原本偏移的特征数据,重新拉回到 0 均值,如右图所示,使进入激活函数的数据分布在激活函数线性区,使得输入数据的微小变化,更明显地体现到激活函数的输出,提升了激活函数对输入数据的区分力.
- 但这种简单的特征数据标准化使特征数据完全满足标准正态分布,集中在激活函数中心的线性区域,使激活函数丧失了非线性特性,因此,在 BN 操作中,为每个卷积核引入了两个可训练参数缩放因子 和偏移因子 . 反向传播时,缩放因子和偏移因子会与其他待训练参数一同被训练优化,使标准正态分布后的特征数据,通过缩放因子和偏移因子优化了特征数据分布的宽窄和偏移量,保证了网络的非线性表达力. 特征数据变为 .
- BN 层位于卷积层之后,激活层之前,Tensorflow 提供了 BN 操作的函数 BatchNormalization
tf.keras.layers.BatchNormalization()
model = tf.keras.models.Sequential([
Conv2D(filters=6, kernel_size=(5, 5), padding='same'), # 卷积层
BatchNormalization(), # BN 层
Activation('relu'), # 激活层
MaxPool2D(pool_size=(2, 2), strides=2, padding='same'), # 池化层
Dropout(0.2), # dropout 层
])
5.6 池化 ( Pooling )
- 池化操作用于减少卷积神经网络中特征数据量,池化的主要方法有:
( 1 ) 最大池化:可以提取图片纹理,选择最大像素点输出.
( 2 ) 均值池化:可以保留背景特征,计算平均像素点输出.
举例说明,如下图所示,如果用 2 * 2 的池化核对输入图片以 2 为步长进行池化,输出图片将变为输入图片的四分之一大小.
- Tensorflow 描述池化:
# 最大池化
tf.keras.layers.MaxPool2D(
pool_size = 池化核尺寸, # 正方形写核长整数, 或 ( 核高 h, 核宽 w )
strides = 池化步长, # 步长整数, 或 ( 纵向步长 h, 横向步长 w ), 默认是 pool_size
padding = 'valid' or 'same' # 使用或不使用全 0 填充, 默认是 valid
)
# 平均池化
tf.keras.layers.AveragePooling2D(
pool_size = 池化核尺寸, # 正方形写核长整数, 或 ( 核高 h, 核宽 w )
strides = 池化步长, # 步长整数, 或 ( 纵向步长 h, 横向步长 w ), 默认是 pool_size
padding = 'valid' or 'same' # 使用或不使用全 0 填充, 默认是 valid
)
model = tf.keras.models.Sequential([
Conv2D(filters=6, kernel_size=(5, 5), padding='same'), # 卷积层
BatchNormalization(), # BN 层
Activation('relu'), # 激活层
MaxPool2D(pool_size=(2, 2), strides=2, padding='same'), # 池化层
Dropout(0.2) # dropout 层
])
5.7 舍弃 ( Dropout )
- 为了缓解神经网络过拟合,在神经网络训练过程中,常把隐藏层的部分神经元按照一定比例从神经网络中临时舍弃,在使用神经网络时,再把所有神经元恢复到神经网络中.
- TF 描述 Dropout :
tf.keras.layers.Dropout( 舍弃的概率 )
model = tf.keras.models.Sequential([
Conv2D(filters=6, kernel_size=(5, 5), padding='same'), # 卷积层
BatchNormalization(), # BN 层
Activation('relu'), # 激活层
MaxPool2D(pool_size=(2, 2), strides=2, padding='same'), # 池化层
Dropout(0.2) # Dropout 层, 随机舍弃 20% 的神经元
])
5.8 卷积神经网络
- 卷积神经网络是借助卷积核对输入特征进行特征提取,再把提取特征送入全连接网络进行识别预测.
- 提取特征包括卷积、批标准化、激活、池化四步.
- 卷积即特征提取器. 就是 CBAPD ,C-Conv2D,B-BatchNormalization,A-Activation,M-Max/Meanpool2D,D-Dropout .
5.9 CIFAR10 数据集
- CIFAR datasets:
( 1 ) 提供 5 万张 32 * 32 像素点的红蓝绿三通道的十分类彩色图片和标签,用于训练;
( 2 ) 提供 1 万张 32 * 32 像素点的红绿蓝三通道的十分类彩色图片和标签,用于测试.
- 导入 CIFAR datasets:
cifar10 = tf.keras.datasets.cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
5.10 卷积神经网络搭建示例
- 用 CIFAR10 datasets 搭建一层卷积,两层全连接网络,如下图所示.
- 搭建经典卷积神经网络结构
# 用 class 类搭建网络结构
class Baseline(Model):
def __init__(self):
# 在 __init__ 函数中,准备出搭建神经网络要用到的每一层结构
super(Baseline, self).__init__()
self.c1 = Conv2D(filters=6, kernel_size=(5, 5), padding='same')
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
self.p1 = MaxPool2D(pool_size=(2, 2), strides=2, padding='same')
self.d1 = Dropout(0.2)
self.flatten = Flatten()
self.f1 = Dense(128, activation='relu')
self.d2 = Dropout(0.2)
self.f2 = Dense(10, activation='softmax')
def call(self, x):
# 在 call 函数中,调用 __init__ 函数搭建好的每层网络结构,从输入到输出经过一次前向传播,返回推理结果 y
x = self.c1(x)
x = self.b1(x)
x = self.a1(x)
x = self.p1(x)
x = self.d1(x)
x = self.flatten(x)
x = self.f1(x)
x = self.d2(x)
y = self.f2(x)
return y
model = Baseline()
- 搭建 LeNet 卷积神经网络
LeNet 卷积神经网络是 LeCun 于 1998 年提出,是卷积神经网络的开篇之作. 通过共享卷积核减少了网络的参数. LeNet 一共有五层网络,两层卷积层,三层全连接层.
class 类定义 LeNet 卷积网络
class LeNet5(Model):
def __init__(self):
super(LeNet5, self).__init__()
self.c1 = Conv2D(filters=6, kernel_size=(5, 5), activation='sigmoid') # 卷积层 C1
self.p1 = MaxPool2D(pool_size=(2, 2), strides=2)
self.c2 = Conv2D(filters=16, kernel_size=(5, 5), activation='sigmoid') # 卷积层 C3
self.p2 = MaxPool2D(pool_size=(2, 2), strides=2)
self.flatten = Flatten()
self.f1 = Dense(120, activation='sigmoid') # 全连接层 F5
self.f2 = Dense(84, activation='sigmoid') # 全连接层 F6
self.f3 = Dense(10, activation='softmax') # 全连接层 Output
def call(self, x):
x = self.c1(x)
x = self.p1(x)
x = self.c2(x)
x = self.p2(x)
x = self.flatten(x)
x = self.f1(x)
x = self.f2(x)
y = self.f3(x)
return y
model = LeNet5()
- 搭建 AlexNet 卷积神经网络
AlexNet 卷积神经网络是 Hinton 于 2012 年提出,使用 relu 激活函数,提升了训练速度,使用 Dropout 缓解过拟合. AlexNet 共有八层网络过,五个卷积层和三个全连接层.
class AlexNet8(Model):
def __init__(self):
super(AlexNet8, self).__init__()
self.c1 = Conv2D(filters=96, kernel_size=(3, 3))
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
self.p1 = MaxPool2D(pool_size=(3, 3), strides=2)
self.c2 = Conv2D(filters=256, kernel_size=(3, 3))
self.b2 = BatchNormlization()
self.a2 = Activation('relu')
self.p2 = MaxPool2D(pool_size=(3, 3), strides=2)
self.c3 = Conv2D(filters=384, kernel_size=(3, 3), padding='same', activation='relu')
self.c4 = Conv2D(filters=384, kernel_size=(3, 3), padding='same', activation='relu')
self.c5 = Conv2D(filters=256, kernel_size=(3, 3), padding='same', activation='relu')
self.flatten = Flatten()
self.f1 = Dense(2048, activation='relu')
self.d1 = Dropout(0.5)
self.f2 = Dense(2048, activation='relu')
self.d2 = Dropout(0.5)
self.f3 = Dense(10, activation='softmax')
- 搭建 VGGNet 卷积神经网络
VGGNet 使用小尺寸卷积核,在减少参数的同时,提高了准确率. VGGNet 的网络结构规整,适合硬件加速 .
网络结构框图如下:卷积核个数从 64 到 128 到 256 再到 512,逐渐增加,因为越靠后,特征图尺寸越小,通过增加卷积核的个数,增加了特征图的深度,保持了信息的承载能力.
- 搭建 InceptionNet 卷积神经网络
InceptionNet 引入了 Inception 结构块,在同一层网络内,使用不同尺寸的卷积核,可以提取不同尺寸的特征,提升了模型感知力;使用了批标准化,缓解了梯度消失. GoogleNet 即 Inception v1,以及 InceptionNet 的后续版本 v2、v3、v4 等,都是基于 Inception 结构块搭建的网络.
通过 1*1 卷积核,作用到输入特征图的每个像素点,通过设定少于输入特征图深度的 1*1 卷积核个数,减少了输出特征图深度,起到了降维的作用,减少了参数量和计算量.
下图为 Inception 结构块,Inception 结构块包含四个分支. 送入到卷积连接器 ( Filter Concatenation ) 的特征数据尺寸相同,卷积连接器会把收到的四路特征数据按深度方向拼接,形成 Inception 结构块的输出.
由于 Inception 结构块中的卷积均采用了 CBA 结构,所以将其定义为一个新的类 ConvBNRelu,可以减少代码长度,增加可读性.
class ConvBNRelu(Model):
def __init__(self, ch, kernelsz=3, strides=1, padding='same'):
super(ConvBNRelu, self).__init__()
self.model = tf.keras.models.Sequential([
Conv2D(ch, kernelsz, strides=strides, padding=padding),
BatchNormalization(),
Activation('relu')
])
def call(self, x):
x = self.model(x)
return x
Inception 结构块的实现:
class InceptionB1k(Model):
def __init__(self, ch, strides=1):
super(InceptionB1k, self).__init__()
self.ch = ch
self.strides = strides
self.c1 = ConvBNRelu(ch, kernelsz=1, strides=strides)
self.c2_1 = ConvBNRelu(ch, kernelsz=1, strides=strides)
self.c2_2 = ConvBNRelu(ch, kernelsz=3, strides=strides)
self.c3_1 = ConvBNRelu(ch, kernelsz=1, strides=strides)
self.c3_2 = ConvBNRelu(ch, kernelsz=5, strides=strides)
self.p4_1 = MaxPool2D(3, strides=1, padding='same')
self.c4_2 = ConvBNRelu(ch, kernelsz=1, strides=strides)
def call(self, x):
x1 = self.c1(x)
x2_1 = self.c2_1(x)
x2_2 = self.c2_2(x2_1)
x3_1 = self.c3_1(x)
x3_2 = self.c3_2(x3_1)
x4_1 = self.p4_1(x)
x4_2 = self.c4_2(x4_1)
# concat along axis = channel
# axis = 3 指定堆叠的维度是沿深度方向
x = tf.concat([x1, x2_2, x3_2, x4_2], axis=3)
return x
有了 Inception 结构块之后,就可以搭建出精简版本的 InceptionNet 了,网络共有十层,如下图所示:
代码实现为:
class Inception10(Model):
def __init__(self, num_blocks, num_classes, init_ch=16, **kwargs):
super(Inception10, self).__init__()
self.in_channels = init_ch
self.out_channels = init_ch
self.num_blocks = num_blocks
self.init_ch = init_ch
self.c1 = ConvBNRelu(init_ch)
self.blocks = tf.keras.models.Sequential()
for block_id in range(num_blocks):
# 两个 Inception 结构块为一个 block
for layer_id in range(2):
if layer_id == 0:
block = InceptionB1k(self.out_channels, strides=2)
# block 中第一个 Inception 结构块卷积步长为 2
# 使得输出特征图尺寸减半
else:
block = InceptionB1k(self.out_channels, strides=1)
# block 中第二个 Inception 结构块卷积步长为 1
self.blocks.add(block)
# enlarger out_channel per block
self.out_channels *= 2
# 由于输出特征图尺寸减半,因此把深度加深,尽可能保证特征抽取中信息的承载量一致
self.p1 = GlobalAveragePooling2D()
self.f1 = Dense(num_classes, activation='softmax')
def call(self, x):
x = self.c1(x)
x = self.blocks(x)
x = self.p1(x)
y = self.f1(x)
return y
model = Inception10(num_blocks=2, num_classes=10)
# num_classes 指定网络是几分类
- 搭建 ResNet 卷积神经网络
ResNet 提出了层间残差跳连,引入了前方信息,缓解梯度消失,使神经网络层数增加成为可能. 在探索卷积实现特征提取时,通过加深网络层数取得了越来越好的效果,但 ResNet 的作者何恺明在 CIFAR10 datasets 上通过做实验发现,一味地堆叠神经网络层数会使神经网络模型退化,以至于后边的特征丢失了前边特征的原本模样. 于是,何恺明用了一根跳连线,将前边的特征直接接到了后边,使输出结果 H ( x ) 包含了堆叠卷积的非线性输出 F ( x ) 和跳过这两层堆叠卷积直接连接过来的恒等映射 X ,让它们对应元素相加,如下图所示.
# 注:Inception 块中的 “+” 是沿深度方向叠加 ( 类似千层蛋糕层数叠加 ),ResNet 块中的 “+” 是特征图对应元素值相加 ( 类似两个矩阵对应元素值相加 ).
这一操作有效缓解了神经网络模型堆叠导致的退化,使得神经网络可以向更深层级发展.
如上图所示,ResNet 块中有两种情况,一种情况用图中的实线表示,这种情况,两层堆叠卷积没有改变特征图的维度,即特征图的个数、高、宽以及深度都相同,可以直接将 F(x) 与 X 相加;另一种情况用图中的虚线表示,两层堆叠卷积改变了特征图的维度,需要借助 1*1 的卷积来调整 x 的维度,使 W(x) 与 F(x) 的维度一致.
ResNet 块有两种形式,一种在堆叠卷积前后维度相同 ( 实线 ) ,另一种在堆叠卷积前后不同 ( 虚线 ),将两种结构封装为一个块,定义 ResnetBlock 类,每调用一次 ResnetBlock 类,生成ResNet 块中的一种形式.
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
self.c1 = Conv2D(filters, (3, 3), strides=strides, padding='same', use_bias=False)
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
self.c2 = Conv2D(filters, (3, 3), strides=1, padding='same', use_bias=False)
self.b2 = BatchNormalization()
# residual_path 为 True 时,对输入进行下采样,利用 1*1 的卷积核做卷积操作,保证 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:
# 当生成前后维度不同的 ResNet 块时,residual_path = True
residual = self.down_c1(input)
residual = self.down_b1(residual)
out = self.a2(y + residual) # 最后输出的是两部分的和,即 F(x)+x 或 F(x)+W(x),再过激活函数
return out
使用上述给出的 ResNet 块搭建 ResNet18 网络结构. ResNet18 由一层卷积、8 个 ResNet 块 ( 每块有两层卷积 )、一层全连接,共 18 层网络组成.
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
# ResNet18 第一层是一个卷积层
self.c1 = Conv2D(self.out_filters, (3, 3), strides=1, padding='same', use_bias=False)
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
self.blocks = tf.keras.models.Sequential()
# 构建ResNet网络结构
# 然后是 4 组 ResNet 块,每组生成两块 ResNet ,每一个 ResNet 块有两层卷积
# 用 for 循环构建 8 个 ResNet 块,循环次数由参数列表元素个数决定
for block_id in range(len(block_list)): # 第几个resnet block
for layer_id in range(block_list[block_id]): # 第几个卷积层
if block_id != 0 and layer_id == 0: # 对除第一个block以外的每个block的输入进行下采样
block = ResnetBlock(self.out_filters, strides=2, residual_path=True)
else:
block = ResnetBlock(self.out_filters, residual_path=False)
self.blocks.add(block) # 将构建好的block加入resnet
self.out_filters *= 2 # 下一个block的卷积核数是上一个block的2倍
# 平均全局池化
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
# 这里列表赋值是 2,2,2,2 四个元素,所以最外层 for 循环执行 4 次
model = ResNet18([2, 2, 2, 2])