TensorFlow 2.0深度学习算法实战 第15章 自定义数据集

在人工智能上花一年时间,这足以让人相信上帝的存在。−艾伦·佩利

深度学习已经被广泛地应用在医疗、生物、金融等各行各业中,并且被部署到网络端、移动端等各种平台上。前面我们在介绍算法时,使用的数据集大部份为常用的经典数据集,可以通过 TensorFlow 几行代码即可完成数据集的下载、加载以及预处理工作,大大地提升了算法的研究效率。在实际应用中,针对于不同的应用场景,算法的数据集也各不相同。那么针对于自定义的数据集,使用 TensorFlow 完成数据加载,设计优秀的网络模型训练,并将训练好的模型部署到移动端、网络端等平台是将深度学习算法落地的必不可少的环节。

本章我们将以一个具体的图片分类的应用场景为例,介绍自定义数据集的下载、数据处理、网络模型设计、迁移学习等一系列实用技术。

15.1 精灵宝可梦数据集

精灵宝可梦(Pokemon GO)是一款通过增强现实(Augmented Reality,简称 AR)技术在室外捕捉、训练宝可梦精灵,并利用它们进行格斗的移动端游戏。游戏在 2016 年 7 月上线Android 和 iOS 端程序,一经发布,便受到全球玩家的追捧,一度由于玩家太多引起了服务器的瘫痪。如图 15.1 所示,一名玩家通过手机扫描现实环境,收集到了虚拟的宝可梦“皮卡丘”。
在这里插入图片描述
我们利用从网络爬取的宝可梦数据集来演示如何完成自定义数据集实战。宝可梦数据集共收集了皮卡丘(Pikachu)、超梦(Mewtwo)、杰尼龟(Squirtle)、小火龙(Charmander)和妙蛙种子(Bulbasaur)共 5 种精灵生物,每种精灵的信息如表 15.1 所示,共 1168 张图片。这些/图片中存在标注错误的样本,因此人为剔除地其中错误标注的样本,获得共 1122 张有效图片。
在这里插入图片描述
读者自行下载提供的数据集文件,解压后获得名为 pokemon 的根目录,它包含了 5 个子文件夹,每个子文件夹的文件名代表了图片的类别名,每个子文件夹下面存放了当前类别的所有图片,如图 15.2 所示。
在这里插入图片描述

15.2 自定义数据集加载

实际应用中,样本以及样本标签的存储方式可能各不相同,如有些场合所有的图片存储在同一目录下,类别名可从图片名字中推导出,例如文件名为“pikachu_asxes0132.png”的图片,其类别信息可从文件名 pikachu 提取出。有些数据集样本的标签信息保存为 JSON 格式的文本文件中,需要按照 JSON 格式查询每个样本的标签。不管数据集是以什么方式存储的,我们总是能够用过逻辑规则获取所有样本的路径和标签信息。

我们将自定义数据的加载流程抽象为如下步骤。

15.2.1 创建编码表

样本的类别一般以字符串类型的类别名标记,但是对于神经网络来说,首先需要将类别名进行数字编码,然后在合适的时候再转换成 One-hot 编码或其他编码格式。考虑𝑛个类别的数据集,我们将每个类别随机编码为𝑙 ∈ [0, 𝑛 − 1]的数字,类别名与数字的映射关系称为编码表,一旦创建后,一般不能变动。

针对精灵宝可梦数据集的存储格式,我们通过如下方式创建编码表。首先按序遍历pokemon 根目录下的所有子目录,对每个子目标,利用类别名作为编码表字典对象name2label 的键,编码表的现有键值对数量作为类别的标签映射数字,并保存进name2label 字典对象。实现如下:

def load_pokemon(root,mode='train'):
    #创建数字编码表
    name2label={}#编码表字典,“sq...”:0
    #遍历根目录下的子文件夹,并排序,保证映射关系固定
    for name in sorted(os.listdir(os.path.join(root))):
        if not os.path.isdir(os.path.join(root,name)):
            continue
        #给每个类别编码一个数字
        name2label[name]=len(name2label.keys())
        ...
15.2.2 创建样本和标签表格

编码表确定后,我们需要根据实际数据的存储方式获得每个样本的存储路径以及它的标签数字,分别表示为 images 和 labels 两个 List 对象。其中 images List 存储了每个样本的路径字符串,labels List 存储了样本的类别数字,两者长度一致,且对应位置的元素相互关联。

