文章代码来源:《deep learning on keras》,非常好的一本书,大家如果英语好,推荐直接阅读该书,如果时间不够,可以看看此系列文章,文章为我自己翻译的内容加上自己的一些思考,水平有限,多有不足,请多指正,翻译版权所有,若有转载,请先联系本人。
个人方向为数值计算,日后会向深度学习和计算问题的融合方面靠近,若有相近专业人士,欢迎联系。
系列文章:
一、搭建属于你的第一个神经网络
二、训练完的网络去哪里找
三、【keras实战】波士顿房价预测
四、keras的function API
五、keras callbacks使用
六、机器学习基础Ⅰ:机器学习的四个标签
七、机器学习基础Ⅱ:评估机器学习模型
八、机器学习基础Ⅲ:数据预处理、特征工程和特征学习
九、机器学习基础Ⅳ:过拟合和欠拟合
十、机器学习基础Ⅴ:机器学习的一般流程十一、计算机视觉中的深度学习:卷积神经网络介绍
十二、计算机视觉中的深度学习:从零开始训练卷积网络
十三、计算机视觉中的深度学习:使用预训练网络
十四、计算机视觉中的神经网络:可视化卷积网络所学到的东西
一个常用、高效的在小图像数据集上深度学习的方法就是利用预训练网络。一个预训练网络只是简单的储存了之前在大的数据集训练的结果,通常是大的图像分类任务。如果原始的数据集已经足够大,足够一般,通过预训练学习到的空间上的特征层次结构就能有效地在我们的模型中工作,因此这些特征对许多计算机视觉问题都很有用,尽管这些新问题和原任务相比可能涉及完全不同的类别。例如,一个人或许在ImageNet上训练了一个网络(里面的类别大多是动物和日常物体)然后重新定义这个训练有素的网络,让它去识别图像里面的家具。这样学习特征在不同问题的可移植性是深度学习和其它一些浅学习方法比起来最关键的好处,这让深度学习在小数据问题中十分高效。
在我们的例子中,我们考虑了一个大的在ImageNet数据集上训练的卷积网络。图像网络包含很多动物,包括不同种的猫和狗,我们可以期望这会在我们的cat vs. dog分类问题中表现得很好。
我们将使用VGG16结构,由Karen Simonyan 和Andrew Zisserman 于2014年改进,简单且在ImageNet中广泛使用卷积网络结构。尽管这是一个有点老的模型,和现在最先进的相比相差不少,且比现在的模型要笨重一些,我们选择它是因为它的结构和你熟悉的非常相近,而且不需要引入任何新的内容。这或许是你的第一次遇到这些可爱的模型名字——VGG,ResNet,Inception,Inception-ResNet,Xception...你将会习惯他们,因为它们会出现的非常频繁,如果你持续在用深度学习做计算机视觉的话。
这里有两种方法去引入一个预训练的网络:特征提取和良好调参。我们将两个都讲,让我们从特征提取开始。
特征提取
特征提取使用通过之前网络学习到的表示来从新的样本中提取有趣的特征。这些特征通过新的分类器,这个分类器是从零开始训练的。
正如我们之前看到的,卷积网络用来图像分类包括两部分:它们从一系列的池化和卷积层开始,以全连接层分类器结束。第一部分叫做模型的“卷积基”。在卷积网络的情况,“特征提取”将会简单的包含拿卷积基作为预训练网络,在这个上面跑一些新数据,在输出层最上面训练一个新的分类器。
为什么仅仅重用卷积基呢?我们能重用全连接分类器吗?一般来说,这个应当避免。这个原因是卷积基学习到的表示可能更通用,所以也更能复用:卷积网络的特征映射在图像中间是存在通用的,这就使得忽视手头的计算机视觉问题很有用了。在另一端,分类器学习到的表示对于我们要训练的模型的类别是非常特别的——它们将只包含整幅图在这一类或那一类的概率。此外,在全连接层的表示不再包含任何物体处于输入图像的哪个位置的信息:这些层摆脱了空间的概念,尽管物体的位置仍然有卷积特映射来描述。对于物体位置有关的问题,全连接特征就显得相当的无用。
注意:某一层表示提取的共性或是说复用能力,取决于模型的深度。早期的层会提取一些局部的,共度通用的特征比如边缘、颜色和纹理,同时后期的层会提取一些高度抽象的例如猫眼和狗眼。因此如果你的新数据集和原来的数据集很不一样,你最好只用前面的几层模型来做特征提取,而不是使用整个卷积集。
在我们的例子中,由于ImageNet分类基的确包含大量的猫、狗的类,可能我们直接复用全连接层都会得到一些好处。然而,我们选择不这样做,为了包含一些更加一般的情况,这些情况中需要分类的新问题和原来的模型类别没有重叠。
让我们使用VGG16网络来实践一下吧,在ImageNet中提取了猫狗的图像的有趣特征,最后在cat vs. dog中分类器训练这些特征。
VGG16模型,还有一些别的,都被keras提前打包好了。你能直接从keras.applications里面调用模型。这里是图像分类模型的的一个列表,全部都是在ImageNet上面预训练过了的,作为kera.applications的一部分:
- Xception
- Inception V3
- ResNet50
- VGG16
- VGG19
- MobileNet
让我们实例教学VGG16模型:
from keras.applications import VGG16
conv_base = VGG16(weights='imagenet',
include_top=False,
input_shape=(150, 150, 3))
我们给结构传入了三个参数:
- weights,用来初始化权重的checkpoint
- include_top,是否包含最高的一层全连接层。默认的话,全连接分类器将会遵从ImgeNet中的1000类。因为我们试着使用我们的全连接层(只有两类,猫和狗)我们不需要把原始的全连接层包含进来。
- input_shape,我们需要放进网络中的图像张量的形状。这个参数完全可选:如果我们不传这个进去,网络就能处理任何形状的输入。
这里有一些VGG16卷积基结构的细节:这和你熟悉的简单卷积网络很相似。
>>> conv_base.summary()
Layer (type) Output Shape Param #
================================================================
input_1 (InputLayer) (None, 150, 150, 3) 0
________________________________________________________________
block1_conv1 (Convolution2D) (None, 150, 150, 64) 1792
________________________________________________________________
block1_conv2 (Convolution2D) (None, 150, 150, 64) 36928
________________________________________________________________
block1_pool (MaxPooling2D) (None, 75, 75, 64) 0
________________________________________________________________
block2_conv1 (Convolution2D) (None, 75, 75, 128) 73856
________________________________________________________________
block2_conv2 (Convolution2D) (None, 75, 75, 128) 147584
________________________________________________________________
block2_pool (MaxPooling2D) (None, 37, 37, 128) 0
________________________________________________________________
block3_conv1 (Convolution2D) (None, 37, 37, 256) 295168
________________________________________________________________
block3_conv2 (Convolution2D) (None, 37, 37, 256) 590080
________________________________________________________________
block3_conv3 (Convolution2D) (None, 37, 37, 256) 590080
________________________________________________________________
block3_pool (MaxPooling2D) (None, 18, 18, 256) 0
________________________________________________________________
block4_conv1 (Convolution2D) (None, 18, 18, 512) 1180160
________________________________________________________________
block4_conv2 (Convolution2D) (None, 18, 18, 512) 2359808
________________________________________________________________
block4_conv3 (Convolution2D) (None, 18, 18, 512) 2359808
________________________________________________________________
block4_pool (MaxPooling2D) (None, 9, 9, 512) 0
________________________________________________________________
block5_conv1 (Convolution2D) (None, 9, 9, 512) 2359808
________________________________________________________________
block5_conv2 (Convolution2D) (None, 9, 9, 512) 2359808
________________________________________________________________
block5_conv3 (Convolution2D) (None, 9, 9, 512) 2359808
________________________________________________________________
block5_pool (MaxPooling2D) (None, 4, 4, 512) 0
================================================================
Total params: 14,714,688
Trainable params: 14,714,688
Non-trainable params: 0
最终的特征的形状是(4,4,512)。这就是我们要接入全连接层的特征。
在这个时候,我们有两种方法来进行:
- 通过我们的数据集来跑卷积基,记录其输出为数组类型,然后使用这些数据作为输入来训练单独一个全连接分类器,就像我们第一次举得那个例子一样。这个方法很快很容易去运行,因为这对于每一个输入的图像只需要运行卷积基一次,卷积基目前来说是管道中最贵的。然而,同样的原因,这种方法不允许我们利用数据增加。
- 给我们有的卷积基增加一层全连接层,然后端到端的运行整个输入数据。这允许我们使用数据增加,因为每个输入图像都通过了整个卷积基。然而,相同的原因,这种方法也比较昂贵。
我们将包含两种方法。让我们第一个看看所需的代码:在我们的数据中记录输出层conv_base并使用这些输出作为新模型的输入。
我们将会从之前介绍的ImageDataGenerator实例来通过数组的形式提取图像和它们的标签。我们将会从这些图像中使用predict方法来提取特征。
import os
import numpy as np
from keras.preprocessing.image import ImageDataGenerator
base_dir = '/Users/fchollet/Downloads/cats_and_dogs_small'
train_dir = os.path.join(base_dir, 'train')
validation_dir = os.path.join(base_dir, 'validation')
test_dir = os.path.join(base_dir, 'test')
datagen = ImageDataGenerator(rescale=1./255)
batch_size = 20
def extract_features(directory, sample_count):
features = np.zeros(shape=(sample_count, 4, 4, 512))
labels = np.zeros(shape=(sample_count))
generator = datagen.flow_from_directory(
directory,
target_size=(150, 150),
batch_size=batch_size,
class_mode='binary')
i = 0
for inputs_batch, labels_batch in generator:
features_batch = conv_base.predict(inputs_batch)
features[i * batch_size : (i + 1) * batch_size] = features_batch
labels[i * batch_size : (i + 1) * batch_size] = labels_batch
i += 1
if i * batch_size >= sample_count:
# Note that since generators yield data indefinitely in a loop,
# we must `break` after every image has been seen once.
break
return features, labels
train_features, train_labels = extract_features(train_dir, 2000)
validation_features, validation_labels = extract_features(validation_dir, 1000)
test_features, test_labels = extract_features(test_dir, 1000)
在把之前的输出丢进全连接分类器之前,我们需要先把它们拉平:
train_features = np.reshape(train_features, (2000, 4 * 4 * 512))
validation_features = np.reshape(validation_features, (1000, 4 * 4 * 512))
test_features = np.reshape(test_features, (1000, 4 * 4 * 512))
此时,我们能够定义我们的全连接分类器了(使用dropout正则化),并在我们所记录的数据和标签上进行训练:
from keras import models
from keras import layers
from keras import optimizers
model = models.Sequential()
model.add(layers.Dense(256, activation='relu', input_dim=4 * 4 * 512))
model.add(layers.Dropout(0.5))
model.add(layers.Dense(1, activation='sigmoid'))
model.compile(optimizer=optimizers.RMSprop(lr=2e-5),
loss='binary_crossentropy',
metrics=['acc'])
history = model.fit(train_features, train_labels,
epochs=30,
batch_size=20,
validation_data=(validation_features, validation_labels))
训练很快,我们可以解决两个全连接层,每一批次在CPU上都只需要不到一秒的时间。
让我们看一下训练的损失和准确率曲线:
import matplotlib.pyplot as plt
acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']
epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
我们在验证集上面的准确率到了约90%,比我们之前从零开始训练的小模型要好得多。然而,我们的图同样揭示了我们几乎从一开始就过拟合,尽管对dropout用了很高的rate。这是因为这些方法都没有利用数据增多,这对于小样本来说预防过拟合相当的重要。
现在,让我们回顾第二种我们提到的做特征提取的方法,这将慢得多,贵得多,但这允许我们在训练的时候使用数据增多的方式:扩展卷积基模型,然后端到端的运行输入。注意到这个技术实际上非常贵,所以你只能在GPU上面试:这在CPU上跑几乎僵化。如果你无法在GPU上跑代码,那么你就用第一种方法吧。
因为模型表现得像个层,你能在sequential模型上加模型:
from keras import models
from keras import layers
model = models.Sequential()
model.add(conv_base)
model.add(layers.Flatten())
model.add(layers.Dense(256, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
让我们看一下最后长啥样
>>> model.summary()
Layer (type) Output Shape Param #
================================================================
vgg16 (Model) (None, 4, 4, 512) 14714688
________________________________________________________________
flatten_1 (Flatten) (None, 8192) 0
________________________________________________________________
dense_1 (Dense) (None, 256) 2097408
________________________________________________________________
dense_2 (Dense) (None, 1) 257
================================================================
Total params: 16,812,353
Trainable params: 16,812,353
Non-trainable params: 0
正如你看到的,VGG16的卷积基有14714688个参数,非常大。我们在顶上加的分类器有2000个参数。
在我们编译和训练模型之前,一个很重要的事情就是冻结卷积基。冻结一层或多层,意味着阻止其权重在训练的时候发生变化。如果你不这样做,之前训练好的卷积基将会在训练的过程中发生改变。因为顶上的全连接层随机初始化,很多的权重升级就会通过网络传播,有效的毁坏之前学习到的表示。
在keras里面,冻结网络是通过将trainable属性设置为False来做到的:
>>> print('This is the number of trainable weights '
'before freezing the conv base:', len(model.trainable_weights))
This is the number of trainable weights before freezing the conv base: 30
>>> conv_base.trainable = False
>>> print('This is the number of trainable weights '
'after freezing the conv base:', len(model.trainable_weights))
This is the number of trainable weights after freezing the conv base: 4
通过这个设置,只有两个加进去的全连接层能够被训练。总共只有两个权重张量:每层两个(主要的权重矩阵和偏置向量)。注意为了让这些改变更有效,我们必须首先将模型编译。如果你曾经修改了权重的可训练性,在编译之后,你应当重新编译模型,不然这些改变会被忽略。
现在我们开始训练我们的模型,使用之前我们用过的数据增多方法。
from keras.preprocessing.image import ImageDataGenerator
train_datagen = ImageDataGenerator(
rescale=1./255,
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')
# Note that the validation data should not be augmented!
test_datagen = ImageDataGenerator(rescale=1./255)
train_generator = train_datagen.flow_from_directory(
# This is the target directory
train_dir,
# All images will be resized to 150x150
target_size=(150, 150),
batch_size=20,
# Since we use binary_crossentropy loss, we need binary labels
class_mode='binary')
validation_generator = test_datagen.flow_from_directory(
validation_dir,
target_size=(150, 150),
batch_size=20,
class_mode='binary')
model.compile(loss='binary_crossentropy',
optimizer=optimizers.RMSprop(lr=2e-5),
metrics=['acc'])
history = model.fit_generator(
train_generator,
steps_per_epoch=100,
epochs=30,
validation_data=validation_generator,
validation_steps=50)
再次画出结果:
如你所见,我们在验证集的准确率到达了大约96%。这比我们之前在小卷积网络上从零训练的结果要好的多。
好的调参
另一个在模型复用中广泛应用的和特征提取互补,是好调参。好调参是最后几层都不冻结,使得可以在最后比较抽象的层次也可以复用调参,使得它们对于手头的问题来说更加相关。
我们曾经声明过为了能够训练顶层随机初始化的分类器,冻结VGG16的卷积基非常重要。相同的原因,只有当卷积基中的分类器被训练过以后,才有可能调参。如果分类器还没有被训练过,传给网络的错误的信号就太大了,这个表示之前学到的将会被毁掉。因此调参的步骤如下:
- 1)在已经训练的基础网络顶层添加你自定义的网络
- 2)冻结基础网络
- 3)训练你添加的部分
- 4)解冻基础网络的某些层
- 5)将这些原来的层和你加的层一起训练
在做特征提取的时候,我们已经结束了前三步。让我们接下来做第四步:我们将会解冻我们的conv_base层,然后冻结其中的一些单独的层。
作为提醒,下面是我们的卷积基的样子:
>>> conv_base.summary()
Layer (type) Output Shape Param #
================================================================
input_1 (InputLayer) (None, 150, 150, 3) 0
________________________________________________________________
block1_conv1 (Convolution2D) (None, 150, 150, 64) 1792
________________________________________________________________
block1_conv2 (Convolution2D) (None, 150, 150, 64) 36928
________________________________________________________________
block1_pool (MaxPooling2D) (None, 75, 75, 64) 0
________________________________________________________________
block2_conv1 (Convolution2D) (None, 75, 75, 128) 73856
________________________________________________________________
block2_conv2 (Convolution2D) (None, 75, 75, 128) 147584
________________________________________________________________
block2_pool (MaxPooling2D) (None, 37, 37, 128) 0
________________________________________________________________
block3_conv1 (Convolution2D) (None, 37, 37, 256) 295168
________________________________________________________________
block3_conv2 (Convolution2D) (None, 37, 37, 256) 590080
________________________________________________________________
block3_conv3 (Convolution2D) (None, 37, 37, 256) 590080
________________________________________________________________
block3_pool (MaxPooling2D) (None, 18, 18, 256) 0
________________________________________________________________
block4_conv1 (Convolution2D) (None, 18, 18, 512) 1180160
________________________________________________________________
block4_conv2 (Convolution2D) (None, 18, 18, 512) 2359808
________________________________________________________________
block4_conv3 (Convolution2D) (None, 18, 18, 512) 2359808
________________________________________________________________
block4_pool (MaxPooling2D) (None, 9, 9, 512) 0
________________________________________________________________
block5_conv1 (Convolution2D) (None, 9, 9, 512) 2359808
________________________________________________________________
block5_conv2 (Convolution2D) (None, 9, 9, 512) 2359808
________________________________________________________________
block5_conv3 (Convolution2D) (None, 9, 9, 512) 2359808
________________________________________________________________
block5_pool (MaxPooling2D) (None, 4, 4, 512) 0
================================================================
Total params: 14714688
我们将会调最后三层的参数,意味着直到block4_pool的层都应该被冻结,最后几层,block5_conv1,block5_conv2和block5_conv3应当被训练。
为什么不调更多层的参数?为什么不调整个卷积基的参数?我们可以。然而我们需要考虑下面这些:
- 卷积基中早期的层编码了一些通用的特征,后期的层编码了一些比较专业的特征,这些才是我们在新的问题中需要重新设计的。在早期层数里面调参会导致结果急剧下降。
- 我们训练的参数愈多,我们过拟合的风险越大。卷积基有15M个参数,所以在我们小样本上训练有很大的风险。
因此,在我们的情况下,好的策略是训练最后一两层卷积基。
让我们结束之前剩下的小尾巴:
conv_base.trainable = True
set_trainable = False
for layer in conv_base.layers:
if layer.name == 'block5_conv1':
set_trainable = True
if set_trainable:
layer.trainable = True
else:
layer.trainable = False
很巧妙的把之前的层全冻了,现在我们能够开始给我们的网络调参了。我们将会用RMSprop优化器,使用很低的学习率。我们选择使用低学习率的原因是我们想要限制我们对这三层的修改的大小。改动太大会损害原有的表示。
现在让我们开始训练吧:
model.compile(loss='binary_crossentropy',
optimizer=optimizers.RMSprop(lr=1e-5),
metrics=['acc'])
history = model.fit_generator(
train_generator,
steps_per_epoch=100,
epochs=100,
validation_data=validation_generator,
validation_steps=50)
用之前的代码画出图:
这个曲线看起来噪声很大。为了让它们更加具有可读性,我们可以光滑它们通过滑动平均:
def smooth_curve(points, factor=0.8):
smoothed_points = []
for point in points:
if smoothed_points:
previous = smoothed_points[-1]
smoothed_points.append(previous * factor + point * (1 - factor))
else:
smoothed_points.append(point)
return smoothed_points
plt.plot(epochs,
smooth_curve(acc), 'bo', label='Smoothed training acc')
plt.plot(epochs,
smooth_curve(val_acc), 'b', label='Smoothed validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()
plt.plot(epochs,
smooth_curve(loss), 'bo', label='Smoothed training loss')
plt.plot(epochs,
smooth_curve(val_loss), 'b', label='Smoothed validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
现在曲线看起来更加干净稳定了。我们看到了1%的提升。
注意到损失曲线并没有展现出真实的提升,实际上它恶化了。你或许想知道,准确率是如何提升的,如果损失没有下降的化?答案很简单:当我们看平均的损失值时,实际上影响准确率的是损失值,不是平均,因为准确率是模型分类预测的二进阈值的结果。模型或许仍然在在变好,只不过没有在平均损失中体现出来。
我们最后能在测试集上评估模型:
test_generator = test_datagen.flow_from_directory(
test_dir,
target_size=(150, 150),
batch_size=20,
class_mode='binary')
test_loss, test_acc = model.evaluate_generator(test_generator, steps=50)
print('test acc:', test_acc)
到这里为止,我们得到了测试准确率为97%。在原来的Kaggle竞赛中关于这个数据集,这是最顶尖的几个结果之一。然而,使用现代深度学习技术,我们可以使用只用很少的训练数据(大约百分之十)来达到这个结果。在20000个样本和2000个样本之间有着巨大的区别。
外卖:在小的数据集上使用卷积网络
这里有一些你需要打包带走的东西,通过之前两部分的学习:
- 卷积网络对于计算机视觉任务来说是最好的机器学习模型。使用少量数据训练得到体面的结果是可能的。
- 在小数据集上,过拟合将会是一个主要问题。数据增加是一个有力的对抗过拟合的方法,当我们面临图像数据时。
- 这很容易在存在的卷积网络上复用数据集,通过特征提取。这对于在小数据集上工作十分有价值。
- 作为特征提取的补充,使用调参,适应存在的模型表示的一些新问题。这将表现推得更远。
现在你已经有了一个有力的工具来解决图像分类问题,特别是小的数据集。