端到端深度学习项目:第1部分

磐创AI 2022-04-01

kerastensorflow深度学习

6238 字丨阅读本文需 18 分钟

第1部分介绍了问题陈述的设置、数据预处理、迁移学习背后的直觉、特征提取、微调和模型评估。

第2部分介绍Flask应用程序的实现及其在Heroku上的后续部署。为了保持连续性,请遵循教程。

介绍

在这样的环境中工作十分幸运:

(a)数基础设施和体系结构随时可用

(b)数据由分析师处理

(c)MLOP由单独的数据工程师部门处理。

决定实施一个端到端的DL项目,该项目将由三部分组成:

第1部分:设置(虚拟环境、训练数据集等)、模型训练(使用Keras微调、学习曲线监控等)、测试。

第2部分:在Heroku上构建Flask应用程序和部署。

本系列分为两部分,旨在为你提供源代码、提示以及在使用深度学习模型时常见的运行时错误。

相信,这些信息会派上用场。

Headsup:本文(以及随后的文章)中的一些内容将进行极其详细的讨论,因为其目的是让人们(尤其是早期研究人员)理解一些设计决策背后的原因/优点/缺点。

第1部分:设置

虚拟环境使用终端,在项目目录中创建一个名为e2eproject的虚拟环境,并将其激活

python3 -m venv e2eproject

source e2eproject/bin/activate

数据集

我们将使用Kaggle提供的公开房屋房间数据集。

你可以手动下载它,然后将其移动到项目目录中,或者使用终端中的以下命令将其直接下载到项目目录中。

注意:在运行以下命令之前,请确保你在项目目录中。

kaggle datasets download -d robinreni/house-rooms-image-dataset — unzip

任务

我们将进行一项图像分类任务。特别是,我们将开发一个模型,根据卧室的图像,检测房子内部是现代的(M类)还是旧的(O类)。

这种模型可能会在再抵押期间或出售房产时对房产估价有用。

正如你可能已经注意到的,数据集是未标记的,然而,慷慨地提出要手动标记约450张图像。(这些标签已在Github仓库中提供。)虽然这不是一个很大的数据集大小,但我们仍然能够在一个测试集上实现几乎80%的准确率。此外,我们将讨论微调、改进模型度量等的适当技术,以确定是否值得花费更多时间标记额外的数据点。

第二部分:模型训练

让我们创建一个model.ipynb笔记本。

安装

from tensorflow.keras.preprocessing.image import ImageDataGenerator

from tensorflow.keras.applications import EfficientNetB0

from tensorflow.keras.callbacks import EarlyStopping

from tensorflow.keras.callbacks import ModelCheckpoint

from tensorflow.keras.layers import BatchNormalization

from tensorflow.keras.layers import Dropout

from tensorflow.keras.layers import Dense

from tensorflow.keras.layers import Input

from tensorflow.keras.optimizers import Adam

from tensorflow.keras.models import Model


from sklearn.metrics import classification_report, confusion_matrix

from sklearn.model_selection import train_test_split

from imutils import paths

from tqdm import tqdm


import matplotlib.pyplot as plt

import numpy as np

import tensorflow as tf

import seaborn as sns

import numpy as np

import shutil

import os

注意:你可能需要进行一些pip install XXX才能使上述代码正常工作。

辅助变量和函数

ORIG_INPUT_DATASET = "House_Room_Dataset/Bedroom"

TRAIN = "training"

VAL = evaluation"

TEST = "testing"

BASE_PATH = "dataset"

BATCH_SIZE = 32

CLASSES = ["Modern", "Old"]

我们将只处理卧室图像,因此ORIG_INPUT_DATASET指向卧室子目录。

BASE_PATH是指向我们将在其中存储图像的训练、测试和验证拆分的目录的路径。

def plot_hist(hist, metric):

    if metric == 'auc':

           plt.plot(hist.history["auc"])

           plt.plot(hist.history["val_auc"])

    else:

           plt.plot(hist.history["loss"])

           plt.plot(hist.history["val_loss"])

    plt.style.use("ggplot")

    plt.title("model {}".format(metric))

    plt.ylabel("{}".format(metric))

    plt.xlabel("epoch")

    plt.legend(["train", "validation"], loc="upper left")

    plt.show()

