CIFAR-10—CNN实战项目

Alex Krizhevsky,Vinod Nair 和 Geoffrey Hinton 收集了 8000 万个小尺寸图像数据集,CIFAR-10 和 CIFAR-100 分别是这个数据集的一个子集。CIFAR-10 数据集由 10 个类别共 60000 张彩色图片组成,其中每张图片的大小为 32×32,每个类别分别 6000 张,部分样本如图 1 所示。
CIFAR-10数据集中部分样本
图 1:CIFAR-10 数据集中部分样本

我们首先下载 CIFAR-10 数据集,解压之后如图 2 所示。
CIFAR-10数据集文件
图 2:CIFAR-10 数据集文件

其中“data_batch_1”至“data_batch_5”是训练文件,每个文件分别有 10000 个训练样本,共计 50000 个训练样本,“test_batch”是测试文件,包含了 10000 个测试样本。

1. 数据预处理

首先导入需要用到的包:
import tensorflow as tf
import numpy as np
import pickle
import os

由于这些数据文件是使用“pickle”进行存储的,因此需要定义一个函数来加载这些数据文件:
def get_pickled_data(data_path):
    data_x =[]
    data_y =[]
    with open(data_path, mode='rb') as file:
        data = pickle.load(file, encoding='bytes')
        x = data[b'data']
        y = data[b'labels']
        #将 3×32×32 的数组变换为32×32×3的数组
        x = x.reshape(10000, 3, 32, 32)\
             .transpose (0, 2, 3, 1).astype('float')
        y = np.array(y)
        data_x.extend(x)
        data_y.extend(y)
    return data_x, data_y

接下来定义一个“prepare_data”函数,用来获取训练和测试数据:
def prepare_data(path):
    x_train =[]
    y_train =[]
    x_test =[]
    y_test =[]
    for i in range(5):
        # train_data_path为训练数据的路径
        train_data_path = os.path.join(path, ('data_batch_' +str(i + 1)))
        data_x, data_y = get_pickled_data(train_data_path)
        x_train += data_x
        y_train += data_y
    #将 50000 个 list 型的数据样本转换为 ndarray 型的
    x_train = np.array(x_train)
    # test_data_path为测试文件的路径
    test_data_path= os.path.join(path, 'test_batch')
    x_test, y_test = get_pickled_data(test_data_path)
    x_test = np.array(x_test)

    return x_train, y_train, x_test, y_test

2. 模型搭建

这个项目将使用 RasNet 模型。RasNet 是一个残差网络,在一定程度上解决了网络层数过多后出现的退化问题。图 3 所示的是“残差块(Residual Block)”,右侧是针对 50 层以上网络的优化结构。
残差块
图 3:残差块

图 4 所示的是一个 34 层的 ResNet 的网络结构,ResNet 的提出者以 VGG-19 模型(图 4 左)为参考,设计了一个 34 层的网络(图 4 中),并进一步构造了 34 层的 ResNet(图 4 右),34 层是按有参数更新的层来计算的。图 4 所示的 34 层 ResNet 中有参数更新的层包括第 1 层卷积层、中间残差部分的 32 个卷积层,以及最后的一个全连接层。

图 4:34 层 ResNet 的网络结构

如图 4 所示,ResNet 中主要使用的是 3×3 的卷积核,并遵守着两个简单的设计原则:
  1. 对于每一层卷积层,如果输出的特征图尺寸相同,那么这些层就使用相同数量的滤波器;
  2. 如果输出的特征图尺寸减半了,那么卷积核的数量将增加一倍,以便保持每一层的时间复杂度。

ResNet 的第一层是 66个7×7 的卷积核,滑动步长为 2;接着是一个步长为 2 的池化层;再接着是 16 个残差块,共 32 个卷积层。根据卷积层中卷积核数量的不同可以分为 4 个部分,每个部分的衔接处特征图的尺寸都缩小了一半,因此卷积核的数量也相应地增加了一倍;残差部分之后是一个池化层,采用平均池化;最后是一个全连接层,并用 Softmax 作为激活函数,得到分类结果。

接下来定义残差块:
class residual_lock(tf.keras.layers.Layer):
    def __init__(self, filters, strides=1):
        super(residual_lock, self). __init__()
        self.conv1 = tf.keras.layers.Conv2D(filters=filters, kernel_size=(3, 3), strides=strides, padding="same")
        #规范化层:加速收敛,控制过拟合
        self.bn1 = tf.keras.layers.BatchNormalization()
        self.conv2 = tf.keras.layers.Conv2D(filters=filters, kernel_size=(3, 3), strides=1, padding="same")
        #规范化层:加速收敛,控制过拟合
        self.bn2 = tf.keras.layers.BatchNormalization()
        #在残差块的第一个卷积层中,卷积核的滑动步长为 2 时,输出特征图尺寸减半,
        #需要对残差块的输入使用步长为 2 的卷积来进行下采样,从而匹配维度
        if strides != 1:
            self.downsample = tf.keras.Sequential()
            self.downsample.add(tf.keras.layers.Conv2D(filters=filters, kernel_size=(1, 1), strides=strides))
            self.downsample.add(tf.keras.layers.BatchNormalization())
        else :
            self.downsample = lambda x: x

    def call (self, inputs, training=None):
        #匹配维度
        identity = self.downsample(inputs)
        conv1 = self.conv1(inputs)
        bn1 = self.bn1(conv1)
        relu = tf.nn.relu(bnl)
        conv2 = self.conv2(relu)
        bn2 = self.bn2(conv2)
        output = tf.nn.relu(tf.keras.layers.add([identity, bn2]))
        return output