我们将 images 和 labels 信息存储在 csv 格式的文件中,其中 csv 文件格式是一种以逗号符号分隔数据的纯文本文件格式,可以使用记事本或者 MS Excel 软件打开。通过将所有样本信息存储在一个 csv 文件中有诸多好处,比如可以直接进行数据集的划分,可以随机采样 Batch 等。csv 文件中可以保存数据集所有样本的信息,也可以根据训练集、验证集和测试集分别创建 3 个 csv 文件。最终产生的 csv 文件内容如图 15.3 所示,每行的第一个元素保存了当前样本的存储路径,第二个元素保存了样本的类别数字
在这里插入图片描述
csv 文件创建过程为:遍历 pokemon 根目录下的所有图片,记录图片的路径,并根据编码表获得其编码数字,作为一行写入到 csv 文件中,代码如下:

def load_csv(root,filename,name2label):
    #从csv文件返回images,labels列表
    if not os.path.exists(os.path.join(root,filename)):
        #如果csv文件不存在
        images=[]
        for name in name2label.keys():#遍历所有子目录,获得所有的图片
            #只考虑后缀为png,jpg,jpeg的图片:'pokemon\\mewtwo\\00001.png'
            images+=glob.glob(os.path.join(root,name,'*.png'))
            images+=glob.glob(os.path.join(root,name,'*.jpg'))
            images+=glob.glob(os.path.join(root,name,'*.jpeg'))
        #打印数据集信息:1167,'pokemon\\builbasur\\0000000.png'
        print(len(images),images)
        random.shuffle(images)#随机打散顺序
        #创建csv文件,并存储图片路径及其label信息
        with open(os.path.join(root,filename),model='w',newline='')as f:
            writer=csv.writer(f)
            for img in images:# 'pokemon\\bulbasaur\\00000000.png'
                name=img.split(os.sep)[-2]
                label=name2label[name]
                # 'pokemon\\bulbasaur\\00000000.png'
                writer.writerow([img,label])
            print('written into csv file:',filename)

创建完 csv 文件后,下一次只需要从 csv 文件中读取样本路径和标签信息即可,而不需要每次都生成 csv 文件,提高计算效率,代码如下:

def load_csv(root,filename,name2label):
    # 此时已经有csv文件在文件系统上,直接读取
    images,labels=[],[]
    with open(os.path.join(root,filename)) as f:
        reader=csv.reader(f)
        for row in reader:
            img,label=row
            label=int(label)
            images.append(img)
            labels.append(label)
    #返回图片路径list和标签list
    return images,labels
15.2.3 数据集划分

数据集的划分需要根据实际情况来灵活调整划分比率。当数据集样本数较多时,可以选择 80%-10%-10%的比例分配给训练集、验证集和测试集;当样本数量较少时,如这里的宝可梦数据集图片总数仅 1000 张左右,如果验证集和测试集比例只有 10%,则其图片数量约为 100 张,因此验证准确率和测试准确率可能波动较大。对于小型的数据集,尽管样本数量较小,但还是需要适当增加验证集和测试集的比例,以保证获得准确的测试结果。这里我们将验证集和测试集比例均设置为 20%即有约 200 张图片用作验证和测试

首先调用 load_csv 函数加载 images 和 labels 列表,根据当前模式参数 mode 加载对应部分的图片和标签。具体地,如果模式参数为 train,则分别取 images 和 labels 的前 60%数据作为训练集;如果模式参数为 val,则分别取 images 和 labels 的 60%到 80%区域数据作为验证集;如果模式参数为 test,则分别取 images 和 labels 的后 20%作为测试集。代码实现如下:

def load_pokemon(root,mode='train'):
    #读取label信息
    #[file1,file2],[3,1]
    images,labels=load_csv(root,'images.csv',name2label)
    #数据集划分
    if mode=='train': #60%
        images=images[:int(0.6*len(images))]
        labels=labels[:int(0.6*len(labels))]
    elif mode=='val':# 20%=60%->80%
        images = images[int(0.6 * len(images)):int(0.8*len(images))]
        labels = labels[int(0.6 * len(labels)):int(0.8*len(labels))]
    else: # 20%=80%->100%
        images = images[int(0.8 * len(images)):]
        labels = labels[int(0.8 * len(labels)):]

需要注意的是,每次运行时的数据集划分方案需固定,防止使用测试集的样本训练,导致模型泛化性能不准确。

15.3 宝可梦数据集实战

在介绍完自定义数据集的加载流程后,我们来实战宝可梦数据集的加载以及训练。

