Classfication基础实验系列一——MiniVGG & CIFAR10

一、概述

  前段时间进行了一些图像分类的实验,最近有点时间,打算把这些实验的具体参数,实验过程以及结果记录下来,为以后调参积累一些经验和直觉。其中有些小型网络结构以及辅助函数,可能以后也会经常用到。这一系列实验主要使用Keras或者Gluon两个框架。

  本文是这个系列笔记的第一篇,这里我以一个简单的MiniVGG网络为例子,复习下使用Keras的Sequential模型迅速搭建一个分类任务的流程。由于网络本身非常简单,准确率也不是很高,不过当时做了一些分离实验,就顺带把一些有趣的实验结果记录下来。

  • 本实验将观察相同网络下,①BN和Dropout对分类结果的影响;②使用FC与GlobalAveragePooling做分类头的区别。

  • 本文详细分析了一个keras中对Sequential模型pop layers的陷阱,并介绍了对应的解决方法——一个改良的pop_layer函数。

二、实验设置

2.1 网络介绍

  VGG网络的特点是多个3x3 Conv+RELU贯穿网络始终。这里的MiniVGG也是如此,其网络架构非常简单:
  两组CONV => RELU => CONV => RELU => POOL后接一组FC=>RELU=>FC=>SOFTMAX,我们可以在网络中加上BN和Dropout,其中BN加在每个RELU之后,而Dropout可以加在Conv和FC之后(Adrian建议在Conv层之后使用0.25的Dropout)。具体参数见下方:

Fig. 1. MiniVGG

  代码见下:非常清楚,每组CONV => RELU => CONV => RELU => POOL之后用一个0.25Dropout。

import os
from keras.datasets import cifar10
from keras.layers.convolutional import Conv2D, MaxPooling2D, AveragePooling2D
from keras.layers.core import Activation, Flatten, Dense, Dropout
from keras.layers.normalization import BatchNormalization
from keras.models import Sequential
from keras.optimizers import SGD
from keras.utils import multi_gpu_model
from keras import backend as K
import matplotlib.pyplot as plt

class MiniVGGNet:
    @staticmethod  # 静态方法: 无需实例化即可调用类中的方法
    def build(width, height, depth, classes):
        chanDim=-1
        inputShape=(height, width, depth)
        if K.image_data_format == "channel_first":
            inputShape = (depth, height, width)
            chanDim=1
        
        model = Sequential()
        model.add(Conv2D(32, (3, 3), padding='same',input_shape=inputShape))
        model.add(Activation('relu'))
        model.add(BatchNormalization(axis=chanDim))
        model.add(Conv2D(32, (3, 3), padding='same'))
        model.add(Activation('relu'))
        model.add(BatchNormalization(axis=chanDim))
        model.add(MaxPooling2D(strides=2))
        model.add(Dropout(0.25))
        
        model.add(Conv2D(64, (3, 3), padding='same'))
        model.add(Activation('relu'))
        model.add(BatchNormalization())
        model.add(Conv2D(64, (3, 3), padding='same'))
        model.add(Activation('relu'))
        model.add(BatchNormalization())
        model.add(MaxPooling2D(strides=2))
        model.add(Dropout(0.25))
        
        model.add(Flatten())
        model.add(Dense(512))
        model.add(Activation('relu'))
        model.add(BatchNormalization())
        model.add(Dropout(0.5))
        model.add(Dense(classes))
        model.add(Activation('softmax'))
        
        return model

2.2 加载数据

  • 直接用keras的cifar10模块将数据集整个加载到内存中,没有使用任何Dataloader,只对数据集做归一化处理。
def loadCifar():
    (trainX, trainY),(testX, testY) = cifar10.load_data()
    trainX = trainX.astype("float") / 255
    testX = testX.astype("float") / 255
    lb = LabelBinarizer()
    trainY = lb.fit_transform(trainY)
    testY = lb.fit_transform(testY)
    labelNames = ["airplane", "automobile", "bird", "cat", "deer", 
                  "dog", "frog", "horse", "ship", "truck"]
    return (trainX, trainY), (testX, testY), labelNames

2.3 Train Loop

