一、绪论
卷积神经网络(Convolutional Neural Network,简称CNN)是一种深度学习模型,特别适用于处理具有网格结构的数据,如图像和视频(视频一般也是先抽帧为图片)。CNN在计算机视觉领域取得了显著的成功,广泛应用于图像分类、目标检测、图像分割等任务。
1、CNN的基本结构
CNN通常由以下几个核心组件组成:
-
输入层:输入层接收原始数据,通常是图像数据。图像数据通常表示为三维张量(高度、宽度、通道数),例如RGB图像的通道数为3。
-
卷积层(Convolutional Layer):卷积层是CNN的核心部分。它通过卷积操作提取输入数据的局部特征。卷积操作使用一组可学习的卷积核,每个卷积核在输入数据上滑动,计算局部区域的加权和,生成特征图(Feature Map)。卷积层的主要作用是捕捉输入数据的局部模式和结构。
-
激活函数(Activation Function):在卷积层之后,通常会应用一个非线性激活函数(如ReLU),以引入非线性特性,增强模型的表达能力。
-
池化层(Pooling Layer):池化层用于降低特征图的空间维度,减少计算量,并增强模型的平移不变性。常见的池化操作包括最大池化(Max Pooling)和平均池化(Average Pooling)。
-
全连接层(Fully Connected Layer):在经过多个卷积层和池化层之后,特征图被展平为一维向量,并通过全连接层进行分类或回归。全连接层将特征图中的每个元素与输出层的每个神经元连接起来,进行最终的决策。
-
输出层:输出层根据任务的不同,可以采用不同的形式。例如,在图像分类任务中,输出层通常是一个softmax层,用于生成类别的概率分布。
2、CNN的优点
- 局部连接:卷积层通过局部感受野(Receptive Field)捕捉输入数据的局部特征,减少了参数数量,提高了模型的效率。
- 权值共享:卷积层中的滤波器在整个输入数据上共享,进一步减少了参数数量,增强了模型的泛化能力。
- 平移不变性:通过池化层和卷积操作,CNN能够对输入数据的平移具有一定的不变性,提高了模型的鲁棒性。
3、主要概念解析
(1)卷积convolution
卷积操作是卷积神经网络最核心的部分。其大致过程就是一个卷积核(上图灰色部分)与输入图像各个区域做矩阵的点积操作,一次卷积操作最终得到一个值。从当前卷积的位置移动到下一位置的距离就是步长,每次移动一格,步长就是1。而模型训练的过程就是卷积核参数(a11-a33)更新的过程。卷积的本质就是将尺度特别大的一张图片映射到一个数值或一个概率分布,对应回归或分类问题。其实也就是一种特征提取的方法。
卷积网络之所以诞生并在图像领域大放异彩,主要就在于图像数据的特点。相比于其他数据,如时序数据,图像数据的维度特别大,如1024*1024。直接将这么大维度的数据扔进网络,必然要求巨大的网络参数量。而通过卷积就可以逐步将一个区域变成一个特征值。
(2)池化pooling
同时,图像数据具有较强的区域性,即相邻像素的值基本一致。那么我们完全可以用一个值代表一个小区域,比如平均值。这也是你发朋友圈时,照片被压缩你也不知道的原因。池化层没有需要训练的参数,池化操作一般有平均池化、最大值池化,本质就是压缩图片的操作。
(3)填充padding
通过在输入数据的边缘添加适当数量的零(或其他值),使得卷积操作后的特征图尺寸与输入数据的尺寸相同。另外,在卷积操作中,边缘像素参与卷积的次数通常少于中心像素,这可能导致边缘信息在特征提取过程中丢失。通过填充,可以确保边缘像素也得到充分的处理,从而避免信息丢失。下图最外面一圈0就是填充的。
(4)通道channel
我们一般见到的图片都是3通道的,即RGB格式,相当于3张照片合到一起才是我们最终看到的样子。当然,如果是灰度图片,输入通道就只有一个。在做卷积操作时,可以增加输出的通道数也可以降低,主要取决于卷积核的数量。
二、基本卷积网络及其改进
1、基本卷积
最基础的卷积就是卷积+池化的堆叠,通过加深网络层数,使网络能够学习到更多信息。后续测试数据均为minst手写数字图像。
# 加载数据
import gzip, os
import numpy as np
# 自行下载
file_dir = '/data/mnist'
files = [
'train-labels-idx1-ubyte.gz', 'train-images-idx3-ubyte.gz',
't10k-labels-idx1-ubyte.gz', 't10k-images-idx3-ubyte.gz'
]
paths = [os.path.join(file_dir, file) for file in files]
def get_minst_data():
with gzip.open(paths[0], 'rb') as lbpath:
y_train = np.frombuffer(lbpath.read(), np.uint8, offset=8)
with gzip.open(paths[1], 'rb') as imgpath:
x_train = np.frombuffer(
imgpath.read(), np.uint8, offset=16).reshape(len(y_train), 28, 28)
with gzip.open(paths[2], 'rb') as lbpath:
y_test = np.frombuffer(lbpath.read(), np.uint8, offset=8)
with gzip.open(paths[3], 'rb') as imgpath:
x_test = np.frombuffer(
imgpath.read(), np.uint8, offset=16).reshape(len(y_test), 28, 28)
return (x_train, y_train), (x_test, y_test)
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import os
from tensorflow import keras
from sklearn.preprocessing import StandardScaler
from tool import get_minst_data
# 构建数据集,minst数据集,手写数字
(x_train_all, y_train_all), (x_test, y_test) = get_minst_data()
x_valid, x_train = x_train_all[:5000], x_train_all[5000:]
y_valid, y_train = y_train_all[:5000], y_train_all[5000:]
# 归一化
scaler = StandardScaler()
x_train_scaled = scaler.fit_transform(
x_train.astype(np.float32).reshape(-1, 1)).reshape(-1, 28, 28, 1)
x_valid_scaled = scaler.transform(
x_valid.astype(np.float32).reshape(-1, 1)).reshape(-1, 28, 28, 1)
x_test_scaled = scaler.transform(
x_test.astype(np.float32).reshape(-1, 1)).reshape(-1, 28, 28, 1)
# 构建模型
model = keras.models.Sequential()
# filter是通道数, padding='same'表示输入输出尺寸不变,卷积核3*3
model.add(keras.layers.Conv2D(filters=32, kernel_size=3,
padding='same',
activation='relu', # selu 带归一化的激活函数
input_shape=(28, 28, 1))) # 第一层网络需要指定输入数据的尺寸
model.add(keras.layers.Conv2D(filters=32, kernel_size=3,
padding='same',
activation='relu'))
# 不指定步长时,默认等于pool_size,这里等于2
model.add(keras.layers.MaxPool2D(pool_size=2))
# 经过此池化操作,维度减半,数据丢失,增加通道数抵消此影响
model.add(keras.layers.Conv2D(filters=64, kernel_size=3,
padding='same',
activation='relu'))
model.add(keras.layers.Conv2D(filters=64, kernel_size=3,
padding='same',
activation='relu'))
model.add(keras.layers.MaxPool2D(pool_size=2))
model.add(keras.layers.Conv2D(filters=128, kernel_size=3,
padding='same',
activation='relu'))
model.add(keras.layers.Conv2D(filters=128, kernel_size=3,
padding='same',
activation='relu'))
model.add(keras.layers.MaxPool2D(pool_size=2))
model.add(keras.layers.Flatten())
model.add(keras.layers.Dense(128, activation='relu'))
# 手写数字0-9,类别数是10,输出维度也是10
model.add(keras.layers.Dense(10, activation="softmax"))
model.compile(loss="sparse_categorical_crossentropy",
optimizer="sgd",
metrics=["accuracy"])
# 模型的概览
# print(model.summary())
# 训练模型
log_dir = './cnn-relu-callbacks'
if not os.path.exists(log_dir):
os.mkdir(log_dir)
output_model_file = os.path.join(log_dir, "normal_cnn_mnist_model.h5")
callbacks = [
keras.callbacks.TensorBoard(log_dir),
keras.callbacks.ModelCheckpoint(output_model_file,
save_best_only=True),
keras.callbacks.EarlyStopping(patience=5, min_delta=1e-3),
]
history = model.fit(x_train_scaled, y_train, epochs=10,
validation_data=(x_valid_scaled, y_valid),
callbacks=callbacks)
# 绘制学习曲线
def plot_learning_curves(his):
pd.DataFrame(his.history).plot(figsize=(8, 5))
plt.grid(True)
plt.gca().set_ylim(0, 1)
plt.show()
plot_learning_curves(history)
# 验证
model = keras.models.load_model(os.path.join(log_dir, "normal_cnn_mnist_model.h5"))
print(model.evaluate(x_test_scaled, y_test, verbose=0))
学习曲线如下:
测试集上的loss和acc为0.2803462743759155, 0.8992999792098999。
2、inception-net
Inception-Net(也称为GoogLeNet)主要解决了深度卷积神经网络中的计算效率问题。传统的卷积神经网络在增加深度和宽度的同时,计算复杂度急剧增加,导致训练和推理速度变慢。通过在同一层中使用不同大小的卷积核(1x1、3x3、5x5)和池化操作,实现了多尺度的特征提取,从而提高了模型的表达能力。
class InceptionBlock(Layer):
def __init__(self, filters, **kwargs):
super(InceptionBlock, self).__init__(**kwargs)
self.filters = filters
# 1x1卷积分支
self.conv_1x1 = Conv2D(filters[0], (1, 1), padding='same', activation='relu')
# 3x3卷积分支
self.conv_3x3_reduce = Conv2D(filters[1], (1, 1), padding='same', activation='relu')
self.conv_3x3 = Conv2D(filters[2], (3, 3), padding='same', activation='relu')
# 5x5卷积分支
self.conv_5x5_reduce = Conv2D(filters[3], (1, 1), padding='same', activation='relu')
self.conv_5x5 = Conv2D(filters[4], (5, 5), padding='same', activation='relu')
# 池化分支
self.pool = MaxPooling2D((3, 3), strides=(1, 1), padding='same')
self.pool_proj = Conv2D(filters[5], (1, 1), padding='same', activation='relu')
def call(self, inputs):
conv_1x1 = self.conv_1x1(inputs)
conv_3x3_reduce = self.conv_3x3_reduce(inputs)
conv_3x3 = self.conv_3x3(conv_3x3_reduce)
conv_5x5_reduce = self.conv_5x5_reduce(inputs)
conv_5x5 = self.conv_5x5(conv_5x5_reduce)
pool = self.pool(inputs)
pool_proj = self.pool_proj(pool)
# 拼接所有分支的输出
output = concatenate([conv_1x1, conv_3x3, conv_5x5, pool_proj], axis=-1)
return output
def get_config(self):
config = super(InceptionBlock, self).get_config()
config.update({
'filters': self.filters,
})
return config
在tensorflow2里,定义自己的网络,需要继承keras.layers.Layer,至少实现初始化和call方法。然后就可以像其他预定义的层一样添加到网络里。注意自定义的网络在加载模型时,需要注册。
custom_objects = {'InceptionBlock': InceptionBlock}
model = keras.models.load_model(os.path.join(log_dir, "inception-net_mnist_model.h5"), custom_objects=custom_objects)
print(model.evaluate(x_test_scaled, y_test, verbose=0))
测试集上的loss和acc分别是0.2759621739387512, 0.901199996471405。可以看到效果有一定提升(要想严格对比,需要保证参数量基本一致)。
3、mobile-net
MobileNet主要解决了在移动设备和嵌入式系统上部署深度学习模型的计算资源限制问题。引入了深度可分离卷积(Depthwise Separable Convolution),将标准的卷积操作分解为深度卷积和逐点卷积(1*1卷积核)两个步骤,大大减少了计算量和参数数量。
传统的卷积操作,是每一个卷积核都要与每一个通道的数据进行卷积。“分离卷积”的意思就是每个卷积核只负责一个通道,最终输出通道的变化靠这个1*1的卷积核。1*1卷积是一种特殊的卷积操作,它在空间维度上不进行任何操作,只改变通道数
class MobileNetBlock(tf.keras.layers.Layer):
def __init__(self, filters, strides=1, alpha=1.0, **kwargs):
super(MobileNetBlock, self).__init__(**kwargs)
self.filters = filters
self.strides = strides
self.alpha = alpha
# 计算输出通道数
self.filters = int(filters * alpha)
# 1x1卷积
self.conv_1x1 = Conv2D(self.filters, (1, 1), padding='same', use_bias=False)
self.bn_1x1 = BatchNormalization()
self.relu_1x1 = Activation('relu')
# 深度可分离卷积
self.depthwise_conv = DepthwiseConv2D((3, 3), strides=(strides, strides), padding='same', use_bias=False)
self.bn_depthwise = BatchNormalization()
self.relu_depthwise = Activation('relu')
# 1x1卷积
self.conv_1x1_2 = Conv2D(self.filters, (1, 1), padding='same', use_bias=False)
self.bn_1x1_2 = BatchNormalization()
self.relu_1x1_2 = Activation('relu')
def call(self, inputs):
# 1x1卷积
x = self.conv_1x1(inputs)
x = self.bn_1x1(x)
x = self.relu_1x1(x)
# 深度可分离卷积
x = self.depthwise_conv(x)
x = self.bn_depthwise(x)
x = self.relu_depthwise(x)
# 1x1卷积
x = self.conv_1x1_2(x)
x = self.bn_1x1_2(x)
x = self.relu_1x1_2(x)
return x
def get_config(self):
config = super(MobileNetBlock, self).get_config()
config.update({
'filters': self.filters,
'strides': self.strides,
'alpha': self.alpha,
})
return config
测试集上的loss和acc分别是0.31912961602211, 0.8862000107765198。和普通的CNN相比效果基本一致,但训练速度更快。
4、resnet
ResNet主要解决了深度卷积神经网络中的梯度消失和梯度爆炸问题。随着网络深度的增加,训练过程中梯度逐渐变小或变大,导致模型难以收敛或过拟合。ResNet引入了残差连接(Residual Connection),通过将输入直接添加到输出中,使得网络可以学习残差映射,从而更容易优化深层网络。
class ResNetBlock(tf.keras.layers.Layer):
def __init__(self, filters, strides=1, **kwargs):
super(ResNetBlock, self).__init__(**kwargs)
self.filters = filters
self.strides = strides
# 第一个卷积层
self.conv1 = Conv2D(filters, (3, 3), strides=strides, padding='same', use_bias=False)
self.bn1 = BatchNormalization()
self.relu1 = Activation('relu')
# 第二个卷积层
self.conv2 = Conv2D(filters, (3, 3), strides=1, padding='same', use_bias=False)
self.bn2 = BatchNormalization()
# 如果步幅不为1,则需要调整输入的维度
if strides != 1:
# 使用1*1卷积改变通道数
self.shortcut = Conv2D(filters, (1, 1), strides=strides, padding='same', use_bias=False)
self.bn_shortcut = BatchNormalization()
else:
self.shortcut = lambda x: x
self.bn_shortcut = lambda x: x
self.relu2 = Activation('relu')
def call(self, inputs):
# 第一个卷积层
x = self.conv1(inputs)
x = self.bn1(x)
x = self.relu1(x)
# 第二个卷积层
x = self.conv2(x)
x = self.bn2(x)
shortcut = self.shortcut(inputs)
shortcut = self.bn_shortcut(shortcut)
# 将输入和输出拼接再送到下一层网络
x = Add()([x, shortcut])
x = self.relu2(x)
return x
def get_config(self):
config = super(ResNetBlock, self).get_config()
config.update({
'filters': self.filters,
'strides': self.strides,
})
return config
测试集上的loss和acc分别是0.28212088346481323, 0.8955000042915344。因为测试案例的数据集不大,网络也不深,所以,resnet的效果没体现出来,但收敛速度比普通卷积网络更快。
5、vgg
VGG主要解决了卷积神经网络中卷积核大小和网络深度对模型性能的影响问题。传统的卷积神经网络通常使用较大的卷积核,而VGG通过使用多个小卷积核来替代大卷积核,从而提高了模型的性能。以一个5*5的卷积核为例,假设输入尺寸为28*28。
输入28*28——经过5*5卷积——输出24*24;
输入28*28——经过3*3卷积——输出26*26——再经过一个3*3卷积——输出24*24;
上述示例中,输入和输出维度都一致。但5*5的卷积核意味着25个待训练参数,而2个3*3的卷积核的参数是2*3*3=18,参数量得到减少。
6、总结
当然,以上网络均已被tensorflow封装,可以从keras.applications选择。不过,官方的实现不一定就是最适合你项目的,能够自己实现网络结构,就多了一个调优的选择。至于如何选择合适的网络,可以参考下面这张图。总体原则就是网络规模尽可能小、准确性尽可能高。
鱼和熊掌,需要权衡。