这是用于绘制两种类型的学习曲线——AUC与epoch、损失与epoch的代码。

注意:如果你使用的是auc以外的度量,比如准确率,请确保在上面的代码片段中使用准确率替换auc和使用准确率替换val_auc。

加载标签

(labels.txt已作为仓库的一部分提供。)

# Reading labels from the txt file

with open("labels.txt", 'r') as f:

     manual_labels = f.read()

# Extracting individual labels into a list

labels = [i for i in manual_labels]

len(labels)

********* OUTPUT **********

451

要检查数据集是否平衡,请执行以下操作:

from collections import Counter

print(Counter(labels).keys()) 

print(Counter(labels).values())

********* OUTPUT **********

dict_keys(['O', 'M'])

dict_values([271, 180])

在我们的数据集中,与现代房屋相比,我们有更多的老房子(尽管差距不是很大)。

因此,抛弃准确率,选择一个更适合处理类别不平衡的指标,即AUC(ROC曲线下的面积)是有意义的。

训练测试验证拆分

在进行拆分之前,对文件名进行排序很重要,因为我们有前451个图像的标签(在House_Room_Dataset/Bedroom中),而不仅仅是任何随机的451个图像。

默认情况下,os.listdir()以随机顺序返回文件,我们不应该依赖它。

# sorting files in the order they appear

files = os.listdir(ORIG_INPUT_DATASET)

files.sort(key=lambda f: int(f.split('_')[1].split('.')[0]))

# checking to see the correct file order

files[:5]

********* OUTPUT **********

['bed_1.jpg', 'bed_2.jpg', 'bed_3.jpg', 'bed_4.jpg', 'bed_8.jpg']

现在我们知道了正确的451张图片,让我们继续进行训练测试证拆分。我们将分别分配约75%、15%和10%的数据用于训练、验证和测试。

# splitting files into train and test sets

trainX, testX, trainY, testY =  train_test_split(files[:len(labels)], 

                labels, 

                stratify=labels, 

                train_size=0.90)

# further splitting of train set into train and val sets

trainX, valX, trainY, valY = train_test_split(trainX, trainY, stratify=trainY, train_size=0.85)

# Checking the size of train, test, eval

len(trainX), len(trainY), len(valX), len(valY),  len(testX), len(testY)

********* OUTPUT **********

(344, 344, 61, 61, 46, 46)

使用Sklearn的train_test_split方法,我们首先将整个数据集拆分为train和test集,然后再将train数据拆分为训练集和验证集。

按标签进行分层很重要,因为我们希望在所有三组(训练、测试和验证)中,现代房屋和旧房屋都按比例分布。


构建训练数据集目录

在后面的代码中,你会注意到,在训练期间,我们不会将整个数据集加载到内存中。相反,我们将利用Keras的.flow_from_directory()函数,该函数的作用是允许批处理。但是,此函数希望数据按如下方式组织到目录中:

为了按照上述格式组织图像文件,我们将使用以下简短片段:

# Building the dataset properly - 

splits = [(trainX, trainY), (testX, testY), (valX, valY)]

dirnames = ['training', 'evaluation', 'validation']

for i, (data,label) in enumerate(splits):

   outside_dir=dirnames[i]

   for j in tqdm(range(0, len(label)), desc="Iterating over images in sub folder"):

       dir = label[j]
       

       # construct the path to the sub-directory

       dirPath = os.path.join(config.BASE_PATH, outside_dir, dir)
       

       # if the output directory does not exist, create it

       if not os.path.exists(dirPath):

           os.makedirs(dirPath)
           
             

     # copy the img to this new directory

       src_img = os.path.join(config.ORIG_INPUT_DATASET, data[j])

       shutil.copy(src_img, dirPath)

当代码段运行时,你应该能够使用TQM模块查看进度,一旦完成,你将发现创建了三个子目录-dataset/training, dataset/evaluation, 和dataset/validation,在每个目录中,将有两个子目录,分别用于现代房子和旧房子。

作为一个健全的检查,让我们看看我们在每个子目录中有多少图像。

trainPath = os.path.join(BASE_PATH, TRAIN)

valPath = os.path.join(BASE_PATH, VAL)

testPath = os.path.join(BASE_PATH, TEST)

totalTrain = len(list(paths.list_images(trainPath)))

