使用极少数据构建强大的图像分类模型

注意:这篇文章最初写于 2016 年 6 月。现在已经非常过时了。请参阅本指南以获取最新的替代方案,或查看我的书“Deep Learning with Python (2nd edition)”的第 8 章。

在本教程中,我们将介绍一些简单而有效的方法,您可以使用这些方法来构建强大的图像分类器,仅使用极少的训练示例——每个您希望能够识别的类别只有几百或几千张图片。

我们将介绍以下选项

  • 从头开始训练一个小网络(作为基线)
  • 使用预训练网络的瓶颈特征
  • 微调预训练网络的顶层

这将引导我们涵盖以下 Keras 功能

  • fit_generator 用于使用 Python 数据生成器训练 Keras 模型
  • ImageDataGenerator 用于实时数据增强
  • 层冻结和模型微调
  • ...以及更多。

我们的设置:只有 2000 个训练示例(每个类别 1000 个)

我们将从以下设置开始

  • 一台安装了 Keras、SciPy、PIL 的机器。如果您有可以使用的 NVIDIA GPU(并且安装了 cuDNN),那就太好了,但由于我们处理的图像很少,因此这不是严格必要的。
  • 一个训练数据目录和验证数据目录,每个图像类别包含一个子目录,其中填充了 .png 或 .jpg 图像
data/
    train/
        dogs/
            dog001.jpg
            dog002.jpg
            ...
        cats/
            cat001.jpg
            cat002.jpg
            ...
    validation/
        dogs/
            dog001.jpg
            dog002.jpg
            ...
        cats/
            cat001.jpg
            cat002.jpg
            ...

为了获取属于您感兴趣的类别的几百或几千张训练图像,一种可能性是使用Flickr API下载在友好许可下与给定标签匹配的图片。

在我们的示例中,我们将使用两组图片,我们从Kaggle获得:1000 只猫和 1000 只狗(虽然原始数据集有 12,500 只猫和 12,500 只狗,我们只取了每个类别的前 1000 张图像)。我们还使用来自每个类别的 400 个额外样本作为验证数据来评估我们的模型。

对于一个远非简单的分类问题来说,这些例子太少了,无法从中学习。所以这是一个具有挑战性的机器学习问题,但它也是一个现实的问题:在许多现实世界的用例中,即使是小规模的数据收集也可能极其昂贵,有时甚至几乎不可能(例如,在医学成像中)。能够充分利用极少的数据是称职的数据科学家的关键技能。

cats and dogs

这个问题有多难?当 Kaggle 在两年多前开始猫狗识别比赛(总共有 25,000 张训练图像)时,它附带了以下声明

“在多年前进行的一次非正式民意调查中,计算机视觉专家认为,如果没有最先进技术的重大进步,分类器的准确率很难超过 60%。作为参考,60% 的分类器将 12 张图像 HIP 的猜测概率从 1/4096 提高到 1/459。目前的文献表明,机器分类器在这项任务上的得分可以超过 80% 的准确率[参考文献]。”

在比赛结果中,排名靠前的参赛者能够通过使用现代深度学习技术获得超过 98% 的准确率。在我们的例子中,因为我们只使用 8% 的数据集,所以问题要困难得多。

关于深度学习对小数据问题的相关性

我经常听到的一种说法是“深度学习只有在你拥有大量数据时才有意义”。虽然不完全错误,但这有点误导人。当然,深度学习需要能够从数据中自动学习特征,而这通常只有在有大量训练数据可用时才有可能——尤其是在输入样本维度非常高的问题中,比如图像。然而,卷积神经网络——深度学习的支柱算法——在设计上是大多数“感知”问题(如图像分类)的最佳模型之一,即使只有很少的数据可供学习。在小型图像数据集上从头开始训练卷积神经网络仍然会产生合理的结果,而无需任何自定义特征工程。卷积神经网络就是好用。它们是这项工作的正确工具。

但更重要的是,深度学习模型本质上是高度可重用的:你可以采用,例如,在大型数据集上训练的图像分类或语音到文本模型,然后将其用于一个明显不同的问题,只需进行微小的更改,正如我们将在本文中看到的那样。特别是在计算机视觉的情况下,许多预训练模型(通常在 ImageNet 数据集上训练)现在可以公开下载,并且可以用来从极少的数据中引导强大的视觉模型。