接着定义一个函数,用来组合残差块:
def build_blocks(filters, blocks, strides=1):
    """组合相同特征图尺寸的残差块"""
    res_block = tf.keras.Sequential()
    #添加第一个残差块,每部分的第一个残差块的第一个卷积层,其滑动步长为 2
    res_block.add(residual_lock(filters, strides=strides))

    #添加后续残差块
    for _ in range(1, blocks):
        res_block.add(residual_lock(filters, strides=1))

    return res_block

定义好残差块和组合组合残差块的函数后,我们就可以实现具体的 ResNet 模型了:
class ResNet(tf.keras.Model):
    """ResNet 模型"""
    def __init__(self, num_classes=10):
        super(ResNet, self).__init__()
        self.preprocess = tf.keras.Sequential([
            tf.keras.layers.Conv2D(filters=64, kernel_size=(7, 7), strides=2, padding='same'),
            #规范化层:加速收敛,控制过拟合
            tf.keras.layers.BatchNormalization(),
            tf.keras.layers.Activation(tf.keras.activations.relu),
            #最大池化:池化操作后,特征图大小减半
            tf.keras.layers.MaxPool2D(pool_size=(3, 3),strides=2)
        ])

        #组合四个部分的残差块
        self.blocks_1 = build_blocks(filters=64, blocks=3)
        self.blocks_2 = build_blocks(filters=128, blocks=4,strides=2)
        self.blocks_3 = build_blocks(filters=256, blocks=6, strides=2)
        self.blocks_4 = build_blocks(filters=512, blocks=3, strides=2)

        #平均池化
        self.avg_pool = tf.keras.layers.GlobalAveragePooling2D()
        #最后的全连接层,使用Softmax作为激活函数
        self.fc=tf.keras.layers.Dense(units=num_classes,activation= tf.keras.mctivations.softmax)

    def call(self, inputs, training=None):
        preprocess = self.preprocess(inputs)
        blocks_1 = self.blocks_1(preprocess)
        blocks2 = self.blocks_2(blocks_1)
        blocks3 = self.blocks_3(blocks2)
        blocks4 = self.blocks_4(blocks3)
        avg_pool = self.avg_pool(blocks4)
        out = self.fc(avg_pool)

        return out
这里 ResNet 模型的实现完全依照图 4 所示的 34 层的 ResNet 模型结构。

3. 模型训练

最后是模型的训练部分:
if __name__== '__main__' :
    model = ResNet()
    model.build(input_shape=(None, 32, 32, 3))
    model.summary()

    #数据集路径
    path = "./CIFAR-10-batches-py"
    #数据载入
    x_train, y_train, x_test, y_test = prepare_data(path)
    #将类标进行 One-Hot 编码
    y_train = tf.keras.utils.to_categorical(y_train, 10)
    y_test = tf.keras.utils.to_categorical(y_test, 10)

    model.compile(loss= 'categorical_crossentropy',
                  optimizer=tf.keras.optimizers.Adam(),
                  metrics=['accuracy'])
    #动态设置学习率
    lr_reducer = tf.keras.callbacks.ReduceLROnPlateau(
        monitor= 'val_accuracy',
        factor=0.2, patience=5,
        min_lr=0.5e-6)
    callbacks = [lr_reducer]

    #训练模型
    model.fit(x_train, y_train,
              batch_size=50, epochs=20,
              verbose=1, callbacks=callbacks,
              validation_data=(x_test, y_test),
              shuffle=True)

在第 18 行代码中,“tf.keras.callbacks.ReduceLROnPlateau”函数可以用来动态调整学习率,并通过“callbacks”将调整后的学习率传递给模型,参数“monitor”是我们要监测的指标,“factor”是调整学习率时的参数(新的学习率=旧的学习率×factor)。经历“patience”个回合后,如果“monitor”指定的指标没有变化,则对学习率进行调整,“min_lr”限定了学习率的下限。

训练过程的正确率和损失的变化如图 5 所示。
ResNet34训练过程中正确率和损失的变化
图 5:ResNet34 训练过程中正确率和损失的变化

最终在验证集上的准确率为 76.12%,有过拟合的现象,准确率还有提升的空间。有兴趣进一步提升分类效果的读者可以尝试如下方法:
  1. 数据集增强:通过旋转、平移等操作来扩充数据集;
  2. 参数微调:包括训练的回合数、学习率等;
  3. 修改模型:可以尝试在 ResNet32 的基础上修改模型的结构,或者替换其他网络模型。