15.3.1 创建 Dataset 对象

首先通过 load_pokemon 函数返回 images、labels 和编码表信息,代码如下:

#加载pokemon数据集,指定加载训练集
#返回训练集的样本路径列表,标签数字列表和编码表字典
images,labels,table=load_pokemon('pokemon','train')
print('images',len(images),images)
print('labels',len(labels),labels)
print('table',table)

构建 Dataset 对象,并完成数据集的随机打散、预处理和批量化操作,代码如下:

# images:string path
# labels:number
db=tf.data.Dataset.from_tensor_slices((images,labels))
db=db.shuffle(1000).map(preprocess).batch(32)

我们在使用 tf.data.Dataset.from_tensor_slices 构建数据集时传入的参数是 images 和labels 组成的 tuple,因此在对 db 对象迭代时,返回的是(𝑿𝑖, 𝒀𝑖)的 tuple 对象,其中𝑿𝑖是第𝑖个 Batch 的图片张量数据,𝒀𝑖是第𝑖个 Batch 的图片标签数据。我们可以通过 TensorBoard可视化来查看每次遍历的图片样本,代码如下:

#创建TensorBoard summary对象
writer=tf.summary.create_file_writer('logs')
for step,(x,y) in enumerate(db):
    # x:[32,224,224,3]
    # y:[32]
    with writer.as_default():
        x=denormalize(x)#反向normalize,方便可视化
        #写入图片数据
        tf.summary.image('img',x,step=step,max_outputs=9)
        time.sleep(5)#延迟5秒,再次循环
15.3.2 数据预处理

上面我们在构建数据集时通过调用.map(preprocess)函数来完成数据的预处理工作。由于目前我们的 images 列表只是保存了所有图片的路径信息,而不是图片的内容张量,因此需要在预处理函数中完成图片的读取以及张量转换等工作。

对于预处理函数(x,y) = preprocess(x,y),它的传入参数需要和创建 Dataset 时给的参数的格式保存一致,返回参数也需要和传入参数的格式保存一致。特别地,我们在构建数据集时传入(𝒙, 𝒚)的 tuple 对象,其中𝒙为所有图片的路径列表𝒚为所有图片的标签数字列表。考虑到 map 函数的位置为 db = db.shuffle(1000).map(preprocess).batch(32),那么preprocess 的传入参数为(𝑥𝑖, 𝑦𝑖),其中𝑥𝑖和𝑦𝑖分别为第𝑖个图片的路径字符串和标签数字。如果 map 函数的位置为 db = db.shuffle(1000).batch(32) .map(preprocess),那么 preprocess 的传入参数为(𝒙𝑖, 𝒚𝑖),其中𝒙𝑖和𝒚𝑖分别为第𝑖个 Batch 的路径和标签列表。代码如下:

#预处理函数
def preprocess(x,y):
    # x:图片的路径,y:图片的数字编码
    x=tf.io.read_file(x)#根据路径读取图片
    x=tf.image.decode_jpeg(x,channels=3)#图片解码,忽略透明通道
    x=tf.image.resize(x,[244,244])#图片缩放为略大于224的244

    #数据增强,这里可以自由组合增强手段
    # x=tf.image.random_flip_up_down(x)
    x=tf.image.random_flip_left_right(x)#左右镜像
    x=tf.image.random_crop(x,[244,244,3])#随机裁剪为224
    #转换成张量,并压缩到0~1区间
    # x:[0,255]=> 0~1
    x=tf.cast(x,dtype=tf.float32)/255.
    x=normalize(x)#标准化
    y=tf.convert_to_tensor(y)#转换成张量

    return x,y

考虑到我们的数据集规模非常小,为了防止过拟合,我们做了少量的数据增强变换,以获得更多样式的图片数据。最后我们将 0~255 范围的像素值缩放到 0~1 范围,并通过标准化函数 normalize 实现数据的标准化运算,将像素映射为 0 周围分布,有利于网络的优化。最后将数据转换为张量数据返回。此时对 db 对象迭代时返回的数据将是批量形式的图片张量数据和标签张量。

标准化后的数据适合网络的训练及预测,但是在进行可视化时,需要将数据映射回0~1 的范围。实现标准化和标准化的逆过程如下:

img_mean=tf.constant([0.485,0.456,0.406])
img_std=tf.constant([0.229,0.224,0.225])
def normalize(x,mean=img_mean,std=img_std):
    # 标准化函数
    # x: [224, 224, 3]
    # mean: [224, 224, 3], std: [3]
    x=(x-mean)/std
    return x