from sklearn.preprocessing import LabelBinarizer
from sklearn.metrics import classification_report
from keras.optimizers import SGD
import numpy as np

def train(trainX, trainY, testX, testY, lr=0.01, num_epochs=40, classes=10, labelNames=None):
    optimizer = SGD(lr=lr, decay=lr/num_epochs, momentum=0.9, nesterov=True)  #使用默认的decay方法
    model = MiniVGGNet.build(32, 32, 3, classes)
    parallel_model = multi_gpu_model(model, gpus=2)
    model.compile(loss="categorical_crossentropy", optimizer=optimizer, metrics=["accuracy"])
    print("[INFO] training network...")
    H = model.fit(trainX, trainY, 64, num_epochs, validation_data=(testX, testY), verbose=1)
    print("[INFO] evaluating network...")
    predictions = model.predict(testX, batch_size=128)
    print(classification_report(testY.argmax(axis=1), predictions.argmax(axis=1), target_names=labelNames))
    plt.style.use("ggplot")
    plt.figure()
    plt.plot(np.arange(num_epochs), H.history["loss"], label="train_loss")
    plt.plot(np.arange(num_epochs), H.history["val_loss"], label="val_loss")
    plt.plot(np.arange(num_epochs), H.history["acc"], label="acc")
    plt.plot(np.arange(num_epochs), H.history["val_acc"], label="val_acc")
    plt.title("Training Loss and Accuracy on CIFAR-10")
    plt.xlabel("Epoch #")
    plt.ylabel("Loss/Accuracy")
    plt.legend()
    plt.savefig("training_curve.png")
    
if __name__ == "__main__":
    (trainX, trainY), (testX, testY), labelNames = loadCifar()
    train(trainX, trainY, testX, testY, labelNames=labelNames)

三、实验结果对比

*注意: 以下四组实验使用相同的配置(lr, decay, epochs, batchSize,均不使用数据增强)

3.1 使用BN+Dropout+FC提取特征:

Fig. 2. 实验1 classification_report
Fig. 3. 实验1 训练曲线

3.2 去掉Conv层的Dropout:

Fig. 4. 实验2 classification_report

Fig. 5. 实验2 训练曲线

3.3 去掉所有BN和Dropout:

Fig. 5. 实验3 classification_report

Fig. 6. 实验3 训练曲线

3.4 在3.1基础上将FC+FC换成GAP+FC

3.4.1 修改Sequential模型时遇到的问题

  这里有个问题需要解决,就是如何去除一个Sequential模型的后面一部分网络然后加上自己的层。后面的迁移学习可能还会再提到这个技巧(不过迁移学习用到更多的是Model类的模型,和Sequential差别较大,迁移学习时用的套路也不同)。对于一个Sequential模型来说,去掉后面几层听起来像是一个非常直觉,简单的想法。不过实际上Keras在实现时不知道出于什么原因,没有直接定义这个功能。

  值得注意的是,keras中的model.layers.pop()接口看起来像是pop掉模型最后一层的,如果我们打印出pop几次的模型summary,可能发现后面的层看起来确实pop掉了。实际上这里是个陷阱!! 原因其实我们现在也不确定。Github上有人说这是因为当我们调用net.layers时,我们得到的并不是真的layers list,而是一个浅拷贝。见下面这段话:

  You can't use model.layers.pop() to remove the last layer in the model. In tf.keras, model.layers will return a shallow copy version of the layers list, so actually you don't remove that layer, just remove the layer in the return value.

参考:https://github.com/tensorflow/tensorflow/issues/22479

  但是感觉又不是这样。因为如果是list的浅拷贝,拷贝之后的对象(下面的lst3)做出修改,lst1并没有改变。但是①对model.layers这个list使用pop之后,下一次调用model.layers,返回的并不是完整的layers_list,而是pop之后的layers_list;②打印model.summary()显示的也是pop之后的模型。这样给人一种感觉就是最后几层已经顺利移除了。然而并不是这样!!训练的时候会发现trainable参数完全没有变化!!