totalVal = len(list(paths.list_images(valPath)))

totalTest = len(list(paths.list_images(testPath)))

print(totalTrain, totalTest, totalVal)

********** OUTPUT *******

344 46 61

注意:如果你的自定义数据位于下面描述的结构中,那么有一个名为split_folders的python包,可用于获取图1中定义的目录结构中的数据。

dataset/

   class1/

       img1.jpg

       img2.jpg

       ...

   class2/

       img3.jpg

       ...

   ...

图像预处理

由于我们处理有限的样本大小,可以使用数据增强,例如旋转,缩放图像等。

数据增强可以增加可用的训练数据量,它实际上做的是获取一个训练样本,并对其应用一个随机转换[来源]。

Keras允许使用ImageDataGenerator对亮度、旋转、缩放、剪切等进行随机增强,最好的是,所有这些都是在模型拟合期间动态完成的,也就是说,你不需要提前计算它们。

训练数据增强:

trainAug = ImageDataGenerator(

rotation_range=90,

zoom_range=[0.5, 1.0],

width_shift_range=0.3,

height_shift_range=0.25,

shear_range=0.15,

horizontal_flip=True,

fill_mode="nearest",

brightness_range=[0.2, 1.0]
   )

大多数参数,如width_shift、height_shift、zoom_range和rotation_range,都可以直接按字面意思理解(如果不是,请查看官方Keras文档)。

一个重要的注意事项是,当你运行时,使用缩放或旋转时,一些空白区域/像素可能是在图像中创建的。

验证数据增强:

valAug = ImageDataGenerator()

你将看到,验证数据的数据增强对象时没有提供任何参数。这意味着我们将使用所有这些默认值,为0。即,我们不应用任何增强。

测试数据增强:

testAug = ImageDataGenerator()

同验证数据。

创建数据增强器

数据增强器将继续为训练期间的模型提供增强的图像。要做到这一点,我们可以使用flow_from_directory() 函数。

# Create training batches whilst creating augmented images on the fly