def denormalize(x,mean=img_mean,std=img_std):
    # 标准化的逆过程函数
    x=x*std+mean
    return x

使用上述方法,分布创建训练集、验证集和测试集的 Dataset 对象。一般来说,验证集和测试集并不直接参与网络参数的优化,并不需要随机打散样本次序。代码如下:

batchsz=128
#创建训练集Dataset对象
images,labels,table=load_pokemon('pokemon',mode='train')
db_train=tf.data.Dataset.from_tensor_slices((images,labels))
db_train=db_train.shuffle(1000).map(preprocess).batch(batchsz)
#创建验证集Dataset对象
images2,labels2,table=load_pokemon('pokemon',mode='val')
db_val=tf.data.Dataset.from_tensor_slices((images2,labels2))
db_val=db_val.map(preprocess).batch(batchsz)
#创建测试集对象
images3,labels3,table=load_pokemon('pokemon',mode='test')
db_test=tf.data.Dataset.from_tensor_slices((images3,labels3))
db_test=db_val.map(preprocess).batch(batchsz)
15.3.3 创建模型

前面已经介绍并实现了 VGG13 和 ResNet18 等主流网络模型,这里我们就不再赘述模型的具体实现细节。在 keras.applications 模块中实现了常用的网络模型,如 VGG 系列、ResNet 系列、DenseNet 系列、MobileNet 系列等等,只需要一行代码即可创建这些模型网络。例如:

#加载DenseNet网络模型,并去掉最后一层全连接层,最后一个池化层设置为max pooling
net=keras.applications.DenseNet121(include_top=False,pooling='max')
#设置为True,即DenseNet部分的参数也参与优化更新
net.trainable=True
newnet=keras.Sequential([
    net,#去掉最后一层的DenseNet121
    layers.Dense(1024,actibation='relu')#追加全连接层
    layers.BatchNormalization(),#追加BN层
    layers.Dropout(rate=0.5),#追加Dropout层,防止过拟合
    layers.Dense(5)#根据宝可梦数据的类别数,设置最后一层输出节点为5
])
newnet.build(input_shape=(4,224,224,3))
newnet.summary()

上面使用 DenseNet121 模型来创建网络,由于 DenseNet121 的最后一层输出节点设计为1000,我们将 DenseNet121 去掉最后一层,并根据自定义数据集的类别数,添加一个输出节点数为 5 的全连接层,通过 Sequential 容器重新包裹成新的网络模型。其中include_top=False 表明去掉最后的全连接层,pooling='max'表示 DenseNet121 最后一个Pooling 层设计为 Max Polling。网络模型结构图 15.4 所示。
在这里插入图片描述

15.3.4 网络训练与测试

我们直接使用 Keras 提供的 Compile&Fit 方式装配并训练网络,优化器采用最常用的Adam优化器,误差函数采用交叉熵损失函数,并设置 from_logits=True,在训练过程中关注的测量指标为准确率。网络模型装配代码如下:

# 装配模型
newnet.compile(optimizer=optimizers.Adam(lr=1e-3),
loss=losses.CategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])

通过 fit 函数在训练集上面训练模型,每迭代一个 Epoch 测试一次验证集,最大训练Epoch 数为 100,为了防止过拟合,我们采用了 Early Stopping 技术,在 fit 函数的 callbacks参数中传入 Early Stopping 类实例。代码如下:

# 训练模型,支持 early stopping
history = newnet.fit(db_train, validation_data=db_val, validation_freq=1, epochs=100,callbacks=[early_stopping])

其中 early_stopping 为标准的 EarlyStopping 类,它监听的指标是验证集准确率,如果连续三次验证集的测量结果没有提升 0.001,则触发 EarlyStopping 条件,训练结束。代码如下:

# 创建 Early Stopping 类,连续 3 次不上升则终止训练
early_stopping = EarlyStopping(
monitor='val_accuracy',
min_delta=0.001,
patience=3
)

我们将训练过程中的训练准确率、验证准确率以及最后测试集上面获得的准确率绘制为曲线,如图 15.5 所示。可以看到,训练准确率迅速提升并维持在较高状态,但是验证准确率比较差,同时并没有获得较大提升,Early Stopping 条件粗发,训练很快终止,网络出现了非常严重的过拟合现象。
在这里插入图片描述
那么为什么会出现过拟合现象呢?考虑我们使用的 DensetNet121 模型的层数达到了121 层,参数量达到了 7M 个,是比较大型的网络模型,而我们大数据集仅有约 1000 个样本。根据经验,这远远不足以训练好如此大规模的网络模型,极其容易出现过拟合现象。为了减轻过拟合,可以采用层数更浅、参数量更少的网络模型,或者添加正则化项,甚至增加数据集的规模等。除了这些方式以外,另外一种行之有效的方式就是迁移学习技术