Fig. 7. list的浅拷贝与引用传递

  如果通过model.layers和model.summary来观察,显然很容易被欺骗。这个地方当时坑了我好长时间,一度怀疑人生。虽然我现在还没明白keras为什么要这样设置,但是其实有一个细节,仔细想想这个办法肯定行不通:我们可能很容易不假思索地认为,net.layers返回的是keras内置对象组成的一个容器,pop也是这个容器的方法。然而事实上net.layers的变量类型是list,想想也知道,你怎么能通过pop一个list对象的元素,来修改一个keras.models.Sequential 对象的元素呢?你至少也得通过models.Sequential对象内置的方法去修改,而不是通过list对象的内置方法...

  事实上,model.layers这个接口非常有用,但是并不是拿这个list来直接用,而是用来遍历某一个范围的层,然后对这些层调用其方法,如设置layer.trainable;或设置layer.W_regularizer等属性。

  上面说的话可能对解决本任务并没有帮助,但是也算是一些通用的思考。因为很多时候我们对API不那么熟悉,很容易不假思索的将返回的结果作为黑匣子使用。仔细区别一下不同的变量类型,就更有可能避免一些不易察觉的错误。

3.4.1 修改Sequential模型的解决方法

  这里给出一个github上找到的解决方法:定义一个真正的用于pop_layer的函数。具体的参考链接由于时间比较长我已经找不到了..下面是代码:

def pop_layer(model):
    if not model.outputs:
        raise Exception('Sequential model cannot be popped: model is empty.')

    model.layers.pop()
    if not model.layers:
        model.outputs = []
        model.inbound_nodes = []
        model.outbound_nodes = []
    else:
        model.layers[-1].outbound_nodes = []
        model.outputs = [model.layers[-1].output]
    model.built = False

def build_fcn_model(lr=0.01, nesterov=True):
    net = MiniVGGNet.build(32, 32, 3, 10)
    for _ in range(6):
        pop_layer(net)
    net.add(AveragePooling2D((8,8)))
    net.add(Flatten())
    net.add(Dense(10))
    net.add(Activation('softmax'))

    optimizer = SGD(lr=lr, momentum=0.9, decay=0.01/80, nesterov=nesterov)
    net.compile(optimizer=optimizer, loss="categorical_crossentropy", metrics=["accuracy"])
    return net
  • 注意:pop_layer函数就是网上找的用来真正移除模型最后一层的函数。下面那个函数是我自己的辅助函数。其中range(6)是指要移除后面6层。这个6就是原始MiniVGG网络的倒数第六层Flatten。我们在使用这个套路处理其他Sequential模型时,可能也需要先确定这个索引。这里给出一个简便的方法::
layerList = [x.__class__.__name__ for x in net.layers]
len(layerList) - layerList.index("Flatten")
####  输出
6

即Flatten层的索引是-6。所以只需要pop六次即可。下面给出GAP模型的实验结果:

Fig. 8. 实验4 classification_report

Fig. 9. 实验4 训练曲线

训练时长比较:

前两组实验时长基本一样;BN时间影响很大;FC换成GAP训练时长明显缩短

实验1: 9s 一个epoch, 173us/step;
实验2: 8-9s一个epoch, 170-173 us/step;
实验3: (去掉BN和Dropout) 6-7s一个epoch, 127-132us/step;
实验4: (GAP) 7s一个epoch, 132us/step;

四、结论

  • 虽然很多文献中提到,卷积层使用Dropout效果不好,因为卷积层相邻像素之间有关联,随机丢弃神经元无法避免信息流到下一层,但是这个小实验显示,Conv层后的Dropout还是可以一定程度抑制过拟合的。

  • BN应该成为大多数分类网络的标配。虽然BN会很大程度增加训练时间

  • 必须通过曲线观察val loss和train loss的关系。只看屏幕打印出的train loss和valid loss有时候判断不出是否过拟合。更不能只看train acc和valid acc。有时候训练到一定epoch数之后,继续训练可能val acc不会降低甚至升高,但是valid loss显示已经明显过拟合了。

  • 看起来将FC换成GAP可能导致模型表现少量下滑。FC提取特征的能力还是值得肯定的。但是FC换成GAP可以带来训练速度的提升以及模型所占空间的减小。不过由于模型和数据集都很简单,这个结论还有待进一步验证。

你可能感兴趣的:(Classfication基础实验系列一——MiniVGG & CIFAR10)