trainGen = trainAug.flow_from_directory(

directory=trainPath,

target_size=(224,224),

save_to_dir='dataset/augmented/train',

save_prefix='train',

shuffle=True

# Create val batches 

valGen = valAug.flow_from_directory(

directory=valPath,

target_size=(224,224),

shuffle=True

一些重要的事情需要考虑:

在每个案例中,目录都设置为训练(或验证)图像的路径。

将目标大小指定为(224x224x224),确保所有图像都将调整到这个大小。

我们还将设置save_to_dir作为通往目录的路径,在那里我们将保存增强的图像。这提供了一个很好的完整性检查,以查看图像是否按照它们应该的方式进行了随机变换。

最后,shuffle被设置为True,因为我们希望样本在批处理生成器中被打乱,这样当model.fit()请求批处理时,就会给出随机样本。这样做将确保不同时代之间的批次看起来不一样,并最终使模型更加稳健。

# Create test batches

testGen = testAug.flow_from_directory(

directory=testPath,

target_size=(224,224),

shuffle=False

除了为testGen设置正确的目录路径外,还有一件主要的事情需要考虑:

Shuffle必须设置为false。

为什么,你问?

因为,现在我们不希望样品在测试批量生成器中被打乱。只有当shuffle被设置为False时,批量才会按照提供的文件名的顺序创建。这需要在模型评估期间匹配文件名(即真实的标签,使用testGen.classes可访问)和预测的标签。

训练过程背后的直觉

我们本可以从零开始训练一个模型,但这注定会表现不佳——主要是因为我们的数据集太少了。在这种情况下,利用迁移学习的力量是有意义的。

迁移学习是指在一个新的数据集上对一个预先训练好的模型进行微调的过程。这使得它能够识别从未训练过的类!

简而言之,迁移学习允许我们利用在训练中获得的知识模型来识别猫和狗,这样它现在就可以用来预测一个房子的内部是否现代。

但是为什么会这样呢?

因为我们选择的任何基础模型(即预训练模型)通常都是在如此大的图像语料库上训练的,所以它通常能够学习图像的良好向量表示。剩下要做的就是在区分定制类(在我们的例子中,是老房子还是现代房子)时使用这些表示。

基于Keras的迁移学习

迁移学习涉及两个主要步骤:

· 特征提取:将预训练好的模型(并冻结其权重)作为基础模型,然后在顶部训练一个新的分类器,使其精确输出N个值(其中N是类数)。

· [可选]微调:分类器训练完成后,解冻基础模型中的几个层,使其能够很好地适应新的数据集。

新的分类器可以是:

· 一堆全连接层(即全连接的层)

· 一个全局池层(将整个特征映射缩小为一个值——maxpool、avgpool)。

根据你的数据集与预训练模型最初训练的数据集的不同程度,其中一些可能会有所不同。请记住,如果这两个数据集非常相似,那么只解冻所有层的一小部分可能是有益的。

微调步骤虽然是可选的,但对于自定义数据集与训练基础模型的数据集有很大不同的用例来说是非常关键的。此外,与特征提取步骤相比,这可能需要更多的时间。由于时间越长,过拟合的可能性就越大,因此建议在仔细监测损失/准确率曲线后,尽早停止训练。

模型选择背后的直觉

接下来是百万美元的问题——我们应该选择哪种模型作为微调的基础模型?

显然,有很多选择,可以在Keras文档中找到。由于ResNet-50很受欢迎,我最初选择了它,但我最终决定继续使用EfficientNet,因为它们可以实现与SOTA模型类似的结果,同时需要更少的失败次数。

此外,文中提到,它们的性能与SOTA模型一致,而需要参数更少。

EfficientNet模型有很多种风格(EfficientNetB0、EfficientNetB1……EfficientB7),它们在架构(即网络深度、宽度)和资源限制方面略有不同。这些模型中的每一个都需要具有特定图像形状的图像。鉴于我们正在处理224x224分辨率的图像,我们将使用EfficientNetB0。

特征提取步骤的模型训练

注意:本教程将使用Tensorflow的Keras API。如果你是Keras新手,我已经写了两个初级Keras教程(第1部分,第2部分),涵盖了网络架构、神经元、激活函数、隐藏层(全连接层、Dropout层、MaxPool层、平坦层)等,比这里讨论的要详细得多。

我们首先使用imagenet权重创建一个EfficientNetB0基础模型。

baseModel = EfficientNetB0(
              weights="imagenet",
              include_top=False, # make sure top layer is not included
              input_tensor=Input(shape=(224, 224, 3)),
              pooling="avg"
             )

需要考虑的几件事:

· include_top必须设置为False,因为EfficientNet网络体系结构中的顶层(即最后一层)是一个全连接层,输出1000个与ImageNet数据集对应的类。我们显然不需要这个!

· 我们将新分类器提交一个全局池化层。好消息是Keras API已经允许我们在实例化efficientnetb0对象的时候这样做了。我们可以简单地将池参数设置为avg,默认值为None。

下一步是通过将每层的trainable设置为False来冻结权重:

# freeze the weights

for layer in baseModel.layers:  

     layer.trainable = False

现在是时候在上面创建一个新的分类器了,它将精确地输出两个类(M或O)。

要做到这一点,我们需要确保这个分类器模型的最后一层是一个有两个输出神经元的全连接层。

在这两者之间,我们还包括了用于正则化的BatchNormalization和Dropout层。

# training a new classifier on top (Functional Keras Model)

x = baseModel.output

Layer_1 = BatchNormalization()(x)

Layer_2 = Dropout(0.5)(Layer_1)

output_layer = Dense(len(CLASSES), activation="softmax")(Layer_2)

model = Model(inputs = baseModel.input, outputs = output_layer)

注:有两种方法可以建立Keras分类器模型:顺序(最基本的一种)和函数(对于具有多个输入/输出的复杂网络)。

上面的代码片段是作为一个函数性网络编写的,因为如果要使用模型进行检查,使用*model.summary()*会使网络体系结构更加清晰。同样地,我们本可以创建一个如下所示的顺序模型,结果也会相同。

# Another way to create the classifier on top of basemodel

model = tf.keras.Sequential()

model.add(baseModel)

model.add(BatchNormalization())

model.add(Dropout(0.5))

model.add(Dense(len(CLASSES), activation="softmax"))

最后,让我们使用Adam optimizer和相对较大的学习率=1e-3来编译模型。由于我们有两个可能的输出类,我们将使用二元交叉熵损失(如果处理两个以上的类,则使用分类交叉熵),并基于tf中实现的AUC度量tf.keras.metrics.AUC。

# compile

opt = Adam(learning_rate=1e-3)

model.compile(optimizer=opt, 

             loss='binary_crossentropy', 

             metrics=[tf.keras.metrics.AUC()]

             )

在使用fit训练模型之前,要做的最后一件事是实现EarlyStoping和ModelCheckpoint。

前者将确保该模式不会训练超过需要的时间。这是通过监控val_loss来实现的,一旦没有进一步的改进,即无法进一步最小化,训练就会停止。

后者将在给定的文件路径上保存最佳模型——在我们的例子为feature_extraction.h5。我们将再次监控验证丢失,并保存最佳模型。

注意:这是一篇非常好的文章,更详细地解释了EarlyStoping和ModelCheckpoint的实现

# implementing early stopping

es = EarlyStopping(

    monitor='val_loss',  #metric to monitor

    mode='min',  # whether to min or max the metric monitored

    patience=10, # epochs to wait before declaring stopped training

    verbose=1  # output epoch when training was stopped

    )

# implementing model checkpoint

mc = ModelCheckpoint(

     'feature_extraction.h5',

      monitor='val_loss',

      mode='min',

      verbose=1, # display epoch+accuracy everytime model is saved

      save_best_only=True

     )

最后,是模型训练的时候了:

# Training the model

hist = model.fit(

      x=trainGen,

      epochs=25,

      verbose=2,

      validation_data=valGen,

      steps_per_epoch=totalTrain // BATCH_SIZE,

      callbacks=[es, mc]

     )

快速查看AUC和损失曲线,我们可以找到模型收敛的证据。

右图中有趣的观察结果之一是,我们的验证损失低于训练损失。

起初,以为有一些数据泄漏问题,但后来发现了这篇优秀的文章,解释了为什么这是完全正常的,有时会在训练期间发生。

总结两个可能的原因(来自文章本身):

原因#1:正则化(如Dropout)仅在训练期间适用,而不是在验证期间。由于正则化牺牲了训练精度以提高验证/测试精度,因此验证损失可能会低于训练损失。

原因#2:我们的验证集太小(只有61张图像),可能比训练集(即不具代表性的验证数据集)更容易。


特征提取步骤后的模型测试

我们将使用一些样板代码来计算使用.predict()获得的模型预测。请记住,predIdxs将类似于[0.8,0.2],即两个类M 和 O的softmax值,所以确保你使用np.argmax选择这两个的最大值。我们使用testGen.class_indices来检查从类名到类索引的映射。

testGen.reset()

predIdxs = model.predict(

            x=testGen,

            steps=(totalTest // BATCH_SIZE) + 1

           )

predIdxs = np.argmax(predIdxs, axis = 1)

print("No. of test images", len(predIdxs))

print(testGen.class_indices)

cm = confusion_matrix(testGen.classes, predIdxs)

heatmap = sns.heatmap(cm, annot=True)

plt.xlabel("Predicted")

plt.ylabel("Actual")

plt.show()

********* OUTPUT********

No. of test images 46 

{'M': 0, 'O': 1}

微调模型

我们将首先解冻当前模型的最后几层,但是,不应该随意打开或关闭层。微调模型有很多技术和技巧(请参见此示例和此示例),但发现其中一些最有用:但发现其中一些最有用:

在这一步编译模型时,使用比特征提取步骤更小的学习率。较小的学习速率意味着需要更多的epoch,因为每次更新时对网络权重的更改较小。

· BathcNormalization层需要保持冻结。

· 在网络体系结构中,卷积块需要整体打开或关闭。

· 例如:考虑model.summary()输出的最后几行。正如你所看到的,图被整齐地组织成块,block7da是最后一个块。

作为一个起点,我们将解冻block7d中的所有层(BathcNormalization层将保持原样)加上下面的7个层(其中大多数是我们在构建一个新的分类器头时定义的)。

总的来说,网络的最后20层将是解冻的候选者。

________________________________________

Layer (type)                    Output Shape         Param #     ====================================================================
                             .
                             .
                             .

block6d_project_conv (Conv2D)   (None, 7, 7, 192)    221184      ______________________________________________

block6d_project_bn (BatchNormal (None, 7, 7, 192)    768     ________________________________________________

block6d_drop (Dropout)          (None, 7, 7, 192)    0           _________________________________________________

block6d_add (Add)               (None, 7, 7, 192)    0           __________________________________________________

block7a_expand_conv (Conv2D)    (None, 7, 7, 1152)   221184    
__________________________________________

block7a_expand_bn (BatchNormali (None, 7, 7, 1152)   4608        ______________________________________________

block7a_expand_activation (Acti (None, 7, 7, 1152)   0           ________________________________________________

block7a_dwconv (DepthwiseConv2D (None, 7, 7, 1152)   10368      
__________________________________________
                             .
                             .
                             .

已经将用于微调的代码捆绑到一个名为fine_tune_model的函数中。大多数代码从特征提取步骤重复。

def fine_tune_model(model):

    # unfreeze last conv block i.e. block7a 

    for layer in model.layers[-20:]:

         if not isinstance(layer, BatchNormalization):

              layer.trainable = True

    # check which of these are trainable and which aren't

    for layer in model.layers:

         print("{}: {}".format(layer, layer.trainable))

    # compile (with an even smaller learning rate)

    opt = Adam(learning_rate=1e-5)

    model.compile(

            optimizer=opt,

            loss='binary_crossentropy',

            metrics=[tf.keras.metrics.AUC()]

            )

     return model

model_fine_tuned = fine_tune_model(model)

由于微调还将使用相同的数据生成器,即trainGen、valGen和testGen,因此必须重置它们,以便它们从数据集中的第一个样本开始。

trainGen.reset()

valGen.reset()

testGen.reset()

最后,让我们设置早停和模型检查点(注意,我们已经将耐心增加到20,因为我们现在将进行更长时间的训练,即50个epoch),并开始训练。

# implementing early stopping

es_tune = EarlyStopping(

    monitor='val_loss', 

    mode='min',  

    patience=20, 

    verbose=1  

    )

# implementing model checkpoint

mc_tune = ModelCheckpoint(

     'fine_tuned_house.h5',

      monitor='val_loss',

      mode='min',

      verbose=1, 

      save_best_only=True

     )

hist = model_fine_tuned.fit(

     x=trainGen,

     steps_per_epoch=totalTrain // BATCH_SIZE,

     validation_data=valGen,

     epochs=50,

     verbose=2,

     callbacks=[es_tune, mc_tune]

    )

特征提取步骤后的模型测试

在将其与之前的混淆矩阵进行比较后,我们只成功地将正确预测的图像数量增加了2。作为最后的理智检查,看看这个微调步骤是否显示出任何过拟合。

验证损失稳定在0.55左右,表明模型没有过拟合。总的来说,验证集预测的AUC确实会随着时间的推移而变得更好,但回报会逐渐减少。(简单地说,长时间的训练似乎对我们的案例没有实质性的帮助)。

起初,认为训练曲线的波动是由于批量大小造成的,因为它们在网络学习中起着作用。同样,过大的学习速率会阻碍收敛,导致损失函数波动,陷入局部极小值。然而,无论是增加批量大小还是降低学习率都无助于平滑梯度。

另一种可能的解释是,网络已经达到了给定数据集的容量,也就是说,它无法从中学习更多内容。这是可能的,因为我们正在尝试使用344个样本来训练一个相对较大的网络(记住,我们已经解冻了一些额外的层,这意味着存在更多可训练的参数),这些样本无法提供足够的信息来了解问题(进一步)。

注意:为了改进模型,在将更多图像推入训练过程之前,可能需要对模型超参数、train:val split、预训练权重的选择以及网络体系结构本身进行修补。

未来的工作

在这篇最近的论文中已经确定,使用未标记和标记数据集的联合训练优于我们首先使用未标记数据进行预训练,然后对标记数据进行微调。这就是所谓的半监督学习,将是我们下一个教程的重点。这将允许我们充分利用数据集中难以获得标签的剩余图像。

免责声明:凡注明来源本网的所有作品,均为本网合法拥有版权或有权使用的作品,欢迎转载,注明出处本网。非本网作品均来自其他媒体,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责。如您发现有任何侵权内容,请依照下方联系方式进行沟通,我们将第一时间进行处理。

0赞 好资讯,需要你的鼓励
来自:磐创AI
0

参与评论

登录后参与讨论 0/1000

为你推荐

加载中...