数据预处理和数据增强

为了充分利用我们为数不多的训练示例,我们将通过一些随机变换来“增强”它们,这样我们的模型就不会两次看到完全相同的图片。这有助于防止过拟合,并帮助模型更好地泛化。

在 Keras 中,这可以通过 keras.preprocessing.image.ImageDataGenerator 类来完成。这个类允许您

  • 配置在训练期间对图像数据进行的随机变换和归一化操作
  • 通过 .flow(data, labels).flow_from_directory(directory) 实例化增强图像批次(及其标签)的生成器。然后,这些生成器可以与接受数据生成器作为输入的 Keras 模型方法 fit_generatorevaluate_generatorpredict_generator 一起使用。

让我们马上来看一个例子

from keras.preprocessing.image import ImageDataGenerator

datagen = ImageDataGenerator(
        rotation_range=40,
        width_shift_range=0.2,
        height_shift_range=0.2,
        rescale=1./255,
        shear_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True,
        fill_mode='nearest')

这些只是可用选项中的一小部分(有关更多信息,请参阅文档)。让我们快速回顾一下我们刚才写的内容

  • rotation_range 是一个以度为单位的值(0-180),是随机旋转图片的范围
  • width_shiftheight_shift 是范围(作为总宽度或高度的一部分),用于在其中随机垂直或水平平移图片
  • rescale 是一个值,我们将通过该值在任何其他处理之前将数据相乘。我们的原始图像由 0-255 范围内的 RGB 系数组成,但这些值对于我们的模型来说太高而无法处理(考虑到典型的学习率),因此我们通过使用 1/255 的因子进行缩放来将目标值设置在 0 到 1 之间。
  • shear_range 用于随机应用剪切变换
  • zoom_range 用于随机缩放图片
  • horizontal_flip 用于随机水平翻转一半的图像——当没有水平不对称的假设时(例如真实世界的图片),这非常有用。
  • fill_mode 是用于填充新创建的像素的策略,这些像素可能在旋转或宽度/高度偏移后出现。

现在让我们开始使用此工具生成一些图片并将它们保存到一个临时目录中,以便我们可以了解我们的增强策略正在做什么——在这种情况下,我们禁用重新缩放以保持图像可显示

from keras.preprocessing.image import ImageDataGenerator, array_to_img, img_to_array, load_img

datagen = ImageDataGenerator(
        rotation_range=40,
        width_shift_range=0.2,
        height_shift_range=0.2,
        shear_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True,
        fill_mode='nearest')

img = load_img('data/train/cats/cat.0.jpg')  # this is a PIL image
x = img_to_array(img)  # this is a Numpy array with shape (3, 150, 150)
x = x.reshape((1,) + x.shape)  # this is a Numpy array with shape (1, 3, 150, 150)

# the .flow() command below generates batches of randomly transformed images
# and saves the results to the `preview/` directory
i = 0
for batch in datagen.flow(x, batch_size=1,
                          save_to_dir='preview', save_prefix='cat', save_format='jpeg'):
    i += 1
    if i > 20:
        break  # otherwise the generator would loop indefinitely

这就是我们得到的——这就是我们的数据增强策略的样子。

cat data augmentation


从头开始训练一个小卷积神经网络:40 行代码实现 80% 的准确率

图像分类工作的正确工具是卷积神经网络,所以让我们尝试在我们的数据上训练一个,作为初始基线。由于我们只有很少的例子,我们最应该关心的是过拟合。当一个模型接触到的例子太少时,就会发生过拟合,它学习到的模式不能泛化到新的数据,即当模型开始使用不相关的特征进行预测时。例如,如果你,作为一个人,只看到三张伐木工人的图片,和三张水手的图片,其中只有一个伐木工人戴着帽子,你可能会开始认为戴帽子是伐木工人的标志,而不是水手。然后,你就会成为一个非常糟糕的伐木工人/水手分类器。

数据增强是防止过拟合的一种方法,但这还不够,因为我们增强的样本仍然高度相关。您防止过拟合的主要关注点应该是模型的熵容量——您的模型允许存储多少信息。一个可以存储大量信息的模型有可能通过利用更多特征来获得更高的准确率,但它也更容易开始存储不相关的特征。同时,一个只能存储少量特征的模型将不得不关注数据中最重要的特征,而这些特征更有可能是真正相关的,并且可以更好地泛化。