15.4 迁移学习
15.4.1 迁移学习原理

迁移学习(Transfer Learning)是机器学习的一个研究方向,主要研究如何将任务 A 上面学习到的知识迁移到任务 B 上,以提高在任务 B 上的泛化性能。例如任务 A 为猫狗分类问题,需要训练一个分类器能够较好的分辨猫和狗的样本图片,任务 B 为牛羊分类问题。可以发现,任务 A 和任务 B 存在大量的共享知识,比如这些动物都可以从毛发、体型、形态、发色等方面进行辨别。因此在任务 A 训练获得的分类器已经掌握了这部份知识,在训练任务 B 的分类器时,可以不从零开始训练,而是在任务 A 上获得的知识的基础上面进行训练或微调(Fine-tuning),这和“站在巨人的肩膀上”思想非常类似。通过迁移任务 A 上学习的知识,在任务 B 上训练分类器可以使用更少的样本和更少的训练代价,并且获得不错的泛化能力

我们介绍一种比较简单,但是非常常用的迁移学习方法:网络微调技术。对于卷积神经网络,一般认为它能够逐层提取特征,越末层的网络的抽象特征提取能力越强,输出层一般使用与类别数相同输出节点的全连接层,作为分类网络的概率分布预测。对于相似的任务 A 和 B,如果它们的特征提取方法是相近的,则网络的前面数层可以重用,网络后面的数层可以根据具体的任务设定从零开始训练。

如图 15.6 所示,左边的网络在任务 A 上面训练,学习到任务 A 的知识,迁移到任务B 时,可以重用网络模型的前面数层的参数,并将后面数层替换为新的网络,并从零开始训练。我们把在任务 A 上面训练好的模型叫做预训练模型,对于图片分类来说,在ImageNet 数据集上面预训练的模型是一个较好的选择。
在这里插入图片描述

15.4.2 迁移学习实战

我们在 DenseNet121 的基础上,使用在 ImageNet 数据集上预训练好的模型参数初始化DenseNet121 网络,并去除最后一个全连接层,追加新的分类子网络,最后一层的输出节点数设置为 5。代码如下:

# 加载 DenseNet 网络模型,并去掉最后一层全连接层,最后一个池化层设置为 max pooling
# 并使用预训练的参数初始化
net = keras.applications.DenseNet121(weights='imagenet', include_top=False,pooling='max')
# 设计为不参与优化,即 DenseNet 这部分参数固定不动
net.trainable = False
newnet = keras.Sequential([
    net, # 去掉最后一层的 DenseNet121
    layers.Dense(1024, activation='relu'), # 追加全连接层
    layers.BatchNormalization(), # 追加 BN 层
    layers.Dropout(rate=0.5), # 追加 Dropout 层,防止过拟合
    layers.Dense(5) # 根据宝可梦数据的任务,设置最后一层输出节点数为 5
])
newnet.build(input_shape=(4,224,224,3))
newnet.summary()

上述代码在创建 DenseNet121 时,通过设置 weights='imagenet'参数可以返回预训练的DenseNet121 模型对象,并通过 Sequential 容器将重用的网络层与新的子分类网络重新封装为一个新模型 newnet。在微调阶段,可以通过设置 net.trainable = False 来固定 DenseNet121部分网络的参数,即 DenseNet121 部分网络不需要更新参数,从而只需要训练新添加的子分类网络部分,大大减少了实际参与训练的参数量。当然也可以通过设置 net.trainable =True,像正常的网络一样训练全部参数量。即使如此,由于重用部分网络已经学习到良好的参数状态,网络依然可以快速收敛到较好性能。

基于预训练的 DenseNet121 网络模型,我们将训练准确率、验证准确率和测试准确率绘制为曲线图,如图 15.7 所示。和从零开始训练相比,借助于迁移学习,网络只需要少量样本即可训练到较好的性能,提升十分显著。
在这里插入图片描述

未完待续!
参考文献

1.https://www.pyimagesearch.com/2018/04/16/keras-and-convolutional-neural-networks-cnns/

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

安替-AnTi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值