有不同的方法来调节熵容量。主要的方法是在模型中选择参数的数量,即层的数量和每一层的大小。另一种方法是使用权重正则化,如 L1 或 L2 正则化,它包括强制模型权重取较小的值。

在我们的例子中,我们将使用一个非常小的卷积神经网络,它只有很少的层和每层很少的滤波器,以及数据增强和 dropout。Dropout 也有助于减少过拟合,因为它可以防止一层看到两次完全相同的模式,因此其作用方式类似于数据增强(你可以说 dropout 和数据增强都倾向于破坏数据中出现的随机相关性)。

下面的代码片段是我们的第一个模型,一个简单的 3 个卷积层堆叠,使用 ReLU 激活,然后是最大池化层。这与 Yann LeCun 在 20 世纪 90 年代提倡的图像分类架构非常类似(除了 ReLU)。

这个实验的完整代码可以在这里找到:https://gist.github.com/fchollet/0830affa1f7f19fd47b06d4cf89ed44d

from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Activation, Dropout, Flatten, Dense

model = Sequential()
model.add(Conv2D(32, (3, 3), input_shape=(3, 150, 150)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Conv2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Conv2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

# the model so far outputs 3D feature maps (height, width, features)

在它的顶部,我们连接了两个全连接层。我们用一个单元和一个 sigmoid 激活来结束模型,这对于二元分类来说是完美的。为了配合它,我们还将使用 binary_crossentropy 损失来训练我们的模型。

model.add(Flatten())  # this converts our 3D feature maps to 1D feature vectors
model.add(Dense(64))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(1))
model.add(Activation('sigmoid'))

model.compile(loss='binary_crossentropy',
              optimizer='rmsprop',
              metrics=['accuracy'])

让我们准备数据。我们将使用 .flow_from_directory() 直接从各自文件夹中的 jpg 生成图像数据批次(及其标签)。

batch_size = 16

# this is the augmentation configuration we will use for training
train_datagen = ImageDataGenerator(
        rescale=1./255,
        shear_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True)

# this is the augmentation configuration we will use for testing:
# only rescaling
test_datagen = ImageDataGenerator(rescale=1./255)

# this is a generator that will read pictures found in
# subfolers of 'data/train', and indefinitely generate
# batches of augmented image data
train_generator = train_datagen.flow_from_directory(
        'data/train',  # this is the target directory
        target_size=(150, 150),  # all images will be resized to 150x150
        batch_size=batch_size,
        class_mode='binary')  # since we use binary_crossentropy loss, we need binary labels

# this is a similar generator, for validation data
validation_generator = test_datagen.flow_from_directory(
        'data/validation',
        target_size=(150, 150),
        batch_size=batch_size,
        class_mode='binary')

我们现在可以使用这些生成器来训练我们的模型。每个 epoch 在 GPU 上需要 20-30 秒,在 CPU 上需要 300-400 秒。所以,如果你不着急的话,在 CPU 上运行这个模型是可行的。

model.fit_generator(
        train_generator,
        steps_per_epoch=2000 // batch_size,
        epochs=50,
        validation_data=validation_generator,
        validation_steps=800 // batch_size)
model.save_weights('first_try.h5')  # always save your weights after training or during training

这种方法在 50 个 epoch 后使我们的验证准确率达到 0.79-0.81(这个数字是随意选择的——因为模型很小,并且使用了积极的 dropout,所以在那个时候它似乎没有过度拟合)。所以在 Kaggle 比赛开始的时候,我们就已经是“最先进的”了——只用了 8% 的数据,并且没有努力优化我们的架构或超参数。事实上,在 Kaggle 比赛中,这个模型的得分会进入前 100 名(总共 215 名参赛者)。我猜至少有 115 名参赛者没有使用深度学习 ;)

请注意,验证准确率的方差相当大,这既是因为准确率是一个高方差的度量,也是因为我们只使用了 800 个验证样本。在这种情况下,一个好的验证策略是进行 k 折交叉验证,但这需要在每一轮评估中训练 k 个模型。


使用预训练网络的瓶颈特征:一分钟内达到 90% 的准确率

一种更精细的方法是利用在大数据集上预先训练的网络。这样的网络已经学习了对大多数计算机视觉问题都有用的特征,利用这些特征将使我们能够达到比任何只依赖于可用数据的方法更高的准确率。

我们将使用在 ImageNet 数据集上预训练的 VGG16 架构,该模型之前在本博客中已有介绍。由于 ImageNet 数据集在其总共 1000 个类别中包含多个“猫”类别(波斯猫、暹罗猫...)和许多“狗”类别,因此该模型已经学习了与我们的分类问题相关的特征。事实上,仅仅记录模型对我们数据的 softmax 预测而不是瓶颈特征可能就足以很好地解决我们的狗与猫分类问题。但是,我们在此介绍的方法更有可能很好地推广到更广泛的问题,包括 ImageNet 中不存在的类别问题。

VGG16 架构如下所示

vgg16

我们的策略如下:我们只实例化模型的卷积部分,直到全连接层。然后,我们将在训练和验证数据上运行一次此模型,将输出(VGG16 模型的“瓶颈特征”:全连接层之前的最后一个激活映射)记录在两个 numpy 数组中。然后,我们将在存储的特征之上训练一个小的全连接模型。

我们将特征存储离线而不是将全连接模型直接添加到冻结的卷积基之上并运行整个模型的原因是计算效率。运行 VGG16 很昂贵,尤其是当您在 CPU 上工作时,我们只想做一次。请注意,这阻止了我们使用数据增强。

您可以在此处找到此实验的完整代码。您可以从Github获取权重文件。我们不会回顾模型是如何构建和加载的——这在多个 Keras 示例中已经介绍过了。但是,让我们看一下我们如何使用图像数据生成器记录瓶颈特征

batch_size = 16

generator = datagen.flow_from_directory(
        'data/train',
        target_size=(150, 150),
        batch_size=batch_size,
        class_mode=None,  # this means our generator will only yield batches of data, no labels
        shuffle=False)  # our data will be in order, so all first 1000 images will be cats, then 1000 dogs
# the predict_generator method returns the output of a model, given
# a generator that yields batches of numpy data
bottleneck_features_train = model.predict_generator(generator, 2000)
# save the output as a Numpy array
np.save(open('bottleneck_features_train.npy', 'w'), bottleneck_features_train)

generator = datagen.flow_from_directory(
        'data/validation',
        target_size=(150, 150),
        batch_size=batch_size,
        class_mode=None,
        shuffle=False)
bottleneck_features_validation = model.predict_generator(generator, 800)
np.save(open('bottleneck_features_validation.npy', 'w'), bottleneck_features_validation)

然后,我们可以加载我们保存的数据并训练一个小的全连接模型

train_data = np.load(open('bottleneck_features_train.npy'))
# the features were saved in order, so recreating the labels is easy
train_labels = np.array([0] * 1000 + [1] * 1000)

validation_data = np.load(open('bottleneck_features_validation.npy'))
validation_labels = np.array([0] * 400 + [1] * 400)

model = Sequential()
model.add(Flatten(input_shape=train_data.shape[1:]))
model.add(Dense(256, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(1, activation='sigmoid'))

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['accuracy'])

model.fit(train_data, train_labels,
          epochs=50,
          batch_size=batch_size,
          validation_data=(validation_data, validation_labels))
model.save_weights('bottleneck_fc_model.h5')

由于其体积小,即使在 CPU 上,该模型的训练速度也很快(每个 epoch 1 秒)

Train on 2000 samples, validate on 800 samples
Epoch 1/50
2000/2000 [==============================] - 1s - loss: 0.8932 - acc: 0.7345 - val_loss: 0.2664 - val_acc: 0.8862
Epoch 2/50
2000/2000 [==============================] - 1s - loss: 0.3556 - acc: 0.8460 - val_loss: 0.4704 - val_acc: 0.7725
...
Epoch 47/50
2000/2000 [==============================] - 1s - loss: 0.0063 - acc: 0.9990 - val_loss: 0.8230 - val_acc: 0.9125
Epoch 48/50
2000/2000 [==============================] - 1s - loss: 0.0144 - acc: 0.9960 - val_loss: 0.8204 - val_acc: 0.9075
Epoch 49/50
2000/2000 [==============================] - 1s - loss: 0.0102 - acc: 0.9960 - val_loss: 0.8334 - val_acc: 0.9038
Epoch 50/50
2000/2000 [==============================] - 1s - loss: 0.0040 - acc: 0.9985 - val_loss: 0.8556 - val_acc: 0.9075

我们达到了 0.90-0.91 的验证准确率:还不错。这肯定部分是因为基础模型是在已经包含狗和猫(以及数百个其他类别)的数据集上训练的。


微调预训练网络的顶层

为了进一步改进我们之前的结果,我们可以尝试“微调”VGG16 模型的最后一个卷积块以及顶层分类器。微调包括从训练好的网络开始,然后使用非常小的权重更新在新数据集上对其进行重新训练。在我们的例子中,这可以通过 3 个步骤完成

  • 实例化 VGG16 的卷积基并加载其权重
  • 在我们之前定义的全连接模型之上添加,并加载其权重
  • 冻结 VGG16 模型的层直到最后一个卷积块

vgg16: fine-tuning

注意

  • 为了执行微调,所有层都应以经过适当训练的权重开始:例如,您不应该将随机初始化的全连接网络拍打在预训练的卷积基之上。这是因为由随机初始化的权重触发的大的梯度更新会破坏卷积基中学习到的权重。在我们的例子中,这就是我们首先训练顶层分类器,然后才开始微调卷积权重的原因。
  • 我们选择只微调最后一个卷积块而不是整个网络以防止过拟合,因为整个网络将具有非常大的熵容量,因此具有很强的过拟合倾向。低级卷积块学习到的特征比高级卷积块学习到的特征更通用、更抽象,因此保持前几个块固定(更通用的特征)并仅微调最后一个块(更专业的特征)是合理的。
  • 微调应该使用非常慢的学习率,并且通常使用 SGD 优化器而不是自适应学习率优化器(例如 RMSProp)。这是为了确保更新的幅度保持非常小,以免破坏先前学习的特征。

您可以在此处找到此实验的完整代码。

在实例化 VGG 基础并加载其权重后,我们将之前训练的全连接分类器添加到顶部

# build a classifier model to put on top of the convolutional model
top_model = Sequential()
top_model.add(Flatten(input_shape=model.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(1, activation='sigmoid'))

# note that it is necessary to start with a fully-trained
# classifier, including the top classifier,
# in order to successfully do fine-tuning
top_model.load_weights(top_model_weights_path)

# add the model on top of the convolutional base
model.add(top_model)

然后,我们继续冻结所有卷积层,直到最后一个卷积块

# set the first 25 layers (up to the last conv block)
# to non-trainable (weights will not be updated)
for layer in model.layers[:25]:
    layer.trainable = False

# compile the model with a SGD/momentum optimizer
# and a very slow learning rate.
model.compile(loss='binary_crossentropy',
              optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
              metrics=['accuracy'])

最后,我们开始以非常慢的学习率训练整个模型

batch_size = 16

# prepare data augmentation configuration
train_datagen = ImageDataGenerator(
        rescale=1./255,
        shear_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True)

test_datagen = ImageDataGenerator(rescale=1./255)

train_generator = train_datagen.flow_from_directory(
        train_data_dir,
        target_size=(img_height, img_width),
        batch_size=batch_size,
        class_mode='binary')

validation_generator = test_datagen.flow_from_directory(
        validation_data_dir,
        target_size=(img_height, img_width),
        batch_size=batch_size,
        class_mode='binary')

# fine-tune the model
model.fit_generator(
        train_generator,
        steps_per_epoch=nb_train_samples // batch_size,
        epochs=epochs,
        validation_data=validation_generator,
        validation_steps=nb_validation_samples // batch_size)

这种方法使我们在 50 个 epoch 后达到了 0.94 的验证准确率。非常成功!

以下是您可以尝试获得高于 0.95 的其他一些方法

  • 更积极的数据增强
  • 更积极的 dropout
  • 使用 L1 和 L2 正则化(也称为“权重衰减”)
  • 微调另一个卷积块(以及更大的正则化)

这篇文章到此结束!总而言之,您可以在此处找到我们三个实验的代码

如果您对这篇文章有任何评论或对未来要涵盖的主题有任何建议,可以通过Twitter联系。