深度视觉应用-构建自己的深度视觉项目

在之前的课程中,我们对现代分类网络进行了较为全面的讲解:我们解读了·AlexNet,VGG16、NiN、GoogLeNet以及ResNet的原始论文,并对各个网络都进行了完整的代码复现,现在我们已经了解这些网络的构建思路和具体代码,可以开始应用他们了。从本节开始,我们开始建立我们自己的卷积神经网络架构,这是将之前学习的内容应用到实际架构上的过程,也是构筑自己的计算机视觉项目的过程。过去的大部分时间中,我们一直围绕着算法和架构本身在进行讨论,这的确是整个卷积神经网络应用过程中最为关键的部分。然而,在将经典架构落地使用的过程中,数据和算力会带来重大影响,同时深度卷积网络的训练过程和训练技巧也非常关键(在GoogLeNet和ResNet的论文详解后,相信你也已经意识到这一点)。不难理解,数据质量和数量会很大程度上影响我们的建模过程,算力则会对我们能够实现的训练程度进行限制,但不止如此,在训练过程中还存在大量初学者很难想象到的困难。

在这一章中,我们将学习如何构筑自己的计算机视觉项目,包括如何处理数据、如何利用好计算资源、如何进行训练、最终如何产出优质的结果。我们可以将这一结果用于论文、汇报、或自我学习。在这堂课的结尾,我们还会提供数个深度视觉方向的案例,作为算法落地实践的具体例子。经过这一堂课,你将能够自由调用任意数据、任意卷积架构,实现较为恰当的训练并最终落地成自己的深度视觉案例。

在开始建立自己的深度视觉项目之前,你首先要明确自己希望算法执行的具体任务。在传统机器学习中,我们常常会区分有监督、无监督、分类、回归、聚类等等这些任务类别,因为不同的任务会指向不同形式的标签、不同的评估指标、不同的损失函数,因此会影响我们的训练和建模流程。在深度学习中也是一样。在深度视觉中,除了区分经典的“回归、分类”之外,我们还需要区分众多的、视觉应用类别。比如说,深度视觉中有着“图像识别”这样输入图像、输出类别的任务,也有“风格迁移”这样输入图像、输出图像的任务,还有“图像理解”这种输入图像、输出文字的任务。最基本地来说,如果只考虑对图像的内容进行判断情况,我们至少也有识别(recognition)、检测(detection)、分割(segmentation)三种最基本的任务。

在这里插入图片描述

以上图为例,识别任务是以图像中的单一对象为核心、采集信息并做出判断的任务,任何超出单一对象的任务都不是单纯的图像识别。因为是对单个对象进行操作,用于图像识别的数据集往往都比较规整、比较简单,被识别物体基本都轮廓完整、拍摄清晰、是图像中最容易被人眼注意到的对象。也因此,图像识别适用于像机场人脸识别这样简单的应用场景。

检测任务和分割任务是针对图像中多个或单个对象进行判断的任务,因此分割和检测任务所使用的图像往往要复杂很多。在检测任务中,我们首先要使用边界框(bounding box,简写为bbox)对图像中的多个对象所在的位置进行判断,在一个边界框容纳一个对象的前提下,再对边界框中每一个单一对象进行图像识别。因此,检测任务的标签有两个:(1)以坐标方式确定的边界框的位置,(2)每个边界框中的物体的属性。检测任务的训练流程也分两步:第一步是定位,第二步是判断,检测任务的训练数据也都是带有边框的。我们可以只使用其中一个标签来进行训练,但在检测任务中同时训练两个标签才是更常见的情况。检测任务比较适用于大规模动态影像的识别,比如识别道路车辆、识别景区人群等。检测也是现在实际落地应用最为广泛的视觉任务。

在这里插入图片描述

分割任务是像素级别的密集任务,需要对图像中的每个像素进行分类,因此不需要定义边界框就能够找到每个对象的“精确边界”。分割任务的标签往往也只有一个,通常就是对象的定义或性质(比如,这个像素是猫,这个像素是蓝天),但是标签中的类别会非常多,对于复杂的图像,标签类别可能成百上千。分割任务是现在图像领域对“理解图像”探索的前沿部分,许多具体的困难还未解决,同时,在许多实际应用场景中并不需要分割任务这样“精确”的判断,因此分割的实际落地场景并不如检测来得多。比较知名的实际落地场景是美颜相机、抖音换脸特效等。

三种任务在输出的结果及标签上有所不同,但它们可以一定程度上共享训练数据,这主要是因为图像中的“对象”概念是可以人为定义的。用于图像识别的训练数据只要有适当的标签,也可以被用于检测和分割。例如,对于只含有一张人脸的图像而言,我们可以执行普通的图像识别,单张脸的“人脸检测”,或一张人脸上不同五官的“特征检测”(landmark detection),只要训练数据中存在边界框,那标签中就可以进行检测。相对的,用于检测和分割的数据如果含有大量的对象,可以被标注为“人群”这样的标签,来进行识别(不过,用于检测、分割的数据拥有可以作为识别数据的标签的情况非常少,因为检测分割数据集往往都是多对象的)。

在这里插入图片描述

在单个任务之中,我们也可能遭遇不同的“标签”。例如,对人脸数据,我们可以进行“属性识别”(attribute recognition)、“个体识别”(identity recognition)、“情绪识别"(emotion recoginition)等等不同的任务,对于同一张图像,我们的识别结果可能完全不同:

在这里插入图片描述

在情绪识别中,我们只拥有“情绪”这一个标签,但标签类别中包含不同的情绪。在属性识别中,我们可以执行属性有限的多分类任务,也可以让每一种属性都可以是一个单独的标签,针对一个标签来完成二分类任务。而个体识别则是经典的人脸识别,在CelebA数据集中我们使用人名作为标签来进行判断。

同样的,在场景识别、物体识别数据集下(比如大规模场景识别数据LSUN),我们可能无法使用全部的数据集,因为全数据集可能会非常巨大并且包含许多我们不需要的信息。在这种情况下,标签可能是分层的,例如,场景可能分为室内和室外两种,而室内又分为卧室、客厅、厨房,室外则分为自然风光、教堂、其他建筑等,在这种情况下,室内和室外就是“上层标签”,具体的房间或景色则被认为是“下层标
签”。我们通常会选择某个下层标签下的数据集进行学习,例如,在LSUN中选择“教堂”或“卧室”标签来进行学习:

在这里插入图片描述

对检测任务,我们也可能会检测不同的对象,例如,检测车牌号和检测车辆就是完全不同的标签,我们也需要从可以选择的标签中进行挑选。对于分割,则有更多的选项,我们可以执行将不同性质的物体分割开来的“语义分割”,也可以执行将每个独立对象都分割开来的“实例分割”,还可以执行使用多边形或颜色进行分割的分割方法。同时,根据分割的“细致程度”,还可以分为粗粒度分割(Coarse)和细粒度分割(Fine-grained),具体的分割程度由训练图像而定:

在这里插入图片描述

在这里插入图片描述

因此,在图像数据集的读取过程中,你可能发现一个图像数据集中会带有很多个指向不同任务的标签、甚至很多个指向不同任务的训练集。遗憾的是,数据集本身并不会向你说明数据集所指向的任务,因此你必须从中辨别出你需要的部分。torchvision.datasets模块中自带的CelebA人脸数据集就是这种情况。如下图所示,类datasets.CelebA下没有任何文字说明,只有一个指向数据源的链接,光看PyTorch
官方页面你并不能判断这个数据集是什么样的数据集。不过,这个类有参数“target_type”,这个参数可以控制标签的类型,四种标签类型分别是属性(attr)、个体(identity)、边界框(bbox)和特征(landmarks)。根据参数说明,可以看出属性是40个二分类标签,你可以从中选择一个进行分类,个体是判断这个人脸是哪个具体人物,这两个标签指向的是识别任务。边界框和特征都以坐标形式表示,这两个标签则指向检测任务。

在这里插入图片描述

同样情况的还有Cityscapes,这个数据集的类datasets.Cityscapes下也没有任何说明,但它含有参数mode和target_type,其中mode有两个模式:fine以及coarse,这两个模式表示Cityscape是一个用于分割任务的数据集。同时,target_type下面还有instance(实例分割)、semantic(语义分割)、polygon(多边形分割)、color(颜色分割)四种选项,你必须清晰了解自己的需求以及每种分割的含义,你才可能正确填写这些参数。同时,我们可以一次性训练导入多个标签进行使用,如何混用这些标签来达成训练目标也是难点之一。

在这里插入图片描述

再看看LSUN数据集中,需要自己填写标签名称的情况:

在这里插入图片描述

现在我们知道不同视觉任务所对应的数据有多大区别了。图像数据丰富多样,在具体任务不明确的情况下,我们就连最基本的数据导入都会存在问题。基于对图像任务的理解,我将本课内容分为上、下两部分,其中上以图像识别任务为主,下以视觉任务重的其他任务为主。在每一部分中,我们会先打下与该任务相关的数据、算力、训练流程等关键基础,然后再学习具体的案例和代码。参考课程目录,当你抱有不同的学习目标,你可以使用不同的方式学习本章课程。如果你没有立即建立项目、完成任务的需求,我会建议你完整地学完全部内容,这能够为你日后继续在图像方面深入打下坚实的基础。如果你现在有着立刻完成某个项目的需求、并且手上握有自己的数据,那你可以跳过本课程中部分内容。

一 数据

数据是一切机器学习项目的根基。当我们想建立一个项目时,首先考虑的第一个问题就是:数据从哪里来?如果你是带着项目在学习这段课程,那你可能已经从导师或公司团队那里拿到了相应的数据,那对你来说最难的部分可能是如何将你的数据放入卷积网络,你可以直接学习《一、2 使用自己的数据/图片创造数据集》。但如果你没有现成的数据在手,你就需要使用深度学习框架中配置好的数据集。对初学者、尤其是只了解深度视觉、不太了解传统视觉方法的人们来说,自己收集和创造数据基本是不太可能的,因此使用现成的数据集是成本更低廉的方法。不过,调用内置数据集可不是一件容易的事。现在,让我们来看看调用经典数据集中的那些问题。

1 认识经典数据

在入门级课程时,我们都会学习MNIST和Fashion-MNIST这两个数据集,他们都是灰度图像的数据集,并且都有10个标签类别,每个标签类别分别对应了图像上的物品/数字是什么。

在这里插入图片描述

这两个数据集代表了最传统、最简单的图像预测问题中所使用的数据,他们足以应对图像领域最前沿的任何分类模型,例如残差网络。当我们的学习越来越深入,我们自然会渴望使用更有挑战性的数据集,例如我们之前已经介绍过的ImageNet,或者许多人可能都听说过的COCO、CIFAR等数据集。然而,即便是对前沿模型掌握熟练的初学者,也无法轻易将Fashion-MINST数据集上的操作推广至其他数据集。
例如,在PyTorch中,如果可能的话,我们都会从torchvision.datasets模块下调用图像数据集。在Lesson 11中,我们使用以下代码成功调用了Fashion-MNIST数据集。你可以试着将这段代码中使用的类更换为其他数据集,就会开始无穷无尽的报错之旅。

import torchvision
import torchvision.transforms as transforms
mnist = torchvision.datasets.FashionMNIST(root='...\FashionMNIST'
                                         ,train=False                   
                                         ,download=True
                                         ,transform=transforms.ToTensor())

不同于传统机器学习中广泛使用的表格数据,图像数据集在格式、标签、内容上的丰富程度异常地高,大部分图像数据集无法用同一个API调用,这就是说许多情况下我们不能使用同样的代码加载不同的数据集。同样,图像数据集可能会存在文件太大、占用内存太多、普通用户无法调用的问题。例如,ImageNet是一个巨大的数据集,我们能够获取到的开源部分比原始数据集小很多,然而这个开源的ImageNet子集的大小也有155G。大部分经典图像数据集的大小都超过10个G,大大超出大部分个人电脑的运存。另外,CNN对于图像数据也有一些要求,例如,被识别对象往往需要在图像中心,图像要清晰、要具体,不能容纳太复杂的信息,还需要被标上具体的标签,因此图像数据集的生产成本往往很高。现在我们能够接触到的大部分图像或视频数据集,都是研究机构或商业机构出于研究目的自制的数据集,大部分都有版权限制和使用限制。几乎所有数据集都被要求只能使用于学术场景,许多数据集需要注册、申请才能够使用,许多数据集甚至完全不开源。这又进一步缩小了我们可以调用的图像数据集的范围。即便存在这些重重困难,PyTorch还是将许多数据集归纳在torchvision模块的datasets模块下,试图使用相似的API对其进行调用,但每个API上复杂的参数和参数的说明又让许多初学者望而却步。可见,初学者即便知道一些经典图像数据集的名称,也无法轻易实现对数据集的调用。

为了解决这个问题,我对PyTorch中内置的数据集进行了梳理,并将调用这些数据集所需的基础知识都包含在这一节当中,尽可能地赋予大家自由调用数据集的能力。遗憾的是,考虑到课时限制与学习效率,我们无法在课程中对所有类都进行讲解,因此要想灵活使用图像数据集,英文阅读能力/谷歌翻译插件是必不可少的,否则你将会陷入无边无际的报错当中。幸运的是,我们还是可以一定程度上将数据集进行归类讲解。只要认识了这些数据的名称,并知道去哪里获取他们,我们就可以一定程度上实现对他们的调用。我们先来认识一下这些数据集吧。

1.1 入门数据:MNIST、其他数字与字母识别

第一部分要介绍的是最适合用于教学和实验、几乎对所有的电脑都无负担的MNIST一族。MNIST一族是数字和字母识别的最基本的数据集,这些数据几乎全都是小尺寸图像的简单识别,可以被轻松放入任意神经网络中进行训练。具体如下:

数据名称数据说明
FashionMNIST衣物用品数据集
MNIST手写数字数据集
KuzushijiMNIST日语手写平假名识别,包含48个平假名字符和一个平假名迭代标记,一个高度不平衡的数据集
QMNIST与MNIST高度相似的手写数字数据集
EMNIST与MNIST高度相似,在MNIST的基础上拓展的手写数字数据集
Omniglot全语种手写字母数据集,包含来自50个不同字母的1623个不同的手写字符,专用于“一次性学习"
USPS另一个体系的手写数字数据集,常用来与MNIST对比
SVHN实拍街景数字数据集(Street View House Number),是数字识别和检测中非常不同的一个数据集。有原始尺寸数据集可以下载,但在PyTorch中内置的是32x32的识别数据。注意,使用本数据需要SciPy模块的支持。

在这里插入图片描述

在这里插入图片描述

这些数据集都很简单,那什么时候使用他们呢?在深度视觉的研究中,我们很少专门就MNIST进行研究,但我们在这些简单识别数据集上设置了其他值得研究的问题。比如,在我们撰写论文或检验自己的架构时,MNIST一族是很好的基准线——他们尺寸很小,容易训练,很简单却又没有那么“简单”。一流的架构往往能够在MNIST数据集上取得99%以上的高分,而发表论文时,MNIST数据集的结果低于97%是不能接受的。单一机器学习算法能够在Fashion-MNIST数据集上取得的分数基本都在90%左右,而一流的深度学习架构至少需要达到95%以上的水准。再比如,我们常常使用平假名识别的数据集来研究深度学习中的样本不平衡问题,我们还使用Omniglot数据集来研究人脸识别(主要是个体识别 identityrecognition)中常见的“一次性学习”问题(one-shot learning)。我们来重点讲讲这个“一次性学习”的问题。

在这里插入图片描述

在这里插入图片描述

在人脸识别中,我们有两种识别策略:第一种策略是以人名为标签进行多分类,在训练样本中包含大量的同一个人的照片,测试集中也包含这个人的照片,看CNN能否正确预测出这个人的名字;而第二种策略则是一种二分类策略,在训练样本中给与算法两张照片,通过计算距离或计算某种相似性,来判断两张照片是否是同一个人,输出的标签为“是/否相似或一致”,在这种策略中,测试集的样本也是两张照片,并且测试集的样本不需要出现在训练集中。

如果基于第一种策略来执行人脸识别,则机场、火车站的人脸识别算法必须把全国人民的人脸数据都学习一遍才可能进行正确的判断。而在第二种策略中,算法只需要采集身份证/护照上的照片信息,再把它与摄像头中拍摄到的影像进行对比,就可以进行人脸识别了。这种“看图A,判断图B上的人是否与图A上的人是同一人”的学习方法,就叫做一次性学习,因为对于单一样本,算法仅仅见过一张图A而已。不难想象,实际落地的人脸识别项目都是基于一次性学习完成的。Omniglot数据集就是专门训练一次性学习的数据集。从上图可以看出,Omniglot数据集中的字母/符号对我们而言是完全陌生的,因此我们并无法判断出算法是否执行了正确的“识别”结果。而再Omniglot数据集上,算法是通过学习图像与图像之间的相似性来判断两个符号是否是一致的符号,至于这个符号是什么,代表什么含义,对Omniglot数据集来说并无意义。

字母和数字识别的数据集的尺寸都较小,因此PyTorch对以上每个数据集都提供了下载接口,因此我们无需自行下载数据,就可以使用torchvision.datasets.xxxx的方式来对他们进行调用。在网速没有太大问题的情况下,只要将download设置为True,并确定VPN是关闭状态,就可以顺利下载。注意,下载之后最好将download参数设置为False,否则只要调用目录写错,就会重新进行下载,费时也费流量。
在课程中,我已给大家下载好以下三个文件(其中Fashion-MNIST是之前就下载过的),大家可以将文件放置到自己的目录下,将root修改为文件夹所在目录后来进行运行。我的根目录如下所示:

在这里插入图片描述

你可以查看相应的文件夹,你会发现FashionMNIST中的文件是gz文件加压后的pt文件,omniglot-py的文件是zip文件解压后的png图片,SVHN中是matlab生成的mat文件,但这些文件都可以统一使用torchvision.datasets来读取。

基于这个目录,我们来运行以下代码:

#在频繁调用数据的过程中,可能出现环境问题,导致jupyter整个崩溃刷新
#为解决/避免该问题,对环境进行了部分修改
import os
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"
import torchvision
import torchvision.transforms as transforms

fmnist = torchvision.datasets.FashionMNIST(root ='I:\F盘 + 代码\F盘datasets'
                                         ,train =True #根据类的不同,参数可能发生变化
                                         ,download =False #未下载则设置为True
                                         ,transform =transforms.ToTensor())
svhn = torchvision.datasets.SVHN(root ='I:\F盘 + 代码\F盘datasets\SVHN'
                                 ,split ="train"
                                 ,download = False
                                 ,transform = transforms.ToTensor())
omnist = torchvision.datasets.Omniglot(root ='I:\F盘 + 代码\F盘datasets'
                                       ,background = True
                                       ,download = False
                                       ,transform = transforms.ToTensor())
#如返回结果所示,除了样本量之外什么都无法看见
omnist
fmnist
svhn
#之前我们使用.data的方式查看特征,.target的方式查看标签,但如下所示,不同的数据集并不共享调用
的API
#这其实可以理解,当面临的任务不同时,每个数据集的标签排布方式和意义也都不同,因此不太可能使用相同
的API进行调用
for i in [fmnist,svhn,omnist]:
    print(i.data.shape)
for i in [fmnist,svhn,omnist]:
    print(i.targets.shape)
#如果你想查看每个数据集都可以调用哪些属性,必须要进入到数据集的源码进行查看
#如果不想读源码,也可以直接使用下面的方式进行简单的调用
#索引的方式调用单个样本
omnist[0]
len(omnist) #查看样本量
    
#报错概率最低的查看方式
for i in [fmnist,svhn,omnist]:
    for x,y in i:
        print(x.shape,y)
        break
#可视化
#实际上,在读图时如果不加ToTensor的预处理,很可能直接读出PIL文件
#PIL可以直接可视化
fmnist = torchvision.datasets.FashionMNIST(root ='I:\F盘 + 代码\F盘datasets'
                                         ,train =True #根据类的不同,参数可能发生变化
                                         ,download =False #未下载则设置为True
                                        # ,transform = transforms.ToTensor()
                                         )
fmnist[0]
fmnist[0][0] #尺寸较小,难以看清


#同时,当数据集很大时,我们希望最好只读取一遍,所以一般还是会加上ToTensor
#此时我们就需要自己编写可视化的函数
#使用numpy和matplotlib将图像可视化
import matplotlib.pyplot as plt
import numpy as np
import random
#让每个数据集随机显示5张图像
def plotsample(data):
    fig, axs = plt.subplots(1,5,figsize=(10,10)) #建立子图
    for i in range(5):
        num = random.randint(0,len(data)-1) #首先选取随机数,随机选取五次
        #抽取数据中对应的图像对象,make_grid函数可将任意格式的图像的通道数升为3,而不改变图像
原始的数据
        #而展示图像用的imshow函数最常见的输入格式也是3通道
        npimg = torchvision.utils.make_grid(data[num][0]).numpy()
        nplabel = data[num][1] #提取标签
        #将图像由(3, weight, height)转化为(weight, height, 3),并放入imshow函数中读取
        axs[i].imshow(np.transpose(npimg, (1, 2, 0))) 
        axs[i].set_title(nplabel) #给每个子图加上标签
        axs[i].axis("off") #消除每个子图的坐标轴
#可以自行修改plotsample函数,为可视化实现更高的自由度
plotsample(omnist)
plotsample(svhn)
plotsample(fmnist)

根据类的不同,参数train可能变化为split,还可能增加一些其他的参数,具体可以参考datasets页面。MNIST一组的数据几乎都可以被用于最简单的识别项目,是测试架构的最佳数据。在提出新架构或新方法时,学者们总是会在MNIST或Fashion-MNIST数据集上进行测试,并将在这些数据上拿到高分(>95%)作为新架构有效的证明之一。

1.2 竞赛数据:ImageNet、COCO、VOC、LSUN

除了数字和字母识别之外,最为人熟悉并令人瞩目的就是各大竞赛的主力数据了。之前在讲解大规模视觉挑战赛ILSVRC的时候,我们介绍过ImageNet数据集,和ImageNet数据一样,竞赛数据往往诞生于顶尖大学、顶尖科研机构或大型互联网公司的人工智能实验室,属于推动整个深度学习向前发展的数据集,因此这些数据集通常数据量巨大、涵盖类别广泛、标签异常丰富、可以被用于各类图像任务,并且每年会更新迭代、且在相关竞赛停止或关闭之后会下架数据集。作为计算机视觉的学习者,你可以没有用过这些数据,但你必须知道他们的名字和基本信息,如果你是计算机视觉工程师,那在你的每个项目上线之前,你都需要使用这些数据来进行测试。让我们来看看这些数据集:

在这里插入图片描述

在这里插入图片描述

各个数据集的样图如下所示:

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

这一类数据集最大的特点就是数据量多、原图尺寸很大,因此整个数据集所占用的存储空间也会更大。最小的VOC数据集也在3.6个G左右,其他都在20G以上。PyTorch只提供了VOC的下载通道,但这个下载通道极不稳定,因此我还是推荐大家提前下载好之后将数据放入根目录中进行读取。

竞赛数据都是来自于各个机构和大学的研究,因此其风格和调用流程不可能一致,使用每个数据都需要进行一定的探索、还必须具备一定的英文阅读/谷歌翻译能力/Github使用/Python脚本编程能力。在我们的课程中,我给大家准备了2012年的ImageNet数据集、VOC以及LSUN数据集中较小的2类数据(见课程资料),并且准备了可以运行来下载LSUN其他类别数据的Python脚本和readme文档。其中VOC适用于分割和检测任务,ImageNet和LSUN适用于分类任务。我的根目录如下所示:

在这里插入图片描述

如果你并不知道如何使用Python脚本文件,可以参考下面的代码。图中正在下载LSUN户外教堂类别,注意,执行此代码时VPN必须处于关闭状态。

在这里插入图片描述

#首先将cmd根目录切换到download.py所在的目录
#切换到同一个盘的不同目录
C:\Users\Admin>cd C:\dataset2\lsun-master
#切换到不同盘的目录
C:\Users\Admin>cd /d F:datasets2\lsun-master
#切换到相应目录后运行下载,其中church_outdoor是其中一个类别
F:datasets2\lsun-master>python download.py -c church_outdoor

值得一提的是,LSUN竞赛现已关闭,因此测试集已无法下载,但训练集和验证集还是可以下载。LSUN各个类别的数据集大小如下所示。在课程中,我给大家下载了户外教堂以及教室两个类别,可以用于分类。

在这里插入图片描述

LSUN数据集下载后是压缩文件,解压后是LMBD(Lightning Memory-Mapped Database)数据库的文件。在深度学习中,有许多大型图像数据集都是储存为LMBD文件,因为框架Caffe和TensorFlow在早期使用了大量储存为LMBD格式的数据集。从LMBD数据库中读取数据的代码并不复杂,但需要较多数据库和LMBD相关的基础知识,我在后续为大家提供了相关代码。幸运的是,LSUN的LMBD文件可以直接通过pytorch中datasets下的类来直接调用,具体代码如下:

#导入一个类别
data_train = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                       ,classes=["church_outdoor_train"]
                                       ,transform = transforms.ToTensor())
data_train
data_val = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                     ,classes=["church_outdoor_val"]
                                     ,transform = transforms.ToTensor())
data_val
data_val[0][0]
plotsample(data_val)
#只导入一个类别时,该类别是没有标签的
check_ = 0
for x,y in data_val:
    check_+=0
check_
#想要进行训练,至少得导入两个类别,进行二分类
data_train = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                       ,classes=
["church_outdoor_train","classroom_train"]
                                       ,transform = transforms.ToTensor())
data_train
data_val = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                     ,classes=
["church_outdoor_val","classroom_val"]
                                     ,transform = transforms.ToTensor())
data_val
#此时标签会自动标注为0和1
#循环代码别在data_train上运行,时间会爆炸
for x,y in data_val:
    print(y)
#每个类别的验证集的大小都是300,因此两个类别就是共600个样本
check_ = 0
for x,y in data_val:
    check_+=y
check_                                       

很遗憾的是,ImageNet数据集并不能使用pytorch中的datasets下的类来直接调用(能够被pytorch直接调用的都是tar.gz格式文件,或tar.gz解压后的文件),而VOC不能作为识别数据被使用,因此希望调用竞赛数据来完成分类任务则需要更多的技巧。在《一、2 使用自己的数据/图片创造数据集》中,我们将使用ImageNet和LSUN数据集来说明,如何将压缩文件/数据库文件中的图片导出为四维tensor。同时,VOC数据集的导入(识别和检测数据的导入)会在本课下半部分、讲解具体识别和检测任务的时候说明。

事实上,如果没有GPU计算资源的话,我不推荐大家使用ImageNet。虽然比起LSUN和VOC,ImageNet含有更丰富的数据,只要我们有足够的硬盘空间,我们都可以下载或导入它,但在没有较大GPU支持的情况下,我们很难对这个数据进行适当的训练(proper training)。在没有GPU时,个人电脑的显存大多只有4G,在有GPU的情况下,显存可以达到8G或16G,但这和150G、200G的数据集比起来都不算什么。要训练ImageNet,我们必须使用非常小的batch_size,但batch_size过小又会延长训练
完成一个epochs的时间。如果训练一个ImageNet需要20个小时,那我们就基本没有任何“学习体验”可言了。因此,如果我们要使用ImageNet数据集,我强烈建议大家使用Colab等线上平台的大型GPU。

1.3 景物、人脸、通用、其他

如果入门数据太简单,竞赛数据又太大该怎么办呢?难道就没有尺寸适中,又非常适合初学者练习和试验的数据集吗?当然有。除了竞赛数据和入门数据,我们还有不少通用的数据集,比如:

在这里插入图片描述

在这里插入图片描述

部分数据集的样例如下所示:
【CelebA】

在这里插入图片描述

【CIFAR10 & CIFAR100】

在这里插入图片描述

【STL-10】

在这里插入图片描述

【Cityscapes】

在这里插入图片描述

【Place365】

在这里插入图片描述

在课程中,我准备了celebA、CIFAR、sbu、sbd四种数据,在图像识别中我们比较常用的是CIFAR。以CIFAR为例,我们来调用一下这个数据集。我的根目录如下:

在这里插入图片描述

CIFAR文件夹内部为解压状态:

在这里插入图片描述

#import torch, torchvision
#import torchvision.transforms as transforms
#import matplotlib.pyplot as plt
#import numpy as np
#CIFAR10
#注意我的根目录到了哪一层
data = torchvision.datasets.CIFAR10(root = "I:\F盘 + 代码\F盘datasets3\cifar"
                                   ,train=True
                                   ,download=False
                                   ,transform = transforms.ToTensor())
#非常规整而且完整的数据集
#几乎就是彩图版MNIST

data
for x,y in data:
    print(x,y)
    break
data.data.shape
data.classes
np.unique(data.targets)
data_test = torchvision.datasets.CIFAR10(root = "I:\F盘 + 代码\F盘
datasets3\cifar"
                                   ,train=False
                                   ,download=False
                                   ,transform = transforms.ToTensor())
data_test
plotsample(data)
#CIFAR100
data100 = torchvision.datasets.CIFAR100(root = "I:\F盘 + 代码\F盘datasets3\cifar"
                                   ,train=True
                                   ,download=False
                                   ,transform = transforms.ToTensor())
data100
np.unique(data100.targets)
plotsample(data100)

SBU和SBD数据也可以使用torchvision.datasets进行读取,大家可以尝试自己读读看。不过注意他们都不能被用于图像识别任务。对于学习资料中没有提供的数据文件,我们可以去哪里找呢?首先,我并不推荐使用pytorch当中自带的download功能,考虑到图像数据集的尺寸都不小,并且pytorch官方的下载功能通常速度较慢(实际上,我消耗了3天时间才将只有3G的SBU数据集下载下来),还十分容易出现超时的问题。因此,能够不使用download参数就不使用download参数。

如下图所示,对于PyTorch中带有的数据集,我们可以从数据说明中找到这个数据集的官方网站或原始地址。进入该原始地址后,我们大部分时候都可以找到数据的下载渠道。当然,通过原始地址下载的数据很有可能不能使用torchvision.datasets来进行读取,但我们也不妨一试。当你下载好的数据无法被读取时,可以尝试更换目录、解压下载文件等方式,或许可以被读取成功。

在这里插入图片描述

在dataset、dataset2、dataset3和dataset4四个文件夹中,分别存在不同的便于下载的数据集。这些数据集都很巨大,你必须下载和使用的数据都在dataset4中,其他文件夹中的内容你可以按需下载。在下一节中,我们将仔细来说明如何读取已经存在的、下载好的数据文件。无论数据文件是什么格式,我们都能够采用一定的方法将其处理成四维张量格式,从而让数据能够被输入卷积神经网络当中。

2 使用自己的数据/图片创造数据集

如果你拥有自己的数据集,那首先要考虑的就是如何将你的数据集输入到PyTorch当中去。如果你的数据是来自于网络(比如说,从Kaggle下载,从论文作者处获得,从某个数据集官方的网站进行下载),那你遇见的原始数据格式可能是各种情况,最常见的是各类压缩文件、pt文件、数据库格式文件或者png/jpg/webp等原始图像。如果你的数据是来自于实验室、公司数据库、甚至是领导/导师给的数据,那你的数据大概率都是csv/txt/mat等结构的二维数据表。无论我们的原始数据集是呈现什么样的格式,我们必须将其转换为四维的张量,数据才可以被卷积神经网络处理。对于任意压缩文件,先解压查看内部是什么内容,对于其他格式文件,你可以根据你的需要查看本节中相应的小节。

2.1 从图像png/jpg到四维tensor

ImageFolder

当你拥有的数据是一系列图像,并且每个标签对应的图像是存放在单独的文件夹中,那你几乎遇见了最简单的情况。在torchvision中存在直接将文件夹中的图片打包成tensor的类ImageFolder,它的参数和torchvision.datasets中其他数据导入类的参数非常相似,其中root是你的原始图像所在的根目录,transform是你希望对图像执行的具体操作。

train_dataset = torchvision.datasets.ImageFolder(root="XXXX"                     
                            ,transform=torchvision.transforms.ToTensor()
                                       )

这个类可以接受 .jpg、.jpeg、.png、.ppm、.bmp、.pgm、.tif、.tiff、.webp这9种不同的图片格式作为输入,并且还能够通过文件夹的分类自动识别标签。举例说明,如果你的图片是被打包成如下所示的特定格式,那你的数据就适用于ImageFolder这个类:

在这里插入图片描述

在你的根目录下,每个类别需要有一个单独的文件夹。如上图所示,cat和dog就是该数据集的两种类别,而类别文件夹中可以存在多个子文件夹,或直接存放图片。图片的格式不需要统一,只要在ImageFolder可接受的9种格式中即可。让我们以celebA数据集中随机提取出的子集为例来实验一下ImageFolder代码。我的根目录如下,其中训练集和测试集是分开的两个文件夹,train和test文件夹中分别有两个类别female和male。

在这里插入图片描述

在这里插入图片描述

#训练集
train_dataset = torchvision.datasets.ImageFolder(root="I:\F盘 + 代码\F盘
datasets4\picturestotensor\Train",
                                       
transform=torchvision.transforms.ToTensor()
                                       )
train_dataset
for x,y in train_dataset:
    print(x,y)
    break
#几种可以调用的属性

train_dataset.classes
train_dataset.targets
#查看具体的图像地址
train_dataset.imgs
#随机查看5张图像
plotsample(train_dataset)
#测试集 - 注意更换根目录
test_dataset = torchvision.datasets.ImageFolder(root="I:\F盘 + 代码\F盘
datasets4\picturestotensor\Test",
                                       
transform=torchvision.transforms.ToTensor()
                                       )
test_dataset
plotsample(test_dataset)

注意,ImageFolder只能够读取根目录的子文件夹中的图片,并且一定会将子文件夹的名称作为类别。当根目录中只有一个子文件夹时,则对所有的图片标签都标注为0。当根目录中没有文件夹,而是直接存放图片时,则会直接报错。

毫无疑问,ImageFolder是一个省时省力的方式,但是简单也意味着它不够灵活(在编程的世界里总是如此),因此会让人非常怀疑它在现实数据集前能有多少作用。当图像相关的标签类别数量很少,我们能够很容易地将图像按照他们所在的标签类别进行打包,当数据的标签类别比较多,或者样本量比较大时,要将同一标签类别的样本分到不同类别的文件夹中就变得不再“省时省力”了。但幸运的是,在图像的世界里,许多数据集、尤其是巨大数据集是提前按照标签“分好”的,比如我们之前看过的LSUN数据集。

在这里插入图片描述

在这里插入图片描述

对类似于LSUN的数据集,我们会按照标签类别分别对数据进行下载,如果下载后获得的数据是这个类别下的图片文件,那毫无疑问这些文件是可以按照类别被储存在单独的文件夹里的。此时我们就可以使用ImageFolder来对数据进行读取。当然了,如果你的数据不是按照标签类别进行下载,或你的标签类别是单独储存在excel或txt文件当中,我们就需要别的操作来读取数据了。

使用ImageFolder读取后的数据是无法轻易更改标签的,这是因为ImageFolder继承自pytorch中的visiondataset类,标签在这个父类中生成,并与特征图一起被固定为一个元组(用来表示从特征到标签的映射)。我们可以通过ImageFolder的各种属性、或索引等方式调用出这个元组的一份复制来进行展示,却无法直接触及到元组中的数据本身,因此我们无法通过ImageFolder的读取出的标签进行改变。

虽然我们可以先从ImageFolder的结果中复制出特征图,再使用TensorDatasets重新对特征图和标签进行拼接,但Python并不支持对元组的批量操作,如果需要复制每个特征图,就必须对每个元组进行循环。但当数据量很大时,从ImageFolder的结果中提取全部样本就会需要很多时间和算力。因此,当数据不能按照类别进行下载时,大部分深度学习研究者都不会使用ImageFolder对数据进行读取,而会选择更加灵活的方式:自己写一个读取数据用的类。

CLASS torch.utils.data.Dataset

在PyTorch中存在一个专门帮助我们构筑数据集的类Dataset,这个类在torch.utils.data模块下,属于PyTorch中数据处理的经典父类之一(另一个我们总是使用的经典父类是nn.Module)。在PyTorch中,许多torchvision.datasets中读数据的类,以及TensorDataset这些合并张量来生成数据的类,都继承自Dataset。如果一个读取数据的类继承自Dataset,那它读取出的数据一定是可以通过索引的方式进行调用和查看的,而继承自其他父类的、读取数据集的功能却不一定能使用索引进行查看,这种性质让Dataset子类的构成也与其他类不同。

Dataset中规定,如果一个子类要继承Dataset,则必须在子类中定义 getitem() 方法。从这个方法的名字(get item,获取对象)也可以看出,它是帮助我们“获取对象”的方法。这个方法中的代码必须满足三个功能:
1)读取单个图片并转化为张量
2)读取该图片对应的标签
3)将该图片的张量与对应标签打包成一个样本并输出
该样本的形式是一个元组,元组中的第一个对象是图像张量,第二个对象是该图像对应的标签。

Dataset类中包含自动循环 getitem() 并拼接其输出结果的功能。也就是说,对于任意继承自Dataset的子类,只要我们恰当地定义了 getitem() ,该子类的输出就一定是打包好的整个数据集。我们可以根据数据的实际情况定义 getitem() ,可以说是实现了最大程度上的灵活性。

现在,我们使用celebA数据集举个例子。完整的celebA数据集中包含图片20万+张(图像大小20G),其中个体识别的标签为“人名”,类别有10,177个,属性识别的标签有40个,每个标签下是二分类,两种标签类别在txt中的格式不同。如果你感兴趣源文件,你可以在课程数据集的dataset3中找到它。将压缩文件解压后,即可获得具体的图像。

在这里插入图片描述

在课程中,我准备了包含1000张图片的celebA的子集,在dataset4\picturetotensor\celebAsubset文件夹中。

在这里插入图片描述

这个文件夹中的目录层次与dataset3中的celebA的原始数据集完全一致,只不过这个文件夹的图像和标签都只有前一千个样本。该子集仅作为读取数据用的例图,并不能被用于建模,如果需要建模请使用原始的20G大小的完整数据集。

在例图上,我们将展示如何使用继承自Dataset的类读取不同的图片和标签类别,你可以自由将数据更换为你的数据进行相同的操作。以下是我的根目录、个体识别的标签txt以及属性识别的标签txt:

在这里插入图片描述

在这里插入图片描述

在写具体的类之前,我们可以先定义 getitem() 方法中要求的内容,试着读取一张图片并生成样本的元组。在CV课程最开始的时候,我们使opencv中的cv.imread函数进行过图像的读取。事实上,有大量的库中都包含能够将图像转化为像素值的函数,原则上我们可以使用任何自己熟悉的函数。在本节课中我们pytorch官方推荐的scikit-learn图像处理库scikit-image来进行处理,只要你的环境中安装有sklearn,你应该都已经有scikit-image库。我们可以通过下面的代码进行检查:

import skimage

如果你导入失败,则需要执行以下代码进行安装。执行该代码时注意关闭VPN,若在cmd中执行则需要去掉开头的感叹号。

!pip install scikit-image -i http://pypi.douban.com/simple --trusted-host pypi.douban.com

或者 anaconda命令刚下

conda install scikit-image

有了skimage,我们来试着读取一张图片及其标签,并处理成样本元组。

#导入一张图片
#import pandas as pd
from skimage import io
#import torch
#包含了所有图像的目录,没有具体到某一张图像
imgpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Img\Img_celeba.7z\img_celeba"
#标签文件的目录
csvpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Anno\identity_CelebA_1000.txt"
identity = pd.read_csv(csvpath,sep = " ",header=None)
identity.head()
#试着读一张图片
io.imread(r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Img\Img_celeba.7z\img_celeba\000001.jpg"
)
#不难发现,imread中输入的内容就是之前定义的imgpath + identity表中的第0列
#只需要一个索引,就可以同时取出一张图片的标签和名称
imgdic = os.path.join(imgpath,identity.iloc[0,0])
imgdic

#将读取的标签和图像合并
idx = 0 #自定义
imgpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Img\Img_celeba.7z\img_celeba"
csvpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Anno\identity_CelebA_1000.txt"
imgdic = os.path.join(imgpath,identity.iloc[idx,0])
image = torch.tensor(io.imread(imgdic))
sample = (image,int(identity.iloc[idx,1]))
image.shape
sample
plt.imshow(image);
#成功读取一张图片,并将它和标签打包成一个元组

这种方式要求txt中所写的文件名必须与图像的文件名一致,当图像的名称与txt中所写的内容不一致时,就需要更改代码。比如,celebA数据集中,提供了原始图像和预处理过的图像。原始图像为jpg格式,预处理过的图像为png格式。当我们需要读取png,而标签中的图片名称都是.jpg格式,我们就需要对identity进行二次处理。

#更换文件拓展名
#identity是个体识别的标签
identity2 = identity.copy()
identity2["2"] = [x[:-3] + "png" for x in identity2.iloc[:,0]]
identity2.head()
imgpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Img\img_align_celeba_png.7z\img_align_ce
leba_png"
csvpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Anno\identity_CelebA_1000.txt"
imgdic = os.path.join(imgpath,identity2.iloc[idx,2])
image = torch.tensor(io.imread(imgdic))
sample = (image,int(identity.iloc[idx,1]))
image.shape
sample
plt.imshow(image)

同样,如果我们读取的是属性的标签,txt格式发生变化,也需要将具体的代码进行调整。为简化代码,我们以读取jpg格式的图像为例:

#读取属性的标签
imgpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Img\Img_celeba.7z\img_celeba"
csvpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Anno\list_attr_celeba_1000.txt"
identity = pd.read_csv(csvpath,sep=" ",header=None)

#开始报错。记得我们之前说过的,-1、1之间相隔的空格数不同
attr_ = pd.read_csv(csvpath,header=None)
attr_.head()
len(attr_.iloc[0,0].split()) #应该有41列,其中第一列是图像的名称
#分列
#一种更简单的方法是,直接把txt放入excel中,让excel帮助我们分列,同时将数据变成更容易操作的csv
文件
attr_ = pd.DataFrame(attr_.iloc[1:,0].str.split().tolist() #数据
                       ,columns=attr_.iloc[0,0].split()) #列名
attr_.head()
#通常来说,我们会选择40个属性中的一个属性作为标签
imgpath = r"F:\datasets4\picturestotensor\celebAsubset\Img\Img_celeba.7z\img_celeba"
csvpath = r"F:\datasets4\picturestotensor\celebAsubset\Anno\list_attr_celeba_1000.txt"
attr_ = pd.read_csv(csvpath,header=None)
attr_.head()
len(attr_.iloc[0,0].split()) #默认帮我按空格进行分类,它会把多个空格也当成一个空格
attr_ = pd.DataFrame(attr_.iloc[1:,0].str.split().tolist(),
 columns = attr_.iloc[0,0].split())
attr_.head()
attr_.loc[:,"Attractive"]

不难发现,无论是特征图发生变化,还是标签发生变化,我们都需要对代码进行具体的调整。在实际数据中,我们可能遇见已经分好训练集、测试集,并且两个数据集的索引用txt标注的情况,也可能会遇见完全没有区分训练集测试集,因此需要自己进行分割的情况。还可能会遇见,需要读取各种各样格式的数据、需要对数据进行各种各样操作的情况。但无论我们如何调整代码,只要将代码包装到Dataset子类的 getitem() 方法中即可。现在以jpg格式下、个体识别为例,我们来定义子类:

from torch.utils.data import Dataset
#import numpy as np
#import pandas as pd
#from skimage import io
#import torch, torchvision
#from torchvision import transforms
#import matplotlib.pyplot as plt
#import random #为了plotsamples而导入
#确保plotsample已运行
class CustomDataset(Dataset):
    """
   自定义数据集,用于读取celebA数据集中的个体识别(identity recognition)数据的标签和图像
   图像格式为jpg
   """
    def __init__(self,csv_file, root_dir, transform = None):
        """
       参数说明:
           csv_file (字符串): 标签csv/txt的具体地址
           root_dir (string): 所有图片所在的根目录
           transform (callable, optional): 选填,需要对样本进行的预处理
       """
        super().__init__()
        self.identity = pd.read_csv(csv_file,sep=" ",header=None)
        self.root_dir = root_dir
        self.transform = transform
    
    def __len__(self):
        #展示数据中总共有多少个样本
        return len(self.identity)
    
    def __info__(self):
        print("CustomData")
        print("\t Number of samples: {}".format(len(self.identity)))
        print("\t Number of classes: 
{}".format(len(np.unique(self.identity.iloc[:,1]))))
        print("\t root_dir: {}".format(self.root_dir))
    
    def __getitem__(self,idx):
        #保证idx不是一个tensor
        if torch.is_tensor(idx):
            idx = idx.tolist()
            
        #图像目录
        imgdic = os.path.join(self.root_dir,self.identity.iloc[idx,0])
        #提取出的,索引为idx的图像的像素值矩阵
        image = io.imread(imgdic) 
        label = self.identity.iloc[idx,1]   
        
        if self.transform != NonI:
            image = self.transform(image)
            
        sample = (image,label)
        return sample
imgpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Img\Img_celeba.7z\img_celeba"
csvpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Anno\identity_CelebA_1000.txt"
data = CustomDataset(root_dir = imgpath
             ,csv_file=csvpath
             ,transform = transforms.ToTensor())
data.__info__()
data.__len__()
for x,y in data:
    print(x.shape, y)
    break
#getitem的存在令子类生成的数据能够被索引调用和查看
data[998]
#让每个数据集随机显示5张图像
plotsample(data)     
#还记得我们用来分割训练集测试集的工具吗?
from torch.utils.data import random_split
data.__len__()
train,test = random_split(data,[700,300],generator = 
torch.Generator().manual_seed(42))
train.__len__() #分割后数据集由Dataset变成了Subset,继承自Dataset类的方法会保留,而自己设
置的方法则会失效
train.__info__()
test.__len__()           

读取属性标签,并放置到类CustomDataset_attr中:

class CustomDataset_attr(Dataset):
    """
   自定义数据集,用于读取celebA数据集中的属性识别(attribute recognition)数据的标签和图像
   图像格式为jpg
   """
    def __init__(self,csv_file, root_dir, labelname, transform = None):
        """
       参数说明:
           csv_file (字符串): 标签csv/txt的具体地址
           root_dir (string): 所有图片所在的根目录
           transform (callable, optional): 选填,需要对样本进行的预处理
       """
        super().__init__()
        self.attr_ = pd.read_csv(csvpath,header=None)
        self.root_dir = root_dir
        self.labelname = labelname
        self.transform = transform
    
    def __len__(self):
        #展示数据中总共有多少个样本
        return len(self.attr_)
    
    def __info__(self):
        print("CustomData")
        print("\t Number of samples: {}".format(len(self.attr_)-1))
        print("\t root_dir: {}".format(self.root_dir))
    
    def __getitem__(self,idx):
        #保证idx不是一个tensor
        if torch.is_tensor(idx):
            idx = idx.tolist()
        
        self.attr_ = pd.DataFrame(self.attr_.iloc[1:,0].str.split().tolist(),
                    columns = self.attr_.iloc[0,0].split())
        
        imgdic = os.path.join(self.root_dir,self.attr_.iloc[idx,0]) #图像目录
        image = io.imread(imgdic) #提取出的,索引为idx的图像的像素值矩阵
        label = int(self.attr_.loc[idx,self.labelname])
        
        if self.transform != NonI:
            image = self.transform(image)
            
        sample = (image,label)
        return sample
    
imgpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Img\Img_celeba.7z\img_celeba"
csvpath = r"I:\F盘 + 代码\F盘
datasets4\picturestotensor\celebAsubset\Anno\list_attr_celeba_1000.txt"
labelname = "Attractive"
data = CustomDataset_attr(csvpath,imgpath,labelname)
data
data.__info__()
data[500]        

如此,我们就实现了自定义数据集的调用,你现在已经可以使用上面的方式自由调用celebA数据,并且可以自由换分数据集。根据具体的标签和图像情况,你可以修改上述代码,并将上述的类用于任何你希望读取的数据。

值得讨论的一点是,当数据量很小的时候,我们使用Customdataset可以很轻松地将数据读取进来,但当数据大小为20个G,数据量超过20万时,我们还能够如此轻松地读取数据吗?答案是肯定的。我们在课程中使用子集,并不是因为数据量过大无法被读取,而是因为数据量过大很难被下载。事实上,PyTorch和skimage在读取图片数据集时,并不会将所有的图片提取出来放入缓存,而是对每一张图片进行读取后,只储存它的PIL格式或像素表示,因此20G的数据集也可以被很轻松地读入一台普通个人电脑的jupyter或pycharm。相对的,如果一个csv文件有好几个G,那读取就会比较痛苦了。当然了,20万张图片的张量表示也并不是一个很小的文件,但是肯定是远远小于20个G的。因此,在做出正确的调整后,CustomDataset也可以读取150G的数据ImageNet,如果你下载了完整的数据集,你可以尝试看看。

2.2 从二维表(csv/txt)到四维tensor

(P.S. 本节需要一定的机器学习基础)
当我们的训练数据是图像时,我们总是将其放入卷积神经网络中进行训练,也因此卷积网络需要的输入结构为(sample,channels,weight,height),那如果我们是二维表格数据,也可以放入卷积网络中吗?卷积网络常常表现出比普通机器学习算法更为强大的学习能力,如果二维数据结构也可以使用卷积网络来进行处理,或许可以获得更好的结果。首先二维数据基本都储存在csv/txt这些表格结构中,如果
我们遇见任意表格结构,可以先将其读入Python:

#csv
import pandas as pd
data = pd.read_csv(r"....")
#txt
import pandas as pd
data = pd.read_csv(r"....",sep=" ")

导入数据之后,将二维表格整理成更高维的数据,就可以放入卷积网络进行训练:

#import numpy as np
#import torch
#import matplotlib.pyplot as plt
data = np.random.randint(0,255,(10,10000)) #假设现在是10个样本,每个样本10000个特征
data.reshape(10,1,100,100) #可以直接使用reshape的方式将数据调整为4维
data = torch.tensor(data) #再放入tensor中转换格式
plt.imshow(data.view(-1,100,100,1)[0]);
data

reshape并不是一个很难的功能,对于大多数二维数据表而言,真正的问题是没有足够的特征用于变形。大部分二维表格数据的特征数都不是很多,甚至在传统机器学习中,人们害怕高维数据而努力对数据进行降维以提高计算效率。但对卷积神经网络来说,我们至少也需要784(28*28)个特征。那低维数据要怎样才能够放入卷积网络中进行训练呢?答案是先升维,再变化结构。在这里,我介绍一种常见的升维方式:多项式升维。

这是一种将特征数据交互相乘来增加特征维度的方法,它靠增加自变量上的次数来提升维度。只要我们设定一个自变量上的次数(大于1),就可以将特征映射到高维空间、并获得数据投影在高次方的空间中的结果。这种方法可以非常容易地通过sklearn中的类PolynomialFeatures来实现。我们先来简单看看这个类是如何使用的。

class sklearn.preprocessing.PolynomialFeatures (degree=2, interaction_only=False,include_bias=True)

from sklearn.preprocessing import PolynomialFeatures as PF
import numpy as np
#如果原始数据是一维的
X = np.arange(1,4).reshape(-1,1)
X
#二次多项式,参数degree控制多项式的次方
poly = PF(degree=2)
#接口transform直接调用
X_ = poly.fit_transform(X)
X_
X_.shape
#三次多项式
PF(degree=3).fit_transform(X)

在这里插入图片描述

X = np.arange(6).reshape(3, 2)
X
#二次多项式
PF(degree=2).fit_transform(X)

在这里插入图片描述

#尝试三次多项式
PF(degree=3).fit_transform(X)

很明显,我们可以看出这次生成的数据有这样的规律:

在这里插入图片描述

在机器学习课程中,我们曾经证明这样的升维方式可以大幅度提升线性模型处理非线性数据的能力。对于二维表格数据来说,多项式变化能够有效将维度升高。但这种方式并不是所有时候都可以用。多项式变化是对原始特征进行重组后形成新的特征,并没有在原始特征基础上进行深层特征提取,因此当原始特征本来就非常少时,多项式变化非常容易导致过拟合。我们来看一个例子:

#在加利福尼亚房价数据集上做实验
#这是基于sklearn框架的代码,与传统深度学习代码有较大区别
from sklearn.datasets import fetch_california_housing as FCH
from sklearn.preprocessing import PolynomialFeatures as PF
from sklearn.linear_model import LinearRegression as LR #线性回归
from sklearn.model_selection import train_test_split as TTS
from sklearn.metrics import mean_squared_error as MSE
data = FCH()
X = data.data
y = data.target
y
X.shape #原始数据只有8个特征,特征量非常少
y.shape
Xtrain,Xtest,Ytrain,Ytest = TTS(X,y,test_size=0.3,random_state=420)
reg = LR().fit(Xtrain,Ytrain)
MSE(reg.predict(Xtrain),Ytrain)
MSE(reg.predict(Xtest),Ytest)
#训练集和测试集结果非常相近,虽然表现不佳但是不存在太多过拟合的情况
poly = PF(degree=4).fit(Xtrain)
Xtrain_ = poly.transform(Xtrain)
Xtest_ = poly.transform(Xtest)
reg = LR().fit(Xtrain_,Ytrain)
MSE(reg.predict(Xtrain_),Ytrain)
MSE(reg.predict(Xtest_),Ytest) #测试集上的MSE变得巨大无比,这是严重过拟合的情况

因此,在我们对数据进行升维、并考虑将数据放入卷积网络进行学习时,必须要考虑数据本身的复杂程度是否足够。当数据过于简单、特征量过少的时候,多项式操作只能加重过拟合,卷积网络对于这样的数据来说也是过于复杂的、并不经济的模型。我们可以通过升维后的表现来判断数据是否具有更强大的潜力(即,特征本身含有较多信息,在升维之后也不会那么容易过拟合,比较适合放入卷积网络进行训练)。来看这一组数据

from sklearn.datasets import fetch_covtype as FC
from sklearn.preprocessing import PolynomialFeatures as PF
from sklearn.linear_model import LogisticRegression as LR #逻辑回归
from sklearn.model_selection import train_test_split as TTS
data = FC() #首次加载会比较耗时,需要进行数据下载
data.data.shape #数据量巨大,因此我们从中抽样2000个样本来进行训练
data.target
X = data.data[:2000]
X.shape
y = data.target[:2000]
Xtrain,Xtest,Ytrain,Ytest = TTS(X,y,test_size=0.3,random_state=420)
#======【TIME WARNING:1mins】=======#
clf = LR(random_state=420, max_iter=1000,solver="newton-cg").fit(Xtrain,Ytrain)
clf.score(Xtrain,Ytrain) #对分类模型而言,该接口是分类准确率
clf.score(Xtest,Ytest)
poly = PF(degree=2,interaction_only=True).fit(Xtrain) #不包含各特征的平方项
Xtrain_ = poly.transform(Xtrain)
Xtest_ = poly.transform(Xtest)
Xtrain_.shape
#======【TIME WARNING:10mins】======#
clf_ = LR(random_state=420, max_iter=1000,solver="newtoncg").fit(Xtrain_,Ytrain)
clf_.score(Xtrain_,Ytrain)
clf_.score(Xtest_,Ytest)

#准确率表现整体上升,存在过拟合但没有太夸张,数据适合放入卷积网络

在这里插入图片描述

当然,我们可以选择将特征信息保留最多的形式,例如,令结构为39x38,可最大程度上保留信息。当输入卷积网络后,再使用图像预处理的方式进行特征筛选。但图像处理上的特征筛选大部分是随机的,比较适用于图像数据,却不太适用于表格数据,因此最好的方法还是我们手动筛选特征。

假设我们现在,剔除42个特征,令特征总量下降到1444(38x38),然后再将数据整理为四维结构。那我们怎么剔除这42个特征呢?我们利用逻辑回归的权重进行筛选。逻辑回归和线性回归一样,本质上都是单层神经网络,他们的权重可以被追溯到具体的特征值上,而多层神经网络的权重却无法被追溯。因此单层神经网络的权重可以被用来衡量特征的重要性。我们可以令逻辑回归的权重从高到低进行排列,并删掉对模型影响最小的42个特征,再将剩下的特征调整为四维:

clf_.coef_.shape #通过逻辑回归的权重选择,逻辑回归是二分类算法,得出的权重是针对7个类别的权重
#首先对权重取绝对值,然后求7个类别上权重的平均
weights = pd.DataFrame(abs(clf_.coef_).mean(axis=0))
weights.shape
#按平均值对特征进行排序,对模型贡献更大的特征排在前面,
idx = weights.sort_values(by=0,ascending=False).iloc[:1444,0].index
#将整个数据升维
X_ = poly.transform(X)
X_ = X_[:,idx]
X_ = X_.reshape(2000,1,38,38)
X_.shape

当我们已经获得了四维的数据后,我们就可以使用之前学过的TensorDataset将数据打包了:

from torch.utils.data import TensorDataset
data = TensorDataset(torch.tensor(X_),torch.tensor(y))
for x,y in data:
    print(x.shape)
    print(y)
    break

最终生成的结果就是我们的数据,其结构与我们直接从torchvision.datasets读出来的内容非常相似。现在只要将数据放入random_split就可以分割训练集测试集,再放入DataLoader分割批次,就可以导入训练了。

2.3 从mat/pt/lmdb到四维tensor

除了二维表格数据和图片数据,我们还可能遇见其他各种各样的数据格式,常见的有matlab中导出的.mat格式,储存图片的pt格式,caffe中常用的数据库格式lmdb等。不同格式需要使用不同的方式进行导入,其中mat格式与pt格式较为简单,具体如下所示:

#FashionMNIST数据集就是pt格式的数据,可以直接使用torch.load进行读取
#import torch
X, y = torch.load(r"I:\F盘 + 代码\F盘datasets\FashionMNIST\processed\test.pt")
X.shape
y
#mat格式,SVHN就是mat格式数据集,我们使用scipy中的sio模块进行读取
#通常来说,scipy属于anaconda自带库,无需额外安装。如果你需要安装scipy,搜索pip安装scipy即可
import scipy.io as sio
#import numpy as np
#import torch
loaded_mat = sio.loadmat(r'I:\F盘 + 代码\F盘datasets\SVHN\train_32x32.mat')
X = torch.tensor(loaded_mat['X'])
y = loaded_mat['y'].astype(np.int64).squeeze()
#格式与pytorch要求的不符,记得调整
X.shape
X = X.reshape(-1,3,32,32) #(sample, channels, weight, height)

lmdb格式文件大多出现在早年的数据中,大部分用于读取lmdb文件的代码也年久失修,就连github上的众多代码也不能顺利跑通,因此我对PyTorch源码稍作修改,构造了用于读取单一的lmdb文件的类ImageFolderLMDB 。通常当我们的数据储存为lmdb格式时,每个lmdb文件中都是单一的标签类别,因此ImageFolderLMDB中会允许我们输入这个lmdb文件中的标签类别。当我们需要多个类别或多个imdb文件时,我们可以使用ImageFolderLMDB多次读取出不同的数据,然后使用torch.utils.data中的ConcatDataset 类将不同的数据集合并起来。从代码的角度来说,这段代码还有非常多可以优化的地方
(有许多可能的不规范输入没有被限制,当不规范输入发生时,我也没注明有指导意义的报错信息),但限于课时和时间,我们只能将其修缮到能够顺利跑通并执行完整任务的程度。之后我们会持续迭代具体的代码。

#本段代码已超出深度学习范围,仅供使用,不做讲解。
#如果你希望,可以将其保存在torchlearning.py文件中方便导入
import os
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"
import six
import string
import pickle
import bisect
import lmdb
from PIL import Image
import torch
from torch.utils.data import DataLoader, Dataset, IterableDataset
from torchvision.transforms import transforms
from torchvision.datasets import ImageFolder
from torchvision import transforms, datasets
class ImageFolderLMDB(Dataset):
    """
   用于从单一lmdb文件中提取出数据集的类
   只适用于lmdb文件中只包含一个标签类别的情况
   不同的标签类别需要使用不同的ImageFolderLMDB进行提取
   """
    def __init__(self, db_path, classes: int, transform=None, 
target_transform=None):
        """
       参数说明
       db_path: 字符串,需要读取的lmdb文件所在的根目录
       classes: int,给现有数据集打上的单一标签。注意该标签是人工标注的,不一定是数据中心的
客观标签
       """
        super().__init__()
        self.db_path = db_path
        self.classes = classes
        self.transform = transform
        self.target_transform = target_transform
        #首先使用lmdb库从lmdb文件中提出数据集
        self.env = lmdb.open(db_path, max_readers=1, readonly=True, lock=False,
                             readahead=False, meminit=False)
        with self.env.begin(write=False) as txn:
            self.length = txn.stat()['entries']
        cache_file = '_cache_' + ''.join(c for c in db_path if c in
string.ascii_letters)
        if os.path.isfile(cache_file):
            self.keys = pickle.load(open(cache_file, "rb"))
        elsI:
            with self.env.begin(write=False) as txn:
                self.keys = [key for key in txn.cursor().iternext(keys=True, 
values=False)]
            pickle.dump(self.keys, open(cache_file, "wb"))
 
    def __getitem__(self, index):
        img, target = None, None
        env = self.env
        with env.begin(write=False) as txn:
            imgbuf = txn.get(self.keys[index])
        # 导入图像
        buf = six.BytesIO()
        buf.write(imgbuf)
        buf.seek(0)
        img = Image.open(buf).convert('RGB')
        # 导入标签
        target = self.classes
        if self.transform is not NonI:
            img = self.transform(img)
        if self.target_transform is not NonI:
            target = self.target_transform(target)
        return img, target
    def __len__(self):
        return self.length
    def __repr__(self):
        return self.__class__.__name__ + ' (' + self.db_path + ')'

我们在LSUN数据集上试试这两个类:

from torch.utils.data import ConcatDataset
#先使用ImageFolderLMDB单独读取数据中的两个类别
data_church = ImageFolderLMDB(r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data\church_outdoor_train_lmdb",classes=0
                     ,transform=transforms.ToTensor())
data_church[0][0].shape
for x,y in data_church:
    print(x.shape)
    print(y)
    break
data_classroom = ImageFolderLMDB(r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data\classroom_train_lmdb",classes=1
                     ,transform=transforms.ToTensor())
data_classroom[0][0].shape
#使用ConcatDataset将其合并
data = ConcatDataset([data_church,data_classroom])
#数据尺寸已经超出了可以做循环的程度
data.__len__()
data_church.__len__()
data_classroom.__len__()
data[120000]
data[160000]

在实际中,我们还可能遇见各种各样的数据格式。如果你遇见新的数据格式,可以先谷歌搜索"xxx to pytorch tensor",如果无果,你可以试着搜索"xxx to csv"或’'xxx to png"。只要能够将数据集提取为图片或表格,导入过程就会变得容易很多。如果你还遇见过其他导入数据的形式,并且你成功实现了数据的导入,也欢迎在交流群内进行分享。

3 图片数据的基本预处理与数据增强

3.1 数据预处理

当顺利导入数据后,我们就可以依据图像的具体情况对图像进行预处理了。与机器学习中较为固定的预处理流程不同,图像的预处理基本完全与数据本身有关。从数据采集的瞬间开始,我们就需要考虑预处理的事项。如果我们的数据是自行从网络爬取或搜索引擎采集,我们可能需要对图像进行去重、删除无效样本等操作,如果数据是自行拍摄、实验提取,那可能也需要根据实验要求进行一些删除、增加的处理。当我们将所有有效数据导入后,我们至少需要确保:

1)全部样本的尺寸是一致的(同时,全部样本的通道数是一致的)
2)图像最终以Tensor形式被输入卷积网络
3)图像被恰当地归一化
其中,前两项是为了卷积神经网络能够顺利地运行起来,第三项是为了让训练过程变得更加流畅快速。在PyTorch中,所有的数据预处理都可以在导入数据的时候,通过transform参数来完成,我们通常在transform参数中填写torchvision.transforms这个模块下的类。在预处理时,我们需要使用的常规类如下所示:

在这里插入图片描述

首先是用来调整尺寸的两个类:中心裁剪 transforms.CenterCrop() 以及 transforms.Resize() 。无论使用怎样的卷积网络,我们都倾向于将图像调整到接近28x28或224x224的尺寸。当原图尺寸与目标尺寸较为接近时,我们可以使用“裁剪”功能。裁剪是会按照我们输入的目标尺寸,将大于目标尺寸的像素点丢弃的功能,因此使用裁剪必然会导致信息损失,过多的信息损失会导致卷积网络的结果变差。

当需要检测或识别的对象位于图像的中心时,可以使用中心裁剪。中心裁剪会以图像中心点为参照,按照输入的尺寸从外向内进行裁剪,被裁剪掉的像素会被直接丢弃。如果输入的尺寸大于原始图像尺寸,则在原始图像外侧填充0,再进行中心裁剪。

当图像的尺寸与目标尺寸相差较大,我们不能接受如此多的信息被丢弃的时候,就需要使用尺寸调整的类Resize。Resize是使用像素聚类、像素插补等一定程度上对信息进行提取或选择、并按要求的尺寸重排像素点的功能。一般来说,Resize过后的图片会呈现出与原图较为相似的信息,但图片尺寸会得到缩放。如果原始图像尺寸很大,目标尺寸很小,我们一般会优先使用Resize将图像尺寸缩小到接近目标尺寸的程度,再用裁剪让图片尺寸完全等于目标尺寸。例如,对于600*800的图像,先Resize将尺寸降到256x256,再裁剪至224x224。

#import torch
#import torchvision
#from torchvision import transforms
from torch import nn
transform = transforms.Compose([transforms.Resize(256)
                             ,transforms.CenterCrop(224)])
#等价于
transform = nn.Sequential(transforms.Resize(256)
                       ,transforms.CenterCrop(224))

调整完尺寸之后,我们需要对数据进行归一化,在这里使用的类是 transforms.Normalize() 。从理论上来说,图像数据的归一化不是必须的,但历史的经验告诉我们,归一化能够非常有效地改善整体训练过程速度,并对最终模型的结果造成巨大的影响,因此各大经典架构的论文和PyTorch官方都强烈建议我们进行归一化。这里的归一化与BN等训练过程中存在的归一化有较大的区别,这里的归一化主要是让像素值减去一个数(默认为均值)、再除以另一个数(默认是标准差),以实现对像素值大小的改变,让模型在一个较高的起点上训练,但并不能像BN一样改变数据的分布。

对表格数据而言,归一化是以特征为单位进行的,每个特征会单独减去自己这个特征的均值,再除以这个特征的标准差。对任意图像而言,归一化都是以通道为单位进行的,每个通道上的全部样本的全部像素点会减去通道像素的均值,再除以通道像素的标准差。为了能够对通道上的全部像素进行计算,图像在被归一化之前必须被转化为Tensor。因此在实际中,我们常常将 transforms.Normalize() 常常和transforms.ToTensor() 连用,具体如下:

transform = transforms.Compose([transforms.ToTensor()
                             ,transforms.Normalize(0.5,0.5)])

但许多人没有注意到的是,类 transforms.ToTensor() 已经带有归一化的功能:这个类会按照最大值255,最小值0对图片数据进行归一化,将所有图像的像素值压缩到[0,1]之间。

data_val = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                     ,classes=
["church_outdoor_val","classroom_val"]
                                     ,transform = transforms.ToTensor())
data_val
data_val[0][0].max()
data_val[0][0].min() #所有值都在0-1之间

因此类 transforms.Normalize() 往往是在[0,1]区间上执行。唯一的例外可能是表格数据,如果输入transforms.ToTensor() 的数据原本是二维表,那其最大值可能会远远超出255,那经过归一化后数字范围也不会在[0,1]之间。为了避免这种情况的出现,我们可以提前将二维表的数据压缩到[0,255]之间。在类 transforms.Normalize() 中有两个参数,一个是mean,另一个是std,分别代表需要减去的值和需要除以的值。比较常见的填写方式有以下三种:

#1) 常见且通用的做法,该写法只适用于三通道图像
transforms.Normalize(mean=[0.5, 0.5, 0.5], #代表三个通道上需要减去的值分别是0.5
                     std=[0.5, 0.5, 0.5]) #代表三个通道上需要除以的值分别是0.5
#在保证数据范围在[0,1]的前提下,使用这个值可以令数据范围拓展到[-1,1]

#也可写作:
transforms.Normalize(0.5,0.5)
#这种写法中,Normalize类会根据通道数进行相应的计算,任意通道数的图像都可以使用
#注意区分,这种写法只能用于单通道(灰度)图像
transforms.Normalize([0.5],[0.5])
#2) ImageNet数据集上的均值和方差,可被用于任意实物照片分类
transforms.Normalize(mean=[0.485, 0.456, 0.406],
                     std=[0.229, 0.224, 0.225])
#3) MNIST数据集上的均值和方差,可被用于MNIST系列
transforms.Normalize((0.1307), (0.3081))
#你也可以根据自己的数据集和自己希望实现的数值范围,来计算放入Normalize的值
#在LSUN数据集上尝试一下
transform = transforms.Compose([transforms.ToTensor()
                             ,transforms.Normalize(mean=[0.485, 0.456, 0.406]
                                                   ,std=[0.229, 0.224, 
0.225])])
transform1 = transforms.Compose([transforms.ToTensor()
                             ,transforms.Normalize(0.5,0.5)])
data_val = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                     ,classes=
["church_outdoor_val","classroom_val"]
                                     ,transform = transform
                                     )
data_val[0][0].max()
data_val[0][0].min()

对图像而言,必须完成的预处理就只有尺寸调整和归一化而已。接下来我们来看数据增强(data augmentation)。

思考
能否使用Resize将图像放大呢?这样有利于提升模型在数据上的表现吗?

3.2 数据增强

在讲解“不变性”时,我们曾详细地介绍过数据增强技术。数据增强是数据科学体系中常用的一种增加数据量的技术,它通过添加略微修改的现有数据、或从现有数据中重新合成新数据来增加数据量。使用数据增强技术可以极大程度地减弱数据量不足所带来的影响,还可以提升模型的鲁棒性、为模型提供各种“不变性”、增加模型抗过拟合的能力。常见的数据增强手段如下:

在这里插入图片描述

以及水平、垂直、镜面翻转:

在这里插入图片描述

在PyTorch当中,只要利用torchvision.transforms中包含的类,就能够很容易地实现几乎所有常见的增强手段。如下图所示,torchvision.transforms下的类可以分为四大类别:尺寸变化、像素值变化、视角变化以及其他变化。在能够让尺寸变化的类中,各类随机裁剪图像的类可以支持数据增强中的“缩放”功能(可放大,可缩小)。通常来说,如果裁剪是“随机”的,这个类一定是被用于数据增强,而不是被用于数据预处理的。这其中最常用的是 transforms.RandomCrop() ,常常被放在transforms.Resize() 后面替代中心裁剪。

在这里插入图片描述

在负责像素变化的类中,色彩抖动一个类就包含了亮度、对比度、饱和度、色相四种控制颜色的关键指标。同样的,各类“随机”调整色彩或清晰度的类一定都是被用于数据增强的。调整颜色的类都需要输入相当多的参数,因此需要相当多传统视觉领域的知识,相比之下放射变换、线性变换这些基于数学逻辑的类更容易理解。同时,灰度图像上能够做的色彩调整很有限。因此在像素变化类别中,最常用的类除了归一化,就是 transforms.RandomAffine() 。

在这里插入图片描述

这里是负责变形、透视、旋转等数据增强方法的类,被使用的频率相当高、每个类都很常用:

在这里插入图片描述

最后剩下的是用于转换格式、或将数据处理流程打包的类,其中最常用的就是transforms.ToTensor() 。

在这里插入图片描述

我们可以选取其中任意的类来尝试一下:

data_val = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                     ,classes=
["church_outdoor_val","classroom_val"]
                                    # ,transform = transforms.ToTensor()
                                     )
data_val[0][0].shape
#原图
data_val[0][0]
transform_aug = transforms.Compose([transforms.Resize(256)
                                   ,transforms.RandomCrop(size=(224))
                                   ,transforms.RandomHorizontalFlip(p=1)])
data_val = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"

                                     ,classes=
["church_outdoor_val","classroom_val"]
                                     ,transform = transform_aug
                                     )
#修改过后的图片
data_val[0][0]
#对于景色数据,水平翻转和随机裁剪都可能会比较有利
#因为建筑可能位于图像的任何地方,而根据尝试,水平翻转后的图像也能够被一眼看出是什么景色

你可以尝试更换不同的数据增强方式,查看数据增强的各个操作如何改变图像。在实际执行代码时,我们往往将数据增强和数据预处理的代码写在一起,如下所示:

#定义transform
transform = transforms.Compose([transforms.Resize(256) #先对尺寸进行操作
                               ,transforms.RandomCrop(size=(224))
                               ,transforms.RandomHorizontalFlip(p=1) #再进行翻转、
旋转等操作
                               ,transforms.RandomRotation(degrees=(-70,70))
                               ,transforms.ToTensor() #对图片都处理完成后,转换为
Tensor
                               ,transforms.Normalize(mean=[0.485, 0.456, 0.406] 
#最后进行归一化
                                                     ,std=[0.229, 0.224, 
0.225])])
#导入数据
data_train = torchvision.datasets.LSUN(root=r"I:\F盘 + 代码\F盘datasets2\lsunmaster\data"
                                       ,classes=
["church_outdoor_train","classroom_train"]
                                       ,transform = transform)
data_train[0][0].shape

现在你知道如何应用数据增强和数据预处理的相关功能了。使用这些代码并不难,真正难的地方在于看透代码底层实现的实际操作。在这段代码中,有一个非常容易被忽略、但一旦注意到之后却百思不得其解的问题:数据增强是增加数据量的技术,而上面的操作哪里增加数据量了呢?(甚至有许多对PyTorch很熟悉的人都并不知道这个问题的答案,可见跑代码是多么地简单)。

如果我们更换不同的数据集、不同的数据增强方式,很快就会发现,除了明确标明会生成多张裁剪图片的 FiveCrop 以及 TenCrop 两个类,其他的类都不能改变数据集中的数据总量。看上去这些进行数据增强的类只是对数据集进行了一个整体的转化而已,并没有真正实现“数据增强”。但深度学习框架在设计上总是非常巧妙的设计。虽然代码上无法直接看出来,但当我们将使用transform处理过的数据放入训练过程时,数据增强就会被实现。我们来看看具体是怎么回事。回顾我们构建的CustomDataset类:

在这里插入图片描述

我们在构建任意继承自Dataset类的、用于读取和构建数据的类CustomDataset时,我们将transform的使用流程写在了 getitem() 中,而没有放在 init() 中,因此CustomDataset被运行时并不会自动对数据执行transform中的操作。相对的,由于继承自Dataset类,CustomDataset会将数据进行读取,并将源数据本身放入内存。当我们通过 getitem() 方法调用数据集中的任意数据时,CustomDataset会从已经储存好的原始数据中,复制出我们希望调用的那些样本,同时激活transform的相关流程,将调用的数据进行transform处理。在这种情况下,返回到我们面前的是从原始数据中复制出的样本经过transform处理后的样子,储存在内存中的原始数据并没有被改变。

#例如
#调用0号样本,此时只有0号样本会被从原始数据中被复制出来,经过transform处理后return到我们面前
#但实际上CustomDataset提取出的原始数据并没有被改变
data_val[0][0]

事实上,任何torchvision.datasets下用于提取数据或处理数据的类都遵守这一原则:只保存原始数据,仅在调用数据时才对数据进行transform处理,这既有利于节省内存空间(只需要保存一份数据),也有利于计算速度(只对需要使用的样本才进行处理)。一般数据被读取后,可能经过分训练集测试集的sample_split,分批次的DataLoader,但他们都不会触发transform。因此当我们读取数据、分割数据时,没有任何预处理或者数据增强的操作被执行。那数据增强什么时候被执行呢?——当我们从分割好的batch_size中提取出数据进行训练时。

在这里插入图片描述

这是我们在Lesson 11中用于训练的代码,这段代码有些简陋,但依然能够展现出我们训练时的基本流程。训练时,我们会一个epoch一个epoch地进行循环,并且在每个epochs中循环所有批次。每次当batchdata中的x和y被调用来训练时,transform就会作用于该批次中所有被提取出的样本。也就是说,在红色箭头指向处,每个批次中的样本都会经过随机裁剪、随机旋转等图像增强操作。

在没有transform的时候,全部数据被分割为不同批次,因此一个epoch中每个批次的数据是不同的,但全部批次组成的这个epoch的数据都是一致的,分割批次只不过改变了样本的训练顺序,并没有增加新的样本,而循环epochs只是在原有的数据上不断进行学习。在存在transform之后,尽管每个批次中的原始数据是一致的,但在每次被调用时,这些数据都被加上了随机裁剪、随机旋转、随机水平翻转等操
作,因此每个批次中的样本都变成了“新样本”,这让整个被训练的epoch都与之前的epoch产生了差异。因此在transform存在时,无论我们循环多少epochs,每个epochs都是独一无二的,这就相当于增加了数据量,实现了“数据增强”。相对的,当transform存在时,我们的模型一次也不会见到原始的数据。

这种做法非常巧妙,并且非常节约内存。每次训练时,我们只需要保留原始数据,每个batch训练结束之后,被transform处理过的数据就可以被释放。并且,由于全部batch中的样本加起来一定是小于等于一个epochs中的样本量,所以调用batch时才进行transform操作和一次性对全部数据进行transform操作的计算量理论上没有太大差异。相对的,如果我们一次性对全部数据进行transform操作,也只能得到一组和原始数据不同的数据,但每次在调用batch时进行操作,却可以创造出和循环次数N一样多的N组新数据,实现了在不增大训练负担的情况下、增加样本量。总之,这是一本万利的做法。

当然啦,数据增强并非只有好处。根据经验,大多数时候,如果存在数据增强操作,模型的迭代周期会更长,毕竟数据中存在大量的随机性,模型收敛得会更慢。但这样得到的模型的鲁棒性和泛化能力都会更强。另一个显而易见的缺点是,数据增强中的随机性无法使用随机数种子进行控制。如果使用随机性种子进行控制,那每次进行的随机操作就会是一致的,每个epochs就会一致,这就和一次性对数据进行处理后再带入训练没有区别,这会让数据增强操作失去意义。但无法控制的随机性可能意味着模型的效果会略为不稳定,对写论文或上线之前进行测试的代码来说,每次运行都得出迥然不同的结果显然是很令人头疼的。因此使用数据增强的模型往往只能够得到一个“结果的范围”,论文中报告的往往是这个范围的上限。现在你了解数据增强是怎样被实现的了,在自己的数据上尝试做做看吧。到此我们对数据的说明就结束
了,下一节我们将会开始详谈模型的训练流程。

二 训练与算法

深度学习的“三驾马车”是数据、算法和算力,当拥有数据之后,我们可以基于数据开始训练模型了。在Lesson11中我们曾经执行过较为简洁的训练流程。经过5个lessons,今天我们需要对我们每个流程都进行全面升级,让我们代码更加接近真实训练流程。在Lesson17的前半段,我们已经学习了导入自定义数据集、数据增强等技能,接下来我们将就架构、优化算法等内容再次进行点到点的补充,最终我们将落地到实际案例上。大家可以根据自己的情况,就不了解的部分进行学习。

在这里插入图片描述

1 更强大的优化算法

经过学习,我们已经nn.Module、nn.functional非常熟悉了,也调用了部分torch.utils.data中的功能,在更加完整的训练流程中标,我们还需要就优化算法和神经网络的运行性能展开说明。在之前的学习中,我们了解过梯度下降,并对优化算法的具体流程进行了深入的剖析。在面对普通梯度下降的各种问题时,我们提出了分批次、完全随机、动量法等改善原始梯度下降的方法,但梯度下降的问题还有很多,例如1)性能不稳定、所需的迭代时间、可能获得的迭代结果都与数据的情况高度相关,2)在非常多的时候还是无法找到很好的最优解等等。在我们实际训练神经网络时,优化算法的选择会带来巨大的不同,而深度学习领域其实已经很少应用最传统的普通梯度下降算法了。今天我们要介绍基于梯度下降的更多优化算法:AdaGrad、RMSprop以及Adam。

1.1 AdaGrad

在这里插入图片描述

不难发现,所有权重在迭代时所使用的学习率是一致的,因此我们通过学习率在 和 的迭代过程中施加的影响和控制是相似的。在这种情况下,我们几乎假设两个权重的梯度值是比较接近的,这也是Glorot条件中所声明的:只有所有权重上的梯度值都比较接近时,模型才能具有较好的学习能力,整体损失才能够被优化到较好的局部最小值上,我们的Xavier初始化、Batch Normlization等方法也是围绕这个目标进行的努力。在实际迭代时,完全可能出现两个权重的梯度值有较大差异的情况,同样的学习率会导致整体迭代方向向更大梯度的方向“偏移”,梯度更大的方向会迭代得更快、更不稳定、甚至形成震荡,而梯度较小的方向会迭代得过慢、对整体方向的影响过小。

通常来说,为了避免优化算法向某个梯度较大的方向发散并让整个迭代方向都"跑偏",我们会将学习率控制在一个很小的范围内,但是这样做又会让梯度本来就较小的方向迭代缓慢,因此使用统一的学习率是存在问题的。同时,要确定一个对这个迭代流程都足够好的学习率也很困难。基于这些问题,许多学者都提出了将学习率衰减加入优化算法的思路。最常见的做法有以下几种:
1)在原始梯度下降或小批量梯度下降的基础上,对学习率进行线性衰减,直到达到一个固定的最小值后
停止
2)按指数进行衰减
3)每次验证集误差提升时,按2~10之间的乘数对学习率进行衰减

但这些方法都是基于实际训练时的表现对学习率进行修正,遵循机器学习中的“后验”思路,缺乏根本的理论基础。AdaGrad跳出了这些典型的方案,使用每个维度上权重梯度的大小来自适应调整学习率,从而避免一个学习率难以适应所有维度的问题。依据这个功能,AdaGrad这个算法的全程正是Adaptive Subgradient Methods(自适应次梯度方法)。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

回到我们迭代单个权重的公式上,AdaGrad对学习率的处理是非常有效的。正如我们之前所说,基于这个公式,如果某个维度上的梯度值(偏导数)一直都比较大,那么这个维度上的学习率就会下降得比较快,如果某个维度上的梯度值(偏导数)一直都比较小,那这个维度上的学习率下降就比较慢,这就达成了我们不同维度使用不同学习率、以保持梯度平稳性、保证迭代平滑的能力。这种能力允许我们在一开始为AdaGrad设置较大的学习率,并在一开始进行较快的迭代。例如,在PyTorch中,大部分优化算法的学习率默认值都是0.001,而AdaGrad和同系列的RMSprop的学习率默认值却是0.01,十倍的差异代表着AdaGrad更可能接受更大的学习率。如果初始设置的学习率是合适的,那AdaGrad可能很快就能够获得较好的收敛结果,如果学习率不太合适,那AdaGrad则能够自主调整学习率到合适的位置。

在这里插入图片描述
通过学习率来修正梯度平稳性使得AdaGrad表现出非常适合于稀疏矩阵的特性,这也是AdaGrad最大的优势之一。在稀疏矩阵上,AdaGrad不仅计算很快、并且常常能够得到更好的结果,这是因为AdaGrad更擅长处理低频特征(infrequent features)。什么是“低频特征”(infrequent features)与“高频特征”(frequent features)呢?这两个概念在经典机器学习中也存在。直觉上来说,高频特征就是特征值中
含有大量信息、对不同样本具备很高区分度的特征。例如,这个特征上存在数十种分类结果,每种分类分布相对均匀,对于样本的识别可能都具有一些作用。这种特征展现出的方差也往往会比较大,也是在机器学习中会表现出较高特征重要性的特征。相对的,低频特征就是特征值中含有的类别较少、且其中一个类别占比很高的特征。例如,一个特征上99%的值都是0,那整个特征对于大部分的样本都不具备区分效力,但这个特征对于占比只有1%的类别所对应的那些少数类样本来说却异常重要。

对任意算法而言,需要达到整体非常高的识别能力,就需要花更多的时间去学习这些低频特征。这一点与集成学习中boosting的思维很相似,想要达到平均水平非常容易,但是想要拔尖、继续提高准确率,则需要重点学习那些难以被辨别的样本。然而,在经典机器学习算法中,由于高频特征对于大部分样本更有区分度,算法可能会更多地偏向高频特征(例如决策树,会重复使用较有区分度地特征),一些优
化算法甚至会直接忽略掉低频特征,比如,在低频特征上计算出很小的梯度值,让低频特征上的权重迭代很快失效。而任何带有学习率调度的算法都可以一定程度上地解决这个问题。在AdaGrad的基础理论中,经过适当训练,AdaGrad会对高频特征设置较低的学习率,对低频特征设置较高的学习率,以此来保证“一碗水端平”的公平学习,因此AdaGrad比较擅长学到普通梯度下降算法无法学到的一些细微的信息,从而擅长处理稀疏矩阵。

在这里插入图片描述

1.2 RMSprop

AdaGrad提出的利用梯度值本身来削减学习率的思想成为了后续非常多优化算法的基础。为了解决AdaGrad所面临的、随着迭代学习率会变得太小的问题,许多研究者都给出了自己的解决方案,其中非常成功的一个解决方案就是RMSprop算法。RMSprop全称为Root Mean Square Propogation(均方根传播),它最初并不是在正式的论文中被发表、而是直接在Hinton的课程《机器学习中的神经网络》
(Neural Network in Machine Learning)中被提出,后来因为其很好的效果被开始广泛使用。后来论文《使用循环神经网络生成序列》(Generating Sequences With Recurrent Neural Networks)正式完善了RMSprop并提出了较为规范的迭代流程,形成了我们今天看到的RMSprop的完整版本。RMSprop的完整版所涉及的内容比一般教材上所写的简单公式要稍微复杂一些,由于PyTorch中使用的是完整版本的内容,因此我们将基于完整版本的内容进行说明。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

1.3 Adam

在这里插入图片描述
在这里插入图片描述

1.4 权重衰减

在这里插入图片描述

1.5 选择优化算法

在众多优化算法中,我们应该如何选择呢?通常在我们自己使用的时候,首先先试试看Adam和RMSprop的效果,如果这两个算法效果不够好,则先尝试调整算法的参数。只有在Adam和RMSprop展现出糟糕结果的时候,我们才会转向其他的优化算法。在2020年发表的论文《A Comparison of Optimization Algorithms for Deep Learning》(优化算法在深度学习上的比较)中,作者对优化算法在深度学习上的应用做出了全面的评估,并测试了10种优化算法的效果。我们可以观察一下作者所做的实验结果:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

可以看出,除了普通梯度下降和AdaGrad劣势明显之外,大部分的算法都能够达到一个较低的loss,但基于不同的数据的情况,不同算法可能展现出5%左右的区别。Adam和RMSprop为代表的衰减学习率的优化算法得到的分数基本都在前列,无论是Kaggle上的数据集、人脸数据集还是CIFAR10,学习率衰减的算法们都表现良好。普通梯度下降中能够有较好表现的是Nesterov梯度下降,如果你遇见了Adam和RMSprop效果不佳的情况,那你则可以选择使用NesterovSGD。在PyTorch中,我们可以使用SGD当中的参数,Nesterov = True来调用这个算法。

在这里插入图片描述

2 从经典架构出发

2.1 调用经典架构

我们已经学习了大量经典架构以及他们的经典思想,可以开始考虑自己的架构了。大多数时候,我们不会从0去创造我们的自己的架构,而是在经典架构中挑选一个适合的架构或适合的思路,并在经典架构上依据数据的需要对架构进行自定义和修改(当然了,我们只能够调用我们已经学过、并且掌握原理的架构,否则我们在修改的时候将无从下手)。在PyTorch中,基本所有经典架构都已经被实现了,所以我们可以直接从PyTorch中“调库”来进行使用。遗憾的是,直接调出来的大部分库是无法满足我们自己需求的,但我们还是可以调用PyTorch中的架构类来作为我们自己架构的基础。

在这里插入图片描述

在torchvision下,我们已经很熟悉datasets和transforms这两个模块,现在我们需要从torchvision中调用完整的模型架构,这些架构都位于“CV常用模型”模块torchvision.models里。在torchvision.models中,架构/模型被分为4大类型:分类、语义分割、目标检测/实例分割以及关键点检测、视频分类。我们之前学习的经典架构都是最基础的分类架构。

在这里插入图片描述

import torch
import torch.nn as nn
from torchvision import models as m
dir(m) #查看models里全部的类
#对于只有一个架构、不存在不同深度的AlexNet来说,两个类调出的结构是一模一样的
m.AlexNet() #查看需要填写的参数是什么?
m.alexnet() #将AlexNet父类的功能包含在里面,不允许对原始架构进行参数输入,但是可以进行预训练
#对残差网络来说,父类是实现具体架构的类,子类是已经填写好必填参数的类
m.ResNet() #可以从这个类中实现各种不同深度的结构
m.resnet152() #具体的深度和参数都已锁定,可以在这个类上执行预训练

在这里插入图片描述

在这里插入图片描述

在实际使用模型时,我们几乎总是直接调用小写的类来进行使用,除非我们想大规模修改内部架构。如下所示,调用类的方式非常简单:

import torchvision.models as m
#查看每个类中的结构
resnet18_ = m.resnet18()
vgg16_ = m.vgg16()
#我们在调用小写的类后,是可以通过调用类中的方法和属性来改变部分架构内容的
#调出架构的某一部分或某一层
resnet18_
#带有名称的,可以直接用.名称的方式进行调用。通常最外层都是具有名称的,可以直接像调用属性一样调
用,比如:
resnet18_.conv1
resnet18_.layer1 #.属性名称

#不带名称,而前面有数字编号的,则只能够使用数字编号进行调用
resnet18_.layer1.BasicBlock #调用失败
resnet18_.layer1[0]
#一个BasicBlock内部又是以名称命名,因此需要用名称调用,此时索引会报错
resnet18_.layer1[0].conv1
#由于网络架构不同,因此不同的架构类并不共享属性或方法
#(我们自己在定义时也是根据架构的情况来改变方法和属性)
#因此我们需要自己对架构进行检查后再进行调用
vgg16_
vgg16_.features[0]
#我们可以根据我们的需要修改部分层的超参数设置
#所有的类都是基于ImageNet数据集构建,因此输入通道数都为3,输出的类别都为1000
#当我们更换自己的数据集后,就需要对输入和输出进行变化
in_channels = 1
out_num = 10
resnet18_
#需要替换掉整个层
resnet18_.conv1 = nn.Conv2d(in_channels, 64, kernel_size=(7, 7), stride=(2, 2), 
padding=(3, 3), bias=False)
resnet18_.fc = nn.Linear(in_features=512, out_features=out_num, bias=True)
resnet18_ #查看已经变化的架构
#另一种方式也可以成功修改resnet18_中显示的内容,但是实际输入数据的时候还是会报错
resnet18_ = m.resnet18()
resnet18_.conv1.in_channels = 1
resnet18_ #第一层的in_channels已经显示为1
#但依然报错
data = torch.ones(10,1,224,224)
resnet18_(data)
#虽然修改输入和输出的特征图数目并不难,但要修改输入图像尺寸就会很困难


#不带名称,而前面有数字编号的,则只能够使用数字编号进行调用
resnet18_.layer1.BasicBlock #调用失败
resnet18_.layer1[0]
#一个BasicBlock内部又是以名称命名,因此需要用名称调用,此时索引会报错
resnet18_.layer1[0].conv1
#由于网络架构不同,因此不同的架构类并不共享属性或方法
#(我们自己在定义时也是根据架构的情况来改变方法和属性)
#因此我们需要自己对架构进行检查后再进行调用
vgg16_
vgg16_.features[0]
#我们可以根据我们的需要修改部分层的超参数设置
#所有的类都是基于ImageNet数据集构建,因此输入通道数都为3,输出的类别都为1000
#当我们更换自己的数据集后,就需要对输入和输出进行变化
in_channels = 1
out_num = 10
resnet18_
#需要替换掉整个层
resnet18_.conv1 = nn.Conv2d(in_channels, 64, kernel_size=(7, 7), stride=(2, 2), 
padding=(3, 3), bias=False)
resnet18_.fc = nn.Linear(in_features=512, out_features=out_num, bias=True)
resnet18_ #查看已经变化的架构
#另一种方式也可以成功修改resnet18_中显示的内容,但是实际输入数据的时候还是会报错
resnet18_ = m.resnet18()
resnet18_.conv1.in_channels = 1
resnet18_ #第一层的in_channels已经显示为1
#但依然报错
data = torch.ones(10,1,224,224)
resnet18_(data)
#虽然修改输入和输出的特征图数目并不难,但要修改输入图像尺寸就会很困难

不难发现,如果我们想要修改经典架构,我们必须逐层修改。而卷积网路的一层可能对后续的所有层都产生影响,因此我们常常只会对网络的输入、输出层进行微调,并不会修改架构的中间层。然而,大部分时候完全套用经典架构都不能满足我们建模的需求,因此我们需要基于经典架构构建我们自己的架构。

2.2 基于经典架构自建架构

尽管修改经典架构是一件冒险的事儿,我们确实有可能存在大规模修改架构的需求:比如说,几乎所有现代经典架构都是基于ImageNet数据集224x224的尺寸、1000分类构建起来的,因此几乎所有的经典架构都会有5次下采样(池化层或步长为2的卷积层)。当我们的数据集足够大时,我们会优先考虑使用经典架构在数据集上跑一跑,但当我们的图像尺寸较小时,我们不一定需要将图像拓展到224x224的尺寸以适应经典架构(我们的确可以使用transform.Resize这么做,但是放大转换后预测效果不一定会很好)。这样不仅会让算力要求提升、计算时间变长,还不一定能够获得很好的效果。如果可能的话,在较小的图片上,我们希望能够尽量保持原状以控制整体计算量。

对卷积架构来说,改变特征图的输入输出数量的行为只与一两个层有关,要改变特征图尺寸的行为则会影响整个架构。因此,我们一般会从经典架构中“抽取”一部分来进行使用,也有很小的可能会从0建立起自己的新架构。假设我们现在使用的是类似于Fashion-MNIST尺寸的,28x28的数据集,在这样的数据集上,我们可能执行下采样的机会只有2次,一次是从28x28降维到14x14,另一次是从14x14降维到7x7。这样的数据集并不是非常适合几千万、上亿参数的经典架构们。在这种情况下,torchvision.models下自带的架构就不能灵活满足需求,因此我们往往不会直接使用自带架构,而是在自带架构的基础上进行架构重构。接下来我们来学习如何基于经典架构来构筑自己的架构。

'''
我们可能有许多不同的建立架构的思路。最常见的方式是按照VGG的方式对网络进行加深,另一种则是使用经
典网络中的块(例如残差网络中经典的残差单元、GoogLeNet中的inception等结构)来加深网络。经验证
明,在inception和残差单元之前增加一些普通卷积层会有好处,因此在这里我先使用VGG的思路,再使用残
差单元。大家可以自由组合自己偏爱的结构。
'''
import torch
from torch import nn
from torchvision import models as m
from torchinfo import summary
#先查看网络结构,挑选我们要使用的部分
vgg16_bn_ = m.vgg16_bn() #初始化的参数
vgg16_bn_.features[7:14]
resnet18_ = m.resnet18()
class MyNet1(nn.Module):
    def __init__(self):
        super().__init__()
        #第一个卷积层自己写,以保证输入数据在尺寸、通道数上都正确
        self.conv1 = 
nn.Sequential(nn.Conv2d(1,64,kernel_size=3,stride=1,padding=1)
                                   ,vgg16_bn_.features[1:3])
        
        #后续的架构直接从经典架构中选
        #对尺寸很小的数据集而言,我们的深度本来就不深,因此可以试着在特征图数量上有所增加
        self.block2 = vgg16_bn_.features[7:14]
        self.block3 = resnet18_.layer3
        #自适应平均池化+线性层,此处都与残差网络一致
        self.avgpool = resnet18_.avgpool
        #输出的线性层自己写,以确保输出的类别数量正确
        self.fc = nn.Linear(in_features=256, out_features=10, bias=True)
    
    def forward(self,x):
        x = self.conv1(x)
        x = self.block3(self.block2(x))
        x = self.avgpool(x)
        x = x.view(x.shape[0],256)
        x = self.fc(x)
   
data = torch.ones(10,1,28,28)
net = MyNet()
net(data)
#查看自己构建的网络架构和参数量
summary(net,input_size=(10,1,28,28),depth=1,device="cpu")

借用经典架构,我们就不必再重新打造整个网络,但缺点是网络的架构和具体的层无法在代码中清晰地显示出来,同时层与层内部的层次结构也不一致,如果我们需要将代码提供给同事或他人进行使用,最好将代码重写为基本层构成的网络,或者准备好完整的批注。除了在残差网络上进行修改,我们也可以基于VGG的基本思路,打造“又浅又窄”的架构。这种架构虽然
在大型数据集上基本无效,但对于Fashion-MNIST来说却可以有很好的结果,并且计算量很小:

class BasicConv2d(nn.Module):
    def __init__(self,in_,out_=10,**kwargs):
        super().__init__()
        self.conv = nn.Sequential(nn.Conv2d(in_,out_,**kwargs)
                                 ,nn.BatchNorm2d(out_)
                                 ,nn.ReLU(inplace=True)
                                 )
    def forward(self,x):
        x = self.conv(x)
        return x
class MyNet2(nn.Module):
    def __init__(self,in_channels=1,out_features=10):
        super().__init__()
        self.block1 = nn.Sequential(BasicConv2d(in_ = 
in_channels,out_=32,kernel_size=5,padding=2)
                                   ,BasicConv2d(32,32,kernel_size=5,padding=2)
                                   ,nn.MaxPool2d(2)
                                   ,nn.Dropout2d(0.25))
        self.block2 = nn.Sequential(BasicConv2d(32,64,kernel_size=3,padding=1)
                                   ,BasicConv2d(64,64,kernel_size=3,padding=1)
                                   ,BasicConv2d(64,64,kernel_size=3,padding=1)
                                   ,nn.MaxPool2d(2)
                                   ,nn.Dropout2d(0.25))
        
        self.classifier_ = nn.Sequential(
            nn.Linear(64*7*7,256)
           ,nn.BatchNorm1d(256) #此时数据已是二维,因此需要BatchNorm1d
           ,nn.ReLU(inplace=True)
           ,nn.Linear(256,out_features)
           ,nn.LogSoftmax(1)
       )
        
    def forward(self,x):
        x = self.block2(self.block1(x))
        x = x.view(-1, 64*7*7)
        output = self.classifier_(x)
        return output
data = torch.ones(10,1,28,28)
net2 = MyNet2()
net2(data)
#查看自己构建的网络架构和参数量
summary(net2,input_size=(10,1,28,28),depth=1,device="cpu")

这是小型数据集不适用于经典架构,而必须自建架构的情况。如果是面对大型数据集,我们通常都会将其处理为类似于ImageNet的形式(224x224x3),先用一些深层架构进行尝试,再根据实际的情况使用深层架构的全部或一部分内容。如果需要删除层,则建议直接从经典架构中提取出需要的部分来组合,如果是要增加层,则可以使用nn.Sequential打包现有架构和新增的层。当然,我们需要谨慎考虑才能决定是否要在深层架构上继续增加层,因为在较大的数据集上、尤其是真实照片上调用较大的模型时,训练成本毫无疑问是非常昂贵的。如果我们真的必须增加层,或者在有限的计算资源下训练深层神经网络,那我们可以考虑使用“迁移学习”技术,也叫做“预训练的技术”。

2.3 模型的预训练/迁移学习

大多数情况下,我们能够用于训练模型的算力和数据都很有限,要完成一个大型神经网络的训练非常困难,因此我们希望能够尽量重复利用已经训练好的神经网络以节约训练和数据资源。如果我们在执行预测任务时,能够找到一个曾经执行过相似任务、并被训练得很好的大型架构,那我们就可以使用这个大型架构中位置较浅的那些层来帮助我们构筑自己的网络。借用已经训练好的模型来构筑新架构的技术就叫做“迁移学习”(transfer learning),也叫做预训练(pre-train)。预训练是我们训练大型模型时、用于降低数据需求以及加快训练速度的关键技术之一。

我们究竟如何借用已经训练好的模型架构呢?答案是借用训练好的模型上的权重。之前我们基于经典架构构建自己的架构时,是直接将经典架构中的结构本身复制一份,再在前后增加我们希望增加的层,这个过程中的经典架构并没有被训练过,所以全部层在训练时都得初始化自己的参数、从0开始训练。但在迁移学习中,我们要复用的是一个已经训练好的架构,包括它的架构本身以及每层上的权重。如下图所示,我们沿用现存架构上的前三个隐藏层以及它们的权重,并在这三个隐藏层后再加入两个我们自定义的层,以此来构筑新的架构。当我们在训练时,我们有两种选择:

1)将迁移层上的权重作为初始化工具(Initialization tool):将迁移层的权重作为新架构的初始化权重,在此基础上对所有层进行训练,给模型指一条“明路”。在最严谨的文献中,借用原始架构权重,并对所有层进行再训练的流程被称为“预训练”。

2)将迁移层作为固定的特征提取工具(fixed feature extractor):我们将迁移过来的层上的权重“固定”起来,不让这些权重受到反向传播等过程的影响,而让它们作为架构中的“固定知识”被一直使用。相对的,我们自己添加的、或我们自己选择的层则可以像普通的网络架构一样初始化参数并参与训练,并在每一次迭代中逐渐找到适合自己的权重。在最严谨的文献中,这个流程被称为“迁移学习”。

这样做有什么意义呢?对神经网络来说,它所学到的知识和能够做出的判断都被储存在权重当中(我们保存模型时,也是在保存模型的权重),因此保留权重,就是保留了之前的架构已经学会的东西。在任意深层神经网络或卷积网络中,接近输入层的层所提取的信息都是较为浅层的信息,接近输出层的层所提取的信息都是深层的信息,因此我们可以认为浅层中的权重可以帮助模型建立一些有用的“常识”,而深层中的权重则可以帮助模型进行具体任务的判断。在迁移学习中,我们总是利用现存架构上较浅的部分的层,来为新架构增加一些“基础知识”。当我们需要执行的任务与原始架构执行的任务有相似之处时,这些“基础知识”可以为新架构提供很好的训练基础。这个行为相当于为新架构引入了一位名师,比起从0开始无头苍蝇一样地学习,让名师指路、再自己学习,毫无疑问是效率更高、速度更快的学习方式。因此,我们通常期待迁移学习能够大量降低模型的训练时间,即便不能,它也能够大量降低我们需要的训练数据。

然而,迁移学习的使用条件是不容忽视的。在使用迁移学习时,必须要注意以下三点:

1)以前执行的任务A与现在需要执行的任务B有相似之处、有相互可借鉴之处。假设任务A是一个100分类的、在日常动物照片上进行分类的任务,如果任务B是一个猫狗识别的任务,两者就非常相似,可以认为他们共享一些基础信息,但如果任务B是预测明天是否下雨,可以说跟动物照片毫无关系,此时迁移学习就派不上用场。还是以名师为例,如果你想学书法中的草书,你的老师是写草书的最好,写隶书的或许也可,但如果你让一位足球教练来教你书法,效果恐怕还不如你自己摸索来得好。所以,新任务如果是基于表格数据、或独特的实验室数据来执行的,迁移学习很少能有用。相反,两个任务越相似,你可以迁移的层数就越多,当任务高度相似时,你甚至可以只改变原始任务的输出层,来完成你的新架构。

2)任务A与任务B的输入图像尺寸、通道数尽量要相同。迁移学习中我们99%的情况都会保留现存架构的输入层,因为输入层上存在的参数可能是最基础、最浅层的通识,保留输入层将有助于模型的学习,因此模型输入的图像与显存架构的输入图像必须完全一致(除非我们放弃输入层)。相似的,在迁移学习中,我们很少会混用架构。在之前自定义架构时,我们使用了VGG16的作为前半部分,ResNet18作为后半部分,但在携带权重的情况下这样做会让模型训练过程变得极度不稳定且混乱。同时,一致的图像尺寸和图像通道数很可能意味着两个任务基本可以沿用一样的网络架构以及超参数设定。如果我们的
任务A很复杂,任务B却很简单,可能意味着任务A的架构层对于任务B来说很容易过拟合。同时,之前我们也提到过,将小型数据强行变大不一定会得到很好的效果,还可能增加计算量,因此在任务属性相似、但图像数据各方面差异很大、无法共享输入层的情况下,迁移学习也不一定能有很好的效果。

3)迁移过来的层不一定是要完全锁死的。在刚将层迁移过来时,我们一般会锁死全部的迁移层,并且先训练模型看看整个模型如何表现。然后我们会试着解锁一两个靠近输出层的迁移层,再次训练来看看模型的表现是否提升,但这种情况下我们会使用很小的学习率,避免在训练中将迁移层上的权重迭代得面目全非。我们拥有的训练数据越多,我们可以解锁的层就越多,拥有的训练数据越少,我们可以在迁移层上增加的新层就越少。通常来说,如果你使用了迁移学习,却发现模型效果无论如何都不够好,那可能就是你增加了超出你训练数据能力的新层,尝试删除一些架构中新增的层,并锁死全部的迁移层再次进行训练。

我们来看看PyTorch中如何实现预训练。首先,在导入经典模型时,我们可以使用模型中已经存在的参数“pretrain”来帮助我们加载预训练模型上带的权重。PyTorch中所有模型的预训练都基于ImageNet数据集来完成,这个与训练参数在大多数实际照片上都可以有所帮助,但对表格数据和MNIST这类的数据集帮助不是很大。来看下面的代码:

import torch
import torch.nn as nn
from torchvision import models as m
resnet18_ = m.resnet18()
#执行此代码时注意关闭VPN
rs18pt = m.resnet18(pretrained=True) #resnet18_pretrained
resnet18_.conv1.weight[0] #初始化的参数,准备好训练
rs18pt.conv1.weight[0] #经过预训练的参数
#属性requires_grad为True,意味着可以参与反向传播
#预训练的参数刚被导入时,都是默认可以被训练的
rs18pt.conv1.weight[0].requires_grad
#将导入的预训练模型中所有的参数锁住
for param in rs18pt.parameters():
    param.requires_grad = False
rs18pt.conv1.weight[0]
#注意到grad_fn属性的消失吗?这意味着这些参数将不能够再参与反向传播等训练流程了
#同时requires_grad属性也会变化为False
rs18pt.conv1.weight[0].requires_grad
#使用新的层覆盖原来的层
rs18pt.conv1 = nn.Conv2d(1, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 
3), bias=False) 
#新生成的层默认requires_grad=True
#因此在锁定模型中的参数后,只要覆盖掉原来的层,或者在原来的层之后加上新的层,新的层默认就是可以训
练的
#但注意,新的层将会覆盖掉原来层已经训练好的参数,所以我们一般不对从conv1进行覆盖
rs18pt.conv1.weight.requires_grad
#按照这一逻辑定义架构
#让18层残差网络的前2个layers都被冻结,后面两个layers从0开始训练
resnet18_ = m.resnet18()
rs18pt = m.resnet18(pretrained=True) #resnet18_pretrained
fcin = rs18pt.fc.in_features
for param in rs18pt.parameters():
    param.requires_grad = False
class MyNet_pretrained(nn.Module):
    def __init__(self):
        super().__init__()
        #迁移层
        self.pretrained = nn.Sequential(rs18pt.conv1,
                                        rs18pt.bn1,
                                        rs18pt.relu,
                                        rs18pt.maxpool,
                                        rs18pt.layer1,
                                        rs18pt.layer2
                                       )
        #允许训练的层
        self.train_ = nn.Sequential(resnet18_.layer3
                                 ,resnet18_.layer4
                                 ,resnet18_.avgpool)
        
        #输出的线性层自己写,以确保输出的类别数量正确
        self.fc = nn.Linear(in_features=fcin, out_features=10, bias=True)
    
    def forward(self,x):
        x = self.pretrained(x)
        x = self.train_(x)
        x = x.view(x.shape[0],512)
        x = self.fc(x)
net = MyNet_pretrained()
net.pretrained[0].weight.requires_grad
net.train_[0][0].conv1.weight.requires_grad


from torchinfo import summary
summary(net,input_size=(10,3,224,224),depth=3,device="cpu")
#不能训练的参数被括号括了起来,表示被锁定
#当你训练一段时间后,希望解锁部分层,可以怎么做呢?
#解锁被锁定的部分中最后一个layers
for param in net.pretrained[5][1].parameters():
    param.requires_grad = True
net.pretrained[5][1].conv1.weight.requires_grad
#在PyTorch中我们可以非常灵活的调用任何的层或控制任何层上的参数
#这为我们灵活调用层来组成自己希望的预训练模型提供了很好的基础

可以发现,在代码上实现迁移学习并不困难,但我们学习的只是迁移学习最浅层的知识。除了pytorch提供的各个可以迁移的架构外,我们还可以在github上找到大量其他可以供我们迁移的模型,这些模型的权重可能储存在github上的url中,也可能我们可以直接从github下载模型本身。对任意pytorch可以调用的模型来说,训练好的权重都储存在参数state_dict中。我们可以直接从url获取权重,也可以从现有模型中调出权重来迁移使用。

#本段代码仅做事例,不可运行
#=======【从url获取模型权重】========
url = 'https://xxxxxx/xxxx/xxxx.pth'
#定义model架构,并实例化model,model架构必须与url中权重要求的架构一模一样
model = TheModelClass(*args, **kwargs)
state_dict = load_state_dict_from_url(url)
model.load_state_dict(state_dict)
#然后就可以用我们对resnet18使用的一系列手段进行迁移学习了
#=======【从保存好的权重文件中获取权重】=======
PATH = 'xxxx/xxx/xxxx.pt'
#实例化模型
model = TheModelClass(*args, **kwargs)
model.load_state_dict(torch.load(PATH))
#=======【从保存好的模型中获取权重】======
PATH = 'xxxx/xxxxx/xxxx.pt'
model = torch.load(PATH)
#获取权重
model.state_dict()
best_model_wts = copy.deepcopy(model.state_dict())
#选择对state_dict()中的部分值进行迭代
model.load_state_dict(best_model_wts)

实际上,迁移学习还可以在无监督的情况下运行,我们不止可以迁移卷积网络和DNN,我们也可以迁移自分编码器(autoencorder)或生成对抗网络(GANs)上的权重,并在这些网络架构后面跟上分类器作为有监督的算法来使用。同样,我们还可以在半监督、标签不足的情况下运行迁移学习(在NLP中我们很可能会这么做),这些技术都非常精妙,但限于课时限制无法全部给大家展开让大家学习,大家感兴趣的可以自己进行学习。有了迁移学习的加持,我们可以节省许多在巨大数据集上训练的时间。但现在值得思考的是,我们怎么知道哪个经典模型更加适合我们用于迁移、或用于修改成新的架构呢?此时我们就需要进行模型选择。

2.4 模型选择

深度学习架构琳琅满目,机器学习算法也是一样,无论我们是想执行迁移学习、从0建立自己的架构、还是直接调用经典架构来使用,在确定应该使用什么架构之前,我们需要完成模型选择的工作。一个不适合的算法可能在训练1000个epochs之后也达不到好的效果,但一个适合的算法+一个适合的优化算法可能只需要30个epochs就能够收敛,选择正确的模型对于少走弯路非常重要。在机器学习中,我们往往会在不同类型的算法上进行交叉验证、绘制学习曲线,然后选择基准分数较高的模型进行调参(如果你不太了解这些技能,可以参考《2021机器学习》课程内容,也可参考B站公开课内容)。但在深度学习中
我们却几乎不可能完成这样一个流程,主要还是因为深度学习所需要的计算量远远超出经典的机器学习算法。即便是对于最简单的Fashion-MNIST数据集来说,要靠CPU在ResNet18上去运行10个epochs还是需要几个小时的时间,更何况是我们往往需要在更复杂的数据集上训练更大的模型。如果我们拥有足够的算力和时间,我们可以使用代码进行类似于交叉验证的模型选择,但在单个模型训练成本就很高的情况下,我们常常无法以个人的身份进行足够的尝试,因此我们就需要依赖于一些经验来帮助我们进行模型的初筛。

首先,我们需要对各个模型有较为清晰的认知。
如果可能的话,我们需要学习尽量多的模型和架构,并且了解他们的优劣。就目前为止我们学习的几个架构而言(LeNet5,AlexNet,NiN,VGG,GoogLeNet,ResNet),同等深度下参数较少的是ResNet和GoogLeNet,这两个算法具有较多的改进手段、也能够取得比较好的效果,但这两个算法比较适用于较大的数据集,也比较适合可以直接使用这两个架构的情况,要在小数据集上去改进、或基于这些架构的思想重构自己的架构会比较困难。相对的,VGG则是思想较为简单、建立和修改起来更容易的架构,如果需要从0建立架构,VGG会更有参考价值,同时只要将VGG中的线性层去掉,VGG的参数量就会骤减,也属于比较好的选择。AlexNet的结构是最简单的,如果要在小数据集上重新建立架构,在AlexNet的结构(卷积x2 + 卷积x3)中加入其他网络的思维会比较容易操作。如果这些架构都无法得到很好的结果,那我们可能需要学习新的架构、或增加其他处理数据或模型的手段。

第二,我们需要关注数据的复杂程度。
数据的复杂程度可以从数据的特征量、标签中的类别数量等方面观察出来:数据的特征量越多,就意味着图像的尺寸越大,那图像所包含的信息也可能更加复杂。比起人造的图像,或清洗干净的图像,真实的照片的尺寸往往会大很多,同时信息也更加复杂。如果是分类数据,标签类别越多则信息越复杂,如果是回归数据,数据波动越大、数据分布越不明确,信息越复杂。越复杂的样本越难学习,因此复杂样本需要的网络很深、很宽。基于此,我们更倾向于尺寸更大的图像或真实照片上使用50层以上的残差网络这样的架构,例如ImageNet,或者尺寸很小、但是由真实照片生成的CIFAR-10数据。根据残差网络
论文中的实验,CIFAR-10需要一个110层的残差网络来达到大约93%测试准确率,而现在能够在CIFAR-10上达到98%左右准确率的网络都不会非常浅或非常窄(特征图数量不会很少)。相对的,我们一般在Fashion-MINST这样比较干净的数据集上使用20层以下的浅层网络进行训练,对于表格数据,我们甚至可以使用10层左右的网络进行训练。

第三,我们需要关注数据的规模和我们拥有的算力。
数据的规模则可以从特征量和样本量观察出来,而算力则决定了我们能够使用多深的网络。无论我们有多少算力,我们都需要尽可能地使用参数利用率更高的架构(比如残差网络,比如很浅且特征图数量较少的改版VGG,比如任何的全连接层都取缔掉等等)。对深度学习来说,数据量巨大比数据缺失要好很多,如果数据量巨大,我们可以将batch_size设置得较小慢慢进行训练,如果数据不足,我们则必须被迫使用更浅的网络,同时会需要增加训练次数、使用数据增强等方式来帮助我们,否则会产生严重的过拟合问题。然而,如果数据难度很大,数据量又非常小、计算资源又不足的话,那神经网络也许就不是最适合的算法了。当然,如果你拥有足够的算力,不需要考虑计算效率的问题,我建议你在所有可能的架构上都尝试至少10个epochs,然后选择起点最高、潜力最大的模型继续进行改造。同时,在没有GPU的情况下,强烈建议不要直接使用原版VGG16。

当你算力有限,又无法判断自己应该使用什么网络时,首先尝试ResNet18观察情况。如果18层残差网络表现良好(例如,可以在10个epochs内获得80%+的水平),你则可以继续在残差网络上加深层。如果18层残差网络在10个epochs内表现不佳、或者你发现你的算力根本带不动18层残差网络,那你可以在AlexNet的基础上增加和删减层来试试看。

更多具体的选择,可以查看下面这张图的推荐:

在这里插入图片描述

如果你的数据尺寸不大、数据量也较小(例如,接近10万数据,甚至在10万数据以下)、你还拥有足以支撑模型选择计算的GPU,并且最关键的是,你并不具备足以选出最佳架构的经验,那你可以依赖于代码和方法执行模型选择。在机器学习中,我们使用手段“交叉验证”来帮助我们执行模型选择。简单来说,交叉验证首先将数据集划分为不同的几份,并让模型在每份不同的数据进行训练和预测,最后对比单个模型在不同数据上的表现(如准确率)。这些表现之间的水平约接近,代表模型越稳定,这些表现的平均水平越高,代表模型判断能力越强,这两点在机器学习中被称为“模型评估能力的方差”、以及“模型的偏差”。一个理想的模型是方差和偏差都很小,也就是单次表现很优秀、同时每次表现都很稳定的模型。

在这里插入图片描述

在这里插入图片描述

在深度学习中,我们也可以用与交叉验证相似的方式来选择模型。需要注意的是,在机器学习当中,我们可能会对5-10个模型上的共20、30组参数进行交叉验证,从中选出最佳模型的最佳参数。但对于深度学习架构来说,由于算力限制,我们必须先根据经验、构筑可以被用于数据的少数2、3个架构,然后再进行筛选。我们常常使用的方法是,令同一个架构在不同的训练集上从0训练5-10次,每次执行2、3个epochs,以此来观察架构在每次初始化之后的表现是否足够好、是否稳定、以及在迭代3个epochs之后是否呈现出过拟合、欠拟合或其他趋势,可能还会评估计算时间等信息。遗憾的是,交叉验证并不能适
用于所有的深度学习的场合,当架构和数据量远远超出我们能够提供的算力时,我们会高度依赖于测试集上的结果来判断模型的泛化能力。我们使用数据增强来构造不同的训练集,最终我们会选择初始化相对稳定、计算时间较短、模型表现也更好的架构(虽然大多数时候,我们选择架构不太可能在这三点上都占优)。

实用小技巧
虽然深度学习很难执行真正的交叉验证,但深度学习训练过程中每个batch的数据都是一个单独的数据集。如果我们希望验证一个已经训练好的模型在不同测试集上的表现,则只需要让一个完整训练流程中的参数迭代过程失效、只执行预测任务即可(就像我们在预训练中做的那样,关闭部分层的梯度计算功能)。在这种情况下,每个batch得出的结果就是现在的模型在不同测试集上得出的结果了。

3 一个完整的流程

现在我们已经完成了对训练与算法这部分内容的补充,可以开始考虑训练的完整流程了。下面这张流程图梳理了我们从“想要实现一个任务”到最终实现为代码的具体流程,其中包括了完整的训练过程,也包括了在构建完整项目的时候一些具体的考虑。有这张流程图作为引导,我们可以判断什么时候需要使用我们学习的什么知识,并且帮助我们自己构建起建模流程中的经验和探索方法,并能够将这些经验应用于自己的项目。

在这里插入图片描述

如果去掉上面这张图像中我们的各种考虑,而只梳理相关流程的话,可以总结为下面这张图像,其中蓝色线条线条框柱的部分是我们自Lesson 11完成一个完整流程后学习的新内容,其他则是我们在Lesson11时就已经完成的内容。有了这些新学习的内容,我们终于可以自己来实现一个真实的分类项目。

在这里插入图片描述

在接下来的案例部分,我们将分别实现三个识别类案例以及一个检测案例、一个分割案例。识别类案例中包括小型图像数据集上的案例、表格数据集上的案例、以及大型图像数据集上的案例。其中前两个案例开放CPU和GPU两个版本,但大型图像数据集的案例只开放GPU版本。接下来我们就来看看第一个案例:SVHN街道实景门牌识别。

三 【案例】SVHN街道实景门牌识别

SVHN全称Street View House Number数据集,它是深度学习诞生初期被创造出来的众多数字识别数据集中的一个,也是唯一一个基于实拍图片制作而成的数字识别数据集。其风格与MNIST数据集相似,每张图像中是裁剪后获得的一个数字,并且是数字0~9相关的十分类,但整个数据集支持识别、检测、无监督三种任务,SVHN数据集也因此具有三种不同的benchmark。由于SVHN原始图像都来源于谷歌地球(Google Earth)街景图中的门牌号,其像素信息中自然场景图像的复杂性较高,数字识别难度更大,对识别模型的要求明显也更高。在学术界,当大家已经厌倦MNIST数据集和Fashion-MNIST数据集上99%的准确率时,常常会使用SVHN数据集来验证自己的网络架构在实拍照片上的能力。同时,虽然是实拍数据集,但SVHN识别集的图像被处理得很小(尺寸为32x32,通道为3),样本量也在10万左右,可以在CPU上实现迭代,是非常适合用来走完整流程的数据集。目前为止,在SVHN数据集识别类benchmark中占据头名的是2020年的宽残差网络WRN28-10,WRN首次将SVHN上的测试准确率推向了99%的水平,同时benchmark上前10的架构全都是在2018年之后提出的。现在,各大论文在SVHN上可以达到的前30名水平大约在97.65%左右。今天我们就基于这样的一个数据集来执行一个完整的流程。

在这里插入图片描述

*注意:由于SVHN数据集的测试数据已经被撤下,不再提供下载,因此我们在案例中所使用的“test”集严格来说应该算是验证集。在PyTorch官方的类中,并没有严格区分验证集和测试集,因此我们也使用测试集来称呼我们的test部分数据集。在课程中,我为大家提供了SVHN数据集的下载链接:

在这里插入图片描述

下载解压后,我们在SVHN文件夹中会看到两个mat格式的文件。将该层目录直接放入PyTorch中进行读取,即可顺利读入SVHN数据集中用于识别的部分。可惜的是,由于mat文件无法在win或者mac操作系统中被识别为图片,所以我们无法在操作系统中自由地查看SVHN中的图像,要了解图像,就必须先将图像读入。

在这里插入图片描述

3.1 设置库,导入环境

import os
import torch
os.environ['KMP_DUPLICATE_LIB_OK']='True' #用于避免jupyter环境突然关闭
torch.backends.cudnn.benchmark=True #用于加速GPU运算的代码
#导入pytorch一个完整流程所需的可能全部的包
import torchvision
from torch import nn, optim
from torch.nn import functional as F
from torchvision import transforms as T
from torchvision import models as m
from torch.utils.data import DataLoader
#导入作为辅助工具的各类包
import matplotlib.pyplot as plt
from time import time
import random
import numpy as np
import pandas as pd
import datetime
import gc
#设置全局的随机数种子,这些随机数种子只能提供有限的控制
#并不能完全令模型稳定下来
torch.manual_seed(1412)
random.seed(1412)
np.random.seed(1412)
#GPU系统会返回True,CPU系统会返回False
torch.cuda.is_available()
#GPU系统会令device = "gpu", cpu系统会令device = "cpu"
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

3.2 数据导入、数据探索、数据增强

导入库之后,我们将开始导入数据。如果我们的数据集是自己的自定义数据,我们则需要写类CustomData来进行导入,幸运的是SVHN是PyTorch中提供了接口的数据集,在课程资料中下载后即可使用torchvision.datasets中的类来帮助我们导入。通常在第一次导入图像的时候,我们不会使用数据增强的任何手段,而是直接ToTensor()导入进行查看。

#导入数据
train = torchvision.datasets.SVHN(root ='I:\F盘 + 代码\F盘datasets\SVHN'
                                 ,split ="train"
                                 ,download = False
                               # ,transform = T.ToTensor()
                                 )
test = torchvision.datasets.SVHN(root ='I:\F盘 + 代码\F盘datasets\SVHN'
                                 ,split ="test"
                                 ,download = False
                                 ,transform = T.ToTensor())
#先调一张图像出来看看
train[0][0]
#检查数据量
train
test
#查看尺寸等信息
for x,y in train:
    print(x.shape)
    print(y)
    break
#标签类别
np.unique(train.labels)
#让每个数据集随机显示5张图像
def plotsample(data):
    fig, axs = plt.subplots(1,5,figsize=(10,10)) #建立子图
    for i in range(5):
        num = random.randint(0,len(data)-1) #首先选取随机数,随机选取五次
        #抽取数据中对应的图像对象,make_grid函数可将任意格式的图像的通道数升为3,而不改变图像
原始的数据
        #而展示图像用的imshow函数最常见的输入格式也是3通道
        npimg = torchvision.utils.make_grid(data[num][0]).numpy()
        nplabel = data[num][1] #提取标签
        #将图像由(3, weight, height)转化为(weight, height, 3),并放入imshow函数中读取
        axs[i].imshow(np.transpose(npimg, (1, 2, 0))) 
        axs[i].set_title(nplabel) #给每个子图加上标签
        axs[i].axis("off") #消除每个子图的坐标轴
plotsample(train) #分辨率很低,数据集有一定的难度                                

了解数据集的基本情况之后,我们需要设置正式导入图像的代码之前,并且必须先定义用于处理图像数据的transform。在决定transform时,我们要决定输入特征图的尺寸、是否执行数据增强、使用怎样的数值进行归一化等信息。通常来说,我们会在训练之后再决定是否要使用数据增强,但对于小型数据集或表格数据集,学习力很强的卷积网络往往非常容易过拟合,因此我们会防御性地增加一些数据增强(这个行为可能让模型变得更加不稳定,但同时可以一定程度上防止过拟合)。需要注意的是,数字识别和其他照片识别在“不变性”上会有较大的差异。举个例子,识别水果、动物、交通工具等图像时,随机水平翻转甚至上下翻转可能是一个很好的选择,但对数字和字母识别来说,测试集中原则上不会存在水平、竖直翻转的情况。同理的还有大规模旋转、变形等常见的数据增强手段。不适合的数据增强不仅会让运算变慢,同时还可能拉低模型整体的预测分数,需要格外注意。

#定义用于处理图像的transform
#训练集可能需要数据增强,测试集却不做数据增强,因此要分开定义
#是否需要数据增强呢?一般一开始不会考虑增加数据增强
trainT = T.Compose([T.RandomCrop(28) #沿用Fashion-MNIST的风格,决定使用28x28的尺寸
                  # ,T.RandomRotation(degrees=[-30,30])
                   ,T.ToTensor()
                   ,T.Normalize(mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 
0.225])]
                   #由于是实拍数据集,使用ImageNet的方差和偏差进行归一化
                   #也可以尝试MNIST数据集的数值
                 )
testT = T.Compose([T.CenterCrop(28) #测试集不需要数据增强,因此使用CenterCrop
                 ,T.ToTensor()
                 ,T.Normalize(mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 
0.225])])
#正式导入数据
train = torchvision.datasets.SVHN(root ='I:\F盘 + 代码\F盘datasets\SVHN'
                                 ,split ="train"
                                 ,download = False
                                 ,transform = trainT
                                 )
test = torchvision.datasets.SVHN(root ='I:\F盘 + 代码\F盘datasets\SVHN'
                                 ,split ="test"
                                 ,download = False
                                 ,transform = testT
                               )
#查看增强后的数据
plotsample(train)

3 基于经典架构构筑自己的网络

#基于小型数据集,首先考虑使用各个经典架构中比较浅、但学习能力又比较强的架构
#比如ResNet18、VGG16、Inception也可以考虑
torch.manual_seed(1412)
resnet18_ = m.resnet18()
vgg16_ = m.vgg16() #VGG本来参数量就很大,因此我个人较少使用vgg16_bn
#查看网络架构,从中选择我们希望使用的部分
#时刻要注意原始架构是在ImageNet数据集上构建的,图像的尺寸、输入、输出都发生了变化
#小图像尺寸意味着池化层/步长为2的卷积层出现的次数有限,惯例来说只能出现2次,最终的特征图尺寸需要
是7x7
#输出变化意味着最后的层需要自己写
resnet18_
vgg16_
class MyResNet(nn.Module):
    def __init__(self):
        super().__init__()
        
        self.block1 = 
nn.Sequential(nn.Conv2d(3,64,kernel_size=3,stride=1,padding=1,bias=False)
                                   ,resnet18_.bn1
                                   ,resnet18_.relu) #删除池化层
        #后续的架构直接从经典架构中选
        #对尺寸很小的数据集而言,我们的深度本来就不深,因此可以试着在特征图数量上有所增加(增加
宽度)
        self.block2 = resnet18_.layer2
        self.block3 = resnet18_.layer3
        #自适应平均池化+线性层,此处都与残差网络一致
        self.avgpool = resnet18_.avgpool
        #输出的线性层自己写,以确保输出的类别数量正确
        self.fc = nn.Linear(in_features=256, out_features=10, bias=True)
    
    def forward(self,x):
        x = self.block1(x)
        x = self.block2(x)
        x = self.block3(x)
        x = self.avgpool(x)
        x = x.view(x.shape[0],256)
        x = self.fc(x)
        return x
class MyVgg(nn.Module):
    def __init__(self):
        super().__init__()
        
        #在9层之后增加一个单独的卷积层,再加入池化层,构成(卷积x2+池化) + (卷积x3+池化)的类
似AlexNet的结构
        self.features = nn.Sequential(*vgg16_.features[0:9] #星号用于解码
                                     ,nn.Conv2d(128, 128, kernel_size=3, 
stride=1, padding=1)
                                     ,nn.ReLU(inplace=True)
                                     ,nn.MaxPool2d(2,2, padding=0, dilation=1, 
ceil_mode=False))
        #进入线性层时输入通道数发生变化,因此线性层需要重写
        #输出层也需要重写
        self.avgpool = vgg16_.avgpool
        self.fc = nn.Sequential(nn.Linear(7*7*128, out_features=4096,bias=True)
                               ,*vgg16_.classifier[1:6]
                               ,nn.Linear(in_features=4096, 
out_features=10,bias=True))
    
    def forward(self,x):
        x = self.features(x)
        x = self.avgpool(x)
        x = x.view(x.shape[0],7*7*128)
        x = self.fc(x)
        return x
from torchinfo import summary
summary(MyResNet(),(10,3,28,28),depth=1,device="cpu") 
summary(MyVgg(),(10,3,28,28),depth=1,device="cpu")
#残差网络的参数量少很多,但是总计算量是VGG的两倍还多
#同时,VGG模型占用的内存更大,所以VGG需要更大的显存,但在GPU上VGG理论上应该更快
#在这个过程中,我们是从已经实例化的类中直接复制层来使用
#因此我们复用经典架构的部分,参数已经被实例化好了      

#因此实例化具体的MyResNet()时没有参数生成
[*MyResNet().block2[0].parameters()][0][0][0] #复制的部分参数是一致的
[*resnet18_.layer2[0].conv1.parameters()][0][0][0]
#没有复用经典架构的部分,则在我们实例化网络的时候才有参数
[*resnet18_.fc.parameters()] #自己设立的部分参数是不同的
[*MyResNet().fc.parameters()]                             

从结构上来看,VGG天生就比残差网络劣势一些:在两个池化层/步长为2的卷积层之间,残差网络可以利用残差快提供更多的卷积层。在我们的架构中,拥有两个大Layers的残差网络拥有9个卷积层,而对于仅仅使用普通的卷积层堆叠的VGG来说,在参数量高于残差网络2倍的前提下,却只能拥有5个卷积层。

4 一套完整的训练函数

4.1 迭代与预测
def IterOnce(net,criterion,opt,x,y):
    """
   对模型进行一次迭代的函数
    
   net: 实例化后的架构
   criterion: 损失函数
   opt: 优化算法
   x: 这一个batch中所有的样本
   y: 这一个batch中所有样本的真实标签
   """
    sigma = net.forward(x)
    loss = criterion(sigma,y)
    loss.backward()
    opt.step()
    opt.zero_grad(set_to_none=True) #比起设置梯度为0,让梯度为None会更节约内存
    yhat = torch.max(sigma,1)[1]
    correct = torch.sum(yhat == y)
    return correct,loss
def TestOnce(net,criterion,x,y):
    """
   对一组数据进行测试并输出测试结果的函数
    
   net: 经过训练后的架构
   criterion:损失函数
   x:要测试的数据的所有样本
   y:要测试的数据的真实标签
   """
    #对测试,一定要阻止计算图追踪
    #这样可以节省很多内存,加速运算
    with torch.no_grad(): 
        sigma = net.forward(x)
        loss = criterion(sigma,y)
        yhat = torch.max(sigma,1)[1]
        correct = torch.sum(yhat == y)
    return correct,loss
4.2 提前停止

优化算法以寻找损失函数的全局最小值作为目的,理想状态下,当算法找到了全局最优时神经网络就“收敛”了,迭代就会停止。然而遗憾的是,我们并不知道真正的全局最小值是多少,所以无法判断算法是否真正找到了全局最小值。其次,一种经常发生的情况可可能是,算法真实能够获取的局部最小值为0.5,且优化算法可能在很短的时间内就锁定了(0.500001, 0.49999)之间的范围,但由于学习率等超参数的设置问题,始终无法到达最小值0.5。这两种情况下优化算法都会持续(无效地)迭代下去,因此我们会需要人为来停止神经网络。我们只会在两种情况下停止神经网络的迭代:

  1. 神经网络已经达到了足够好的效果(非常接近收敛状态),持续迭代下去不会有助于算法效果,比如说,会陷入过拟合,或者会让模型停滞不前
  2. 神经网络的训练时间太长了,即便我们知道它还没有找到最优结果

这两种情况中的第二种非常容易理解,就是我们设置epochs来控制迭代次数,当所有的epochs都循环完毕,迭代自动也就停止了。在卷积训练的时候,我们通常会设置较小的epochs先尝试一下,如果算法往正确的方向迭代,我们才会设置更大的epochs来继续迭代。数据量巨大的时候我们常使用这样的方法来避免算法进入几天几夜的训练流程、最终却得到糟糕的结果。

第一种情况就比较复杂了。首先,我们必须先定义什么是”足够好的效果”。就像我们之前提过多次的,神经网络通过降低损失函数上的值来求解参数w和b,所以只要损失函数的值持续减小、或验证集上的分数持续上升,我们就可以认为神经网络的效果还有提升的空间。在实际的训练流程中,刚开始训练神经网络时,测试集和训练集上的损失一般都很高(有时,训练集上的损失比测试集上的损失还高),但随着训练次数的增多,两种损失都会开始快速下降,一般训练集下降得更快,测试集下降得缓慢一些。直到某一次迭代时,无论我们如何训练,测试集上的损失都不再下降,甚至开始升高,此时我们就需要让迭代停下。当测试集上的损失不再下降、持续保持平稳时,继续训练会浪费训练资源,迭代下去模型也会停滞不前,因此需要停止。当测试集上的损失开始升高时,往往训练集上的损失还是在稳步下降,继续迭代下去就会造成训练集损失比测试集损失小很多的情况,也就是过拟合。在过拟合之前及时停止,能够防止模型被迭代到过拟合状况下。

在这里插入图片描述

那我们如何找到这个测试集损失不再下降、准确率不再上升的“某一时间点”呢?此时,我们可以规定一个阈值,例如,当连续n次迭代中,损失函数的减小值都低于阈值tol,或者测试集的分数提升值都低于阈值tol的时候,我们就可以令迭代停止了。此时,即便我们规定的epochs还没有被用完,我们也可以认为神经网络已经非常接近“收敛”,可以将神经网络停下了。这种停止在机器学习中被称为“early stopping”。有时候,学习率衰减也可能会与early stopping结合起来。在有的神经网络中,我们或许会规定,当连续 次迭代中损失函数的减小值都低于阈值tol时,将学习率进行衰减。当然,如果我们使用的优化算法中本来就带有学习率衰减的机制,那我们则不需要考虑这点了。

在实际实现提前停止的时候,我们规定连续n次是连续5次(如果你愿意,可以设计这个值为超参数)。同时,损失函数的减小值并不是在这一轮迭代和上一轮迭代中进行比较,我们需要让本轮迭代的损失与历史迭代最小损失比较,如果历史最小损失 - 本轮迭代的损失 > tol,我们才认可损失函数减小了。这种设置对于不稳定的架构不太友好,如果我们发现模型不稳定,则可以设置较小的阈值。基于这个思路,来看具体的代码:

    """
   在测试集上的损失连续几个epochs不再降低的时候,提前停止
   val_loss: 测试集/验证集上这个epoch的损失
   """
    def __init__(self, patience=5, tol=0.0005):
        '''
       patience: 连续patience个epochs上损失不再降低的时候,停止迭代
       tol: 阈值,当新损失与旧损失之前的差异小于tol值时,认为模型不再提升
       '''
        self.patience = patience
        self.tol = tol
        self.counter = 0 #连续x次低于tol值
        self.lowest_loss = None #用于记录历史最低损失,在没有历史最低损失之前为None
        self.early_stop = False #是否出发提前停止
     
    def __call__(self, val_loss):
        '''
       val_loss:外部输入的实际损失
       '''
        if self.lowest_loss == None:
            self.lowest_loss = val_loss
        elif self.lowest_loss - val_loss > self.tol:
            self.lowest_loss = val_loss
            self.counter = 0
        elif self.lowest_loss - val_loss < self.tol:
            self.counter += 1
            print("\t NOTICI: Early stopping counter {} of 
{}".format(self.counter,self.patience))
        if self.counter >= self.patience:
            print('\t NOTICI: Early Stopping Actived')
            self.early_stop = True
        return self.early_stop
4.3 训练、测试、监控、保存权重、绘图

在这个函数中,我们将整合之前所写的全部内容,并将训练、测试、监控、保存权重等流程全部包含在
同一个函数中。

def fit_test(net,batchdata,testdata,criterion,opt,epochs,tol,modelname,PATH):
    """
    对模型进行训练,并在每个epoch后输出训练集和测试集上的准确率/损失
    以实现对模型的监控
    实现模型的保存
    
    参数说明:
    net: 实例化后的网络
    batchdata:使用Dataloader分割后的训练数据
    testdata:使用Dataloader分割后的测试数据
    criterion:所使用的损失函数
    opt:所使用的优化算法
    epochs:一共要使用完整数据集epochs次
    tol:提前停止时测试集上loss下降的阈值,连续5次loss下降不超过tol就会触发提前停止
    modelname:现在正在运行的模型名称,用于保存权重时作为文件名
    PATH:将权重文件保存在path目录下
    
    """
    
    SamplePerEpoch = batchdata.dataset.__len__() #整个epoch里有多少个样本
    allsamples = SamplePerEpoch*epochs
    trainedsamples = 0
    trainlosslist = []
    testlosslist = []
    early_stopping = EarlyStopping(tol=tol)
    highestacc = None
    
    for epoch in range(1,epochs+1):
        net.train()
        correct_train = 0
        loss_train = 0
        for batch_idx, (x, y) in enumerate(batchdata):
            y = y.view(x.shape[0])
            correct, loss = IterOnce(net,criterion,opt,x,y)
            trainedsamples += x.shape[0]
            loss_train += loss
            correct_train += correct
            
            if (batch_idx+1) % 125 == 0:
                #现在进行到了哪个epoch
                #现在训练到了多少个样本
                #总共要训练多少个样本
                #现在的训练的样本占总共需要训练的样本的百分比
                print('Epoch{}:[{}/{}({:.0f}%)]'.format(epoch
                                                       ,trainedsamples
                                                       ,allsamples
                                                       ,100*trainedsamples/allsamples))
            
        TrainAccThisEpoch = float(correct_train*100)/SamplePerEpoch
        TrainLossThisEpoch = float(loss_train*100)/SamplePerEpoch #平均每个样本上的损失
        trainlosslist.append(TrainLossThisEpoch)
    
        #每次训练完一个epoch,就在测试集上验证一下模型现在的效果
        net.eval()
        loss_test = 0
        correct_test = 0
        loss_test = 0
        TestSample = testdata.dataset.__len__()

        for x,y in testdata:
            y = y.view(x.shape[0])
            correct, loss = TestOnce(net,criterion,x,y)
            loss_test += loss
            correct_test += correct

        TestAccThisEpoch = float(correct_test * 100)/TestSample
        TestLossThisEpoch = float(loss_test * 100)/TestSample
        testlosslist.append(TestLossThisEpoch)
        
        #对每一个epoch,打印训练和测试的结果
        #训练集上的损失,测试集上的损失,训练集上的准确率,测试集上的准确率
        print("\t Train Loss:{:.6f}, Test Loss:{:.6f}, Train Acc:{:.3f}%, Test Acc:{:.3f}%".format(TrainLossThisEpoch
                                                                                                  ,TestLossThisEpoch
                                                                                                  ,TrainAccThisEpoch
                                                                                                  ,TestAccThisEpoch))
        
        #如果测试集准确率出现新高/测试集loss出现新低,那我会保存现在的这一组权重
        if highestacc == None: #首次进行测试
            highestacc = TestAccThisEpoch
        if highestacc < TestAccThisEpoch:
            highestacc = TestAccThisEpoch
            '''
            该代码将神经网络模型的参数保存到指定路径的文件中,用于后续模型的加载和使用。具体来说,net.state_dic t()返回一个包含模型参数的Ordered Dict对象,torch.save()将该对象序列化并写入文件中。
            '''
            torch.save(net.state_dict(),os.path.join(PATH,modelname+".pt"))
            print("\t Weight Saved")
        
        #提前停止
        early_stop = early_stopping(TestLossThisEpoch)
        if early_stop == "True":
            break
            
    print("Complete")
    return trainlosslist, testlosslist


#整合超参数,完整的定义流程
#实际上在写这个函数时,我们可能需要选择优化算法
#但在这里我省略了选择优化算法的流程
def full_procedure(net,epochs,bs,modelname,PATH,lr=0.001,alpha=0.99,gamma=0,tol=10**(-5),wd=0):
    torch.manual_seed(1412)
    #分割数据,设置num_workers
    batchdata = DataLoader(train,batch_size=bs,shuffle=True
                           ,drop_last=False, num_workers=4, pin_memory=True)
    #测试集上不进行shuffle,可以加速运算
    testdata = DataLoader(test,batch_size=bs,shuffle=False
                         ,drop_last=False, num_workers=4, pin_memory=True)
    
    #损失函数,优化算法
    criterion = nn.CrossEntropyLoss(reduction="sum")
    opt = 
optim.RMSprop(net.parameters(),lr=lr,alpha=alpha,momentum=gamma,weight_decay=wd)
    
    #训练
    trainloss, testloss = 
fit_test(net,batchdata,testdata,criterion,opt,epochs,tol,modelname,PATH)
    
    return trainloss, testloss
#绘图函数
def plotloss(trainloss, testloss):
    plt.figure(figsize=(10, 7))
    plt.plot(trainloss, color="red", label="Trainloss")
    plt.plot(testloss, color="orange", label="Testloss")
    plt.xlabel('Epochs')
    plt.ylabel('Loss')
    plt.legend()
    plt.show()

在这里插入图片描述

当我们调用net.train()方法时,模型将进入训练模式。在训练模式下,一些特定的模块,例如Dropout和Batch Normalization,将被启用。这是因为在训练过程中,我们需要使用Dropout来防止过拟合,并使用Batch Normalization来加速收敛。

在训练模式下,我们需要手动计算损失函数,并使用反向传播算法来更新模型的参数。例如,我们可以使用以下代码来训练一个简单的线性回归模型:

import torch

# 定义模型
class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = torch.nn.Linear(1, 1)

    def forward(self, x):
        x = self.fc1(x)
        return x

# 创建数据集
x = torch.randn(100, 1)
y = 3 * x + 1

# 创建模型和优化器
net = Net()
optimizer = torch.optim.SGD(net.parameters(), lr=0.01)

# 进入训练模式
net.train()

# 训练模型
for epoch in range(100):
    optimizer.zero_grad()
    outputs = net(x)
    loss = torch.nn.functional.mse_loss(outputs, y)
    loss.backward()
    optimizer.step()

当我们调用net.eval()方法时,模型将进入评估模式。在评估模式下,一些特定的模块,例如Dropout和Batch Normalization,将被禁用。这是因为在评估过程中,我们不需要使用Dropout来防止过拟合,并且Batch Normalization的统计信息应该是固定的。

在评估模式下,我们可以使用模型的forward()方法来进行前向传播,并计算模型的输出。例如,我们可以使用以下代码来评估一个简单的线性回归模型:

import torch

# 定义模型
class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = torch.nn.Linear(1, 1)

    def forward(self, x):
        x = self.fc1(x)
        return x

# 创建数据集
x = torch.randn(100, 1)
y = 3 * x + 1

# 创建模型
net = Net()

# 进入评估模式
net.eval()

# 评估模型
with torch.no_grad():
    outputs = net(x)
    loss = torch.nn.functional.mse_loss(outputs, y)

示例1:使用net.train()训练一个卷积神经网络模型

在这个示例中,我们将使用net.train()方法来训练一个简单的卷积神经网络模型。我们将使用MNIST数据集来训练模型,并使用交叉熵损失函数和Adam优化器来训练模型。

import torch
from torch.utils.data import DataLoader
from torchvision.datasets import MNIST
from torchvision.transforms import ToTensor
import torch.nn.functional as F

# 定义卷积神经网络模型
class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = torch.nn.Conv2d(1, 6, 5)
        self.pool = torch.nn.MaxPool2d(2, 2)
        self.conv2 = torch.nn.Conv2d(6, 16, 5)
        self.fc1 = torch.nn.Linear(16 * 4 * 4, 120)
        self.fc2 = torch.nn.Linear(120, 84)
        self.fc3 = torch.nn.Linear(84, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = x.view(-1, 16 * 4 * 4)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

# 加载MNIST数据集
train_data = MNIST(root='data', train=True, download=True, transform=ToTensor())

# 创建DataLoader对象
train_loader = DataLoader(train_data, batch_size=64, shuffle=True)

# 创建模型和优化器
net = Net()
optimizer = torch.optim.Adam(net.parameters(), lr=0.001)

# 进入训练模式
net.train()

# 训练模型
for epoch in range(5):
    running_loss = 0.0
    for i, data in enumerate(train_loader, 0):
        inputs, labels = data

        optimizer.zero_grad()

        outputs = net(inputs)
        loss = F.cross_entropy(outputs, labels)
        loss.backward()
        optimizer.step()

        running_loss += loss.item()
        if i % 100 == 99:
            print('[%d, %5d] loss: %.3f' %
                  (epoch + 1, i + 1, running_loss / 100))
            running_loss = 0.0

在这个示例中,我们首先定义了一个包含两个卷积层和三个全连接层的卷积神经网络模型。然后,我们加载了MNIST数据集,并使用DataLoader来加载数据集。我们使用交叉熵损失函数和Adam优化器来训练模型。在训练模式下,我们手动计算了交叉熵损失函数,并使用反向传播算法来更新模型的参数。

示例2:使用net.eval()评估一个卷积神经网络模型
在这个示例中,我们将使用net.eval()方法来评估一个已经训练好的卷积神经网络模型。我们将使用MNIST数据集来评估模型,并计算模型的准确率。

import torch
from torch.utils.data import DataLoader
from torchvision.datasets import MNIST
from torchvision.transforms import ToTensor
import torch.nn.functional as F

# 定义卷积神经网络模型
class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = torch.nn.Conv2d(1, 6, 5)
        self.pool = torch.nn.MaxPool2d(2, 2)
        self.conv2 = torch.nn.Conv2d(6, 16, 5)
        self.fc1 = torch.nn.Linear(16 * 4 * 4, 120)
        self.fc2 = torch.nn.Linear(120, 84)
        self.fc3 = torch.nn.Linear(84, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = x.view(-1, 16 * 4 * 4)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

# 加载MNIST数据集
test_data = MNIST(root='data', train=False, download=True, transform=ToTensor())

# 创建DataLoader对象
test_loader = DataLoader(test_data, batch_size=64, shuffle=False)

# 加载模型
net = Net()
net.load_state_dict(torch.load('model.pth'))

# 进入评估模式
net.eval()

# 评估模型
correct = 0
total = 0
with torch.no_grad():
    for data in test_loader:
        images, labels = data
        outputs = net(images)
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

print('Accuracy of the network on the 10000 test images: %d %%' % (
    100 * correct / total))

在这个示例中,我们首先定义了一个包含两个卷积层和三个全连接层的卷积神经网络模型。然后,我们加载了MNIST数据集,并使用DataLoader来加载数据集。我们加载了已经训练好的模型,并使用net.eval()方法来进入评估模式。在评估模式下,我们使用模型的forward()方法来进行前向传播,并计算模型的输出。最后,我们计算了模型的准确率。

nn.CrossEntropyLoss() 函数是 PyTorch 中用于计算交叉熵损失的函数。

其中 reduction 参数用于控制输出损失的形式。

当 reduction=‘none’ 时,函数会输出一个形状为 (batch_size, num_classes) 的矩阵,表示每个样本的每个类别的损失。

当 reduction=‘sum’ 时,函数会对矩阵求和,输出一个标量,表示所有样本的损失之和。

当 reduction=‘mean’ 时,函数会对矩阵求平均,输出一个标量,表示所有样本的平均损失。

在这里,需要对num_worker与pin_memory两个参数进行一下特别说明。num_workers很容易理解,它代表允许CPU运行的线程数,num_workers越高,CPU上并行的线程就越多,计算就越快。

pin_memory则控制是否将生成的数据放置在锁页内存中。在计算机中,内存是运行程序的空间,硬盘(又叫虚拟内存)是储存文件的空间。通常来说,当内存不够用时,计算机会向硬盘“借用”一些空间来支持程序的裕兴,此时数据和程序需要在内存与硬盘之间交换,程序运行的速度就会减慢。如果不希望硬盘与内存进行交换,我们可以设置“锁页内存”。锁页内存中的资源只允许在内存中存放,不允许借用硬盘资源。当内存资源比较充足时,我们可以设置pin_memory=True,让生成的tensor都属于锁页内存。在这个模式下,锁页内存中的tensor与程序都不需要与硬盘进行交互,在CPU运行时更快,转义到GPU上的速度也会更快,这可以极大程度地加速整个运算流程。然而,当内存资源不足时,设置pin_memory=True可能会出现大量警告。我们可以根据自己的硬件来调整这个参数,pin_memory能够发挥的效果与电脑硬件性能有很大的关系。

4.4 完整函数的GPU版
#设置cuda上的随机数种子
torch.cuda.manual_seed(1412)
torch.cuda.manual_seed_all(1412)
#设置GPU版本的fit_test
def fit_test(net,batchdata,testdata,criterion,opt,epochs,tol,modelname,PATH):
    
    """
   对模型进行训练,并在每个epoch后输出训练集和测试集上的准确率,以实现模型监控
   """
    
    SamplePerEpoch = batchdata.dataset.__len__()
    allsamples = SamplePerEpoch*epochs
    trainedsamples = 0
    trainlosslist = []
    testlosslist = []
    early_stopping = EarlyStopping(tol=tol)
    highestacc = None
    
    for epoch in range(1,epochs+1):
        #训练
        net.train()
        correct_train = 0
        loss_train = 0
        for batch_idx, (x, y) in enumerate(batchdata):
            #non_blocking 非阻塞=True,表示允许多个线程同时占用一个资源
            #一般来说,一段代码/数据占用一部分计算资源时,该资源是不对其他代码/数据开放的
            #此时被占用的资源叫做临界资源,正在运行的代码/数据被叫做临界区
            #设置non_blocking=True,相当于允许多个代码在临界资源上运行
            #可以加速运算
            x = x.to(device,non_blocking=True)
            y = y.to(device,non_blocking=True).view(x.shape[0])
            correct,loss = IterOnce(net,criterion,opt,x,y)
            
            #计算样本总量、总的correct与loss
            trainedsamples += x.shape[0]
            correct_train += correct
            loss_train += loss
            
            #监控训练进程
            if (batch_idx+1) % 125 == 0:
                print('Epoch{}:[{}/{}({:.0f}%)]'.format(epoch,trainedsamples
,allsamples,100*trainedsamples/allsamples))
        TrainAccThisEpoch = float(correct_train*100)/SamplePerEpoch
        TrainLossThisEpoch = float(loss_train*100)/SamplePerEpoch
        trainlosslist.append(TrainLossThisEpoch)
        
        #清理GPU内存
        #PyTorch使用缓存内存分配器来加速内存分配
        #因此基于PyTorch进行的内存管理机制是比较复杂的
        del x,y,correct,loss,correct_train,loss_train
        gc.collect()
        torch.cuda.empty_cache()
        
        #测试
        net.eval()
        
        #测试集上的准确率     
        correct_test = 0
        loss_test = 0
        TestSample = testdata.dataset.__len__()
        
        for x,y in testdata:
            x = x.to(device,non_blocking=True)
            y = y.to(device,non_blocking=True).view(x.shape[0])
            correct,loss = TestOnce(net,criterion,x,y)
            correct_test += correct
            loss_test += loss
            
        TestAccThisEpoch = float(correct_test*100)/TestSample
        TestLossThisEpoch = float(loss_test*100)/TestSample
        testlosslist.append(TestLossThisEpoch)
        
        #清理GPU内存
        del x,y,correct,loss,correct_test, loss_test
        gc.collect()
        torch.cuda.empty_cache()
        
        #对每一个epoch,打印训练和测试结果
        print("\t Train Loss:{:.6f}, Test Loss:{:.6f}, Train Acc:{:.3f}%, Test 
Acc:{:.3f}%".format(TrainLossThisEpoch, TestLossThisEpoch, TrainAccThisEpoch, 
TestAccThisEpoch))
        
        #对每一个epoch,保存测试集准确率最高的模型的权重
        if (highestacc == None) or (highestacc < TestAccThisEpoch):
            highestacc = TestAccThisEpoch
            torch.save(net.state_dict(),os.path.join(PATH,modelname+".pt"))
            print("\t Weights Saved")
        
        #提前停止
        early_stop = early_stopping(TestLossThisEpoch)
        
        if early_stop == Truebreak
    
    print("【DONE】")
    return trainlosslist, testlosslist   

def full_procedure(net,epochs,bs,modelname,PATH,lr=0.001,alpha=0.99,gamma=0,tol=10**(-5),wd=0):
    
    torch.manual_seed(1412)
    #分割数据,设置num_workers会延长GPU的等待速度因此一般在GPU状态下不设置
    #取消num_workers,增加pin_memory=True
    batchdata = 
DataLoader(train,batch_size=bs,shuffle=True,drop_last=False,pin_memory=True)
    testdata = 
DataLoader(test,batch_size=bs,shuffle=False,drop_last=False,pin_memory=True)
    
    #损失函数,优化算法
    criterion = nn.CrossEntropyLoss(reduction="sum")
    opt = 
optim.RMSprop(net.parameters(),lr=lr,alpha=alpha,momentum=gamma,weight_decay=wd)
    
    #训练
    trainloss, testloss = 
fit_test(net,batchdata,testdata,criterion,opt,epochs,tol,modelname,PATH)
    
    return trainloss, testloss
#绘图函数
def plotloss(trainloss, testloss):
    plt.figure(figsize=(10, 7))
    plt.plot(trainloss, color="red", label="Trainloss")
    plt.plot(testloss, color="orange", label="Testloss")
    plt.xlabel('Epochs')
    plt.ylabel('Loss')
    plt.legend()
    plt.show()
#在PyTorch中对GPU显存进行管理的一系列办法
torch.cuda.is_available() #查看是否有可用GPU
torch.cuda.device_count() #查看GPU数量
torch.cuda.get_device_name(device) #查看指定GPU名称
torch.cuda.memory_allocated() #查看现在GPU内存的占用情况(现状)
torch.cuda.max_memory_allocated() #查看GPU运行以来占用的最大内存量(峰值)
torch.cuda.memory_reserved() #返回缓存内存分配器正在使用的GPU内存(以字节为单位)。
torch.cuda.get_device_capability(device) #查看指定GPU显存容量
torch.cuda.empty_cache() #清空内存分配器中未使用的缓存内存,但不会清空张量内存
#我们通常会依赖的内存显示
nvidia-smi                 

5 模型选择

当所有准备工作都完成后,我们开始进入模型选择的阶段。在这里我们让两个不同的架构分别运行5次,每次迭代3个epochs,以此来观察两个架构的稳定性及潜力。在有足够算力的情况下,我们可以运行更多次、或者更多的epochs,一般来说,能够在每个架构上运行5次,每次5+epochs是最保险的情况。

#回顾模型架构
from torchinfo import summary
for name,i in[("ResNet",MyResNet()),("VGG",MyVgg())]:
    print("\n")
    print(name)
    print(summary(i,input_size=(10,3,28,28),depth=2,device="cpu"))
    print("\n")
'''
===========TIME WARNING==========
===========运行时间警告===========
CPU:Intel i5-10600 @4.10Ghz
GPU:RTX 2060S GPU
MyResNet
 1 epoch on CPU: 3mins~3mins10s
 1 epoch on GPU: 22s~23s
MyVGG
 1 epoch on CPU: 5min50s~6mins
 1 epoch on GPU:20s~22s
===========运行时间警告============
===========TIME WARNING===========
'''
#建立目录用于存储模型选择结果
PATH = r"D:\Pythonwork\DEEP LEARNING\WEEK 9\models\ModelSelection"
#使用函数full_procedure中的默认参数,在模型选择时加入时间计算
#基于现有显存,batch_size被设置为256,对于CPU而言最好从64开始设置
#MyResNet
avgtime = []
for i in range(5):
    torch.manual_seed(1412)
    resnet18_ = m.resnet18()
    net = MyResNet().to(device,non_blocking=True)
    start = time() #计算训练时间
    trainloss, testloss = full_procedure(net,epochs=3, bs=256
                                         ,modelname="model_seletion_resnet"
                                         ,PATH = PATH)
    avgtime.append(time()-start)
    
#循环完毕之后记得删除网络,清GPU内存
del net
gc.collect()
torch.cuda.empty_cache()
print(np.mean(avgtime))
#MyVGG
avgtime = []
for i in range(5):
    torch.manual_seed(1412)
    vgg16_ = m.vgg16_bn()
    net = MyVgg().to(device,non_blocking=True)
    start = time() #计算训练时间
    #此时使用的是full_procedure的默认参数
    trainloss, testloss = full_procedure(net,epochs=3, bs=256
                                         ,modelname="model_seletion_vgg"
                                         ,PATH = PATH)
    avgtime.append(time()-start)

del net
gc.collect()
torch.cuda.empty_cache()
print(np.mean(avgtime))    

首先在GPU上运行,来看残差网络:

在这里插入图片描述

再来看VGG:

在这里插入图片描述

从评估结果来看,虽然SVHN数据集尺寸小、数据量也不多,但要达到其benchmark上所展示的结果,还是需要一些技巧和较长的训练时间,要知道在Fashion-MNIST数据集上,即便使用类似于现在的MyVGG的结构,也能够轻易达到大约85%左右的起点。而在现有架构上,残差网络和VGG都有各自的问题。

  • 偏差

首先来评估模型在准确率上的表现。从前3个epochs的结果来看,残差网络的起点明显比VGG高很多,经过一个epoch,残差网络在训练集上基本能够达到45%以上的准确率,但VGG最高只能达到32%,大部分时候都在25%徘徊。从损失上看,VGG首次迭代时损失总是奇高无比,残差网络在训练集上的损失相对稳定。3个epochs后,几乎每轮迭代中,残差网络在训练集上能够达到的水平一路走高,基本会超出VGG大约10%以上,但测试集上的表现两者相差不多,都在75%~85%之间徘徊。现在的结果说明MyVgg类表现出来的学习能力不足,残差网络表现出来的学习能力较强,但是泛化能力上两者不分伯仲。

  • 方差
    残差网络在训练集上的表现基本稳定,不仅每次训练时都一路走高,并且3个epochs后得到的结果比较相似,但在测试集上就是疯狂跳舞,5次训练中有4次都出现了测试集表现“先高再低”的情况,并且都在第三个epoch处就触发了第一次"提前停止"的阈值,这可能意味着模型在测试集上的表现高度不稳定。

VGG同样在训练集上一路走高,但3个epochs之后得到的准确率上下存在8%-10%的波动,这一点在测试集上也表现了出来。从结果来看,两个架构的都不太稳定,但VGG比残差网络更不稳定。

  • 过拟合与欠拟合
    模型是否过拟合需要在大量迭代之后才能观察出来,因为训练前期训练集与测试集的损失都很高,即便训练集的损失比测试集的损失低一些,也不能说明模型过拟合,因此通常在3个epochs下是看不出什么趋势的。不过根据经验,残差网络的过拟合可能是一个潜在的问题,毕竟残差网络是学习能力十分强大的网络,而我们现在所使用的数据集是小数据集。

在这里插入图片描述

  • 效率
    从运行结果中时间的记录来看,残差网络与VGG在GPU上的计算速度基本相当,两者差异不大。在CPU上我们也做了1个epoch的实验,残差网络明显比VGG计算更快,表现更好:

在这里插入图片描述

到这里,我们基本上已经可以确定模型继续下去的方向了——残差网络现在有更好的基础,我们可以继续训练下去进行观察。对VGG架构而言,目前来看最有效的方向可能是加深网络,比如将5个卷积层增加到8个,但这样会要求更多的算力和时间。在课程中,我们将就残差网络继续调优下去,但我衷心建议大家可以尝试继续加深VGG来进行探索,很有可能VGG会展现出比残差网络更稳定的结果。

6 模型调优

基于自定义的残差网络,我们来进行模型的调优。模型调优与机器学习中的模型调参类似,但又不太相同。模型调参是通过改变参数来左右模型的表现,但深度学习架构本身带有一些随机性、数据处理也带有随机性、就连复现网络结果都非常困难,更不要提试图通过参数去极大地影响模型的表现,所以模型调优更多是在神经网络上不断尝试、观察现象并被动地做出调整的过程。在这个过程中,我们基本遵循一下思路:
1、首先增加迭代次数,观察训练集和测试集上的损失是否都呈现为下降趋势
2、如果呈现下降趋势,则需要增加训练次数,把模型训练至测试集的损失不再下降为止(触发提前停止) 如果不呈现下降趋势(欠拟合),可能需要立刻更换架构,直到损失呈现下降趋势为止
3、当测试集损失不再下降时,停止训练并观察模型的情况,针对现存的问题对症下药 例如,模型过拟合,就采取防止过拟合的操作。模型不稳定,就分析具体原因,让模型变得稳定。
4、如果“对症下药”的所有操作不能够再次提升测试集上的准确率 则必须考虑重做数据上的特征工程、引入论文中的各项技术、或更换更强大的架构现在我们的残差网络才刚被训练过3个epochs,除了起点高之外几乎看不出趋势。因此我们需要迭代更多的迭代次数,来观察模型的学习能力。

6.1 更多迭代次数

首先,我们就刚才选出的架构试着迭代10个epochs。

'''
===========TIME WARNING==========
===========运行时间警告===========
CPU:Intel i5-10600 @4.10Ghz
GPU:RTX 2060S GPU
MyResNet
 1 epoch on CPU: 3mins~3mins10s
 1 epoch on GPU: 22s~23s
===========运行时间警告============
===========TIME WARNING===========
'''
PATH = r"D:\Pythonwork\DEEP LEARNING\WEEK 9\models\ConfirmedResNet"
modelname = "myResNet_test0"
print(modelname)
torch.manual_seed(1412)
resnet18_ = m.resnet18()
net = MyResNet().to(device,non_blocking=True)
start = time() #计算训练时间
trainloss, testloss = full_procedure(net,epochs=10, bs=256
                                     ,modelname=modelname
                                     ,PATH = PATH)
print(time()-start)
plotloss(trainloss,testloss)

在这里插入图片描述

在这里插入图片描述

从训练集的结果来看,残差网络持续在进行学习,并且随着epochs的升高,训练集上的准确率有提升到95%以上的可能,测试集虽然高度不稳定,但整体还是呈现下降的趋势。过拟合问题存在,但并不严重。测试集上不稳定的问题可能会随着模型训练次数的增加而缓解,过拟合问题也类似,于是在将降低提前停止阈值设置为10^(-10) 、其他参数不变的基础上,我连续了训练了三次(test1,test2,test3),
每次30个epochs,以此来逼近现在模型的极限。

PATH = r"D:\Pythonwork\DEEP LEARNING\WEEK 9\models\ConfirmedResNet"
#调小提前停止阈值,再进行三次训练,每次30个epochs
for modelname in ["MyResNet_test1","MyResNet_test2","MyResNet_test3"]:
    print(modelname)
    torch.manual_seed(1412)
    resnet18_ = m.resnet18()
    net = MyResNet().to(device,non_blocking=True)
    start = time() #计算训练时间
    trainloss, testloss = full_procedure(net,epochs=30, bs=256
                                         ,modelname=modelname
                                         ,PATH = PATH
                                         ,tol = 10**(-10))
    print(time()-start)
    plotloss(trainloss,testloss)

理想的情况是,我们会训练到测试集上的损失下降小于10^(-10)为止。训练得到的结果如下:

在这里插入图片描述

遗憾的是,没有一次训练完成了30个epochs,所有训练都在20+epochs处触发了提前停止。在如此低的提前停止阈值下不能完成30个epochs,说明30个epochs之内模型的泛化能力已经触底。从图像趋势上来看,前期激烈的损失波动确实有些惊人,但随着模型迭代,测试集上的损失确实变得相对稳定了。然而,测试集上的损失并没有形成稳定下降的趋势,而已经开始出现了升高的趋势,这说明继续训练下
去,模型会朝着过拟合的方向发展,假设我们继续将阈值调低,模型被允许继续训练,测试集的损失可能会开始走高。

第一次训练最后5个epochs的结果:

在这里插入图片描述

第二次训练最后5个epochs:

在这里插入图片描述

第三次训练最后5个epochs:

在这里插入图片描述

再来看准确率。在经过20个epochs之后,训练集还在稳步缓慢的上升中,但测试集的准确率基本徘徊在94%~95%之间不再提升。在这个架构和这一组参数下,测试集的准确率可以被认为已经达到天花板。现在我们必须从架构和参数上寻求突破,力求打破现在的瓶颈,让测试集准确率继续上升。但调整架构和调整参数是完全不同的操作,两种手段各有利弊——

1、调整架构意味着我们否定现有模型的学习能力和潜力,之前的训练全部作废,必须从0开始训练模型。如果你有GPU资源、或者你的数据集比较小,我们或许可以洒脱地修改架构,但在大型数据集上,重新建模几乎是不太可能的。只有当我们的训练结果看起来实在没有明显问题可以调整的时候,我们才会选择重建架构。
2、调整参数可以依赖于现在已经训练过的结果继续进行,因此成本较小。但缺点是,参数调整必须依赖于模型有明显问题可以“对症下药”,否则乱调参数无端消耗时间。对SVHN数据集而言,现在的架构中规中矩——我们适当地使用了数据增强,使用了提前停止,并且测试集上的准确率也趋于平稳,因此架构现在并没有特别突出的问题。但如果可能的话,我们还是希望模型能够尽量地再平稳一些,同时如果可能地话,在后续迭代中控制住过拟合。模型不稳定、模型过拟合是一般迭代过程中最常见的两个问题。我们接下来就基于SVHN数据集来说明调整模型的方式。

6.2 让迭代更稳定

对许多模型来说,除了准确率不够高,最核心的问题就是测试集上的结果不太稳定。如果能够让测试集的结果更稳定,说不定模型还有继续增加迭代次数的可能。为什么模型会不稳定呢?通常可能存在的理
由有以下几个:

1)数据增强随机性太大、或者bs太小
如果在一个数据集中,我们发现训练集的准确率稳定上升、测试集准确率却在反复横跳(这种反复横跳类似于SVHN数据集训练前期测试集的情况),这说明训练数据中epochs与epochs之间差异太大,并且训练数据与测试数据差距也很大,这会导致模型在优化过程中随机性太强。这可能是因为数据集本身样本与样本之间就差异巨大,而batch_size对于现在的数据集来说太小,也有可能是我们为了防止过拟合而加上的数据增强导致的。

如果可能的话,你可以增加batch_size,如果在现有显存下,很难再增加batch_size,你可以将数据增强取消,或者削弱数据增强中随机的程度,使用更接近原始训练集的数据进行训练,可以提升测试集数据与训练集数据统计上的相似性,从而提升模型效果。当然,取消数据增强很可能降低训练集的学习难度,在已经训练了20+epochs的模型上,取消数据增强有可能直接将模型快速导向过拟合的方向。另外,如果你的训练集上的准确率也在反复横跳,这可能说明模型的学习能力存在问题。先尝试调整batch_size、数据增强等随机的部分看看是否起效,如果不起效,则认真考虑更换架构。

2)初始学习率太大,或迭代不稳定
随着训练次数的增多,测试集上的准确率也变得相对稳定,这可能说明模型的起始学习率太大,最开始的时候模型完全是在损失函数上“横冲直撞”找不到方向。为解决这个问题,我们可以降低学习率lr或增加动量参数gamma,来帮助模型稳住迭代方向。如果我们使用的是Adam作为优化算法,则可以考虑增加β值。

3)模型的置信度低
对于分类模型,神经网络的预测结果都是softmax或sigmoid函数输出的概率值。一般来说我们依赖于阈值或概率之间的相对大小对具体的类别进行判断,但概率的大小实际上也代表着模型对于自己做出的预测的自信程度(也称之为置信度)。例如,对于阈值为0.5的二分类算法而言,样本A输出的概率值为0.51,样本B的概率值输出为0.9,对着两个样本,模型都会指向预测标签1,但是模型明显更有信心将样本B分类正确。

当训练数据发生变化、模型的权重被迭代时,样本上的概率都会发生小范围变化,样本B的概率很可能依然围绕在0.9附近,但样本A的概率很可能在0.45~0.55之间摇摆,这个摇摆很可能会改变样本A的预测结果。如果一个模型对大部分样本的置信度都较低,当模型迭代时,就会存在大量不断变化预测值的样本,这些样本就会导致模型结果剧烈波动。这种情况下,说明模型或架构的学习能力不足,需要更换架构、或通过特征工程降低数据的学习难度、或增强架构的学习能力。

现在我们来尝试一下前两个手段。在训练中,我们已经通过torch.save的代码保存了测试集上准确率最高的一组权重,虽然基于各类随机性,这组权重不一定能够重现出最高的准确率,但我们认为测试准确率是一定程度上代表了模型的泛化能力的。现在我们就基于这组权重来继续训练。

#首先加载之前保存的权重
torch.manual_seed(1412)
resnet18_ = m.resnet18()
net = MyResNet()
net.load_state_dict(torch.load("D:\Pythonwork\DEEP LEARNING\WEEK 9\models\ConfirmedResNet\myResNet_test2.pt"))

加载完成后,会有如下的信息显示:

在这里插入图片描述

#修改modelname,准备好保存结果
modelname = "MyResNet_stable5"
#控制变量:每次只调整一个参数
#先调整可能对整个学习过程影响最大的参数:学习率
print(modelname)
torch.manual_seed(1412)
start = time() #计算训练时间
#降低学习率,原本的lr=0.001,现在我尝试了0.0075,0.0005,0.00025和0.0002和0.0001五种
trainloss, testloss = full_procedure(net,epochs=30, bs=256
                                     ,modelname=modelname
                                     ,PATH = PATH
                                     ,lr = 0.0001
                                     ,gamma=0
                                     ,tol = 10**(-10))
print(time()-start)
plotloss(trainloss,testloss)

得到如下的结果:

在这里插入图片描述

可以看到,将学习率调整为原来的1/10后,模型变得平稳很多,着说明最初设置的学习率0.001对于SVHN数据集来说可能有些过大了。不过,先使用大学习率找到快速到达局部最小值的附近,再使用较小的学习率找到局部最小值,是一种常见的策略。现在,学习率的缩小不仅让模型变得非常平稳,并且提升了模型在测试集上的表现。但可以看到,随着迭代次数的增加,模型是逐渐走向过拟合的。在此基础上,如果将数据增强去除,过拟合会越来越严重。现在,在模型已经比较平稳的情况下,我们再试试看参数gamma,一个较小的gamma可以帮助参数变得更加平稳。

torch.manual_seed(1412)
resnet18_ = m.resnet18()
net.load_state_dict(torch.load("D:\Pythonwork\DEEP LEARNING\WEEK 
9\models\ConfirmedResNet\MyResNet_stable5.pt"))
modelname = "MyResNet_stable6"
print(modelname)
start = time() #计算训练时间
net = net.to(device,non_blocking=True)
#基于lr非常小的考虑,同样尝试了非常小的值
trainloss, testloss = full_procedure(net,epochs=30, bs=256
                                     ,modelname=modelname
                                     ,PATH = PATH
                                     ,lr = 0.0001
                                     ,gamma = 0.0001
                                     ,tol = 10**(-10)
                                   )
print(time()-start)
plotloss(trainloss,testloss)

结果如下:

在这里插入图片描述

现在模型已经非常平稳了,从数据的状态来看较小的gamma和小学习率的结合能够让模型非常平稳。不过,模型明显陷入了过拟合的状况,两次训练中训练集都平稳下降,测试集呈现走高趋势。由于我们已经使用了数据增强,因此在这种情况下,我们可以尝试除了数据增强之外的其他对抗过拟合的方法,说不定能够再一次提升测试集上的准确率。

6.3 对抗过拟合

如果模型出现过拟合的情况,则说明至少模型在训练集的学习上并不存在问题,因此我们可以尝试各种控制过拟合的方法来试图削减训练集上的准确率、并提升测试集上的准确率(注意:如果能够成功消除过拟合,基本都是训练集准确率下降、测试集准确率上升的情况)。

通常来说,我们控制过拟合的方式有以下几种:

1)L1\L2正则化,或权重衰减
也就是在损失函数后加上权重的L1或L2范数表达式,让迭代的时候损失函数因正则项的存在而偏移,从而阻止架构“精确”地学习数据。在优化算法中,我们可以通过参数weight_decay调整权重衰减值。这个值非常敏感,因此调节难度极大,但我们还是可以尝试这么做。
2)Dropout/BN
Dropout和Batch Normalization可以控制正则化,其中Dropout多被用于含有全连接层的卷积网络,而BN基本被用于Inception及之后诞生的所有卷积网络。
3)降低batch_size/数据增强,增加数据与数据之间的不相似性
这一点正好与迭代不稳定的情况相反。如果出现过拟合,则说明我们需要增加训练数据的随机性来阻止算法对数据的学习。降低batch_size也是同理,通过将每次训练时的批次分得更小,可以有效提升每次训练时样本数据呈现出不同规律和分布的概率。
4)降低模型的复杂度
过拟合可能发生的原因之一是在简单数据上使用了过于复杂的架构。当模型复杂度很高、参数很多时,简单数据就很容易被模型“过度学习”,从而导致过拟合。如果存在这样的情况,我们可以削减模型的层、让卷积网络输出的特征图数量变少、让全连接层上输出的值变少,以此降低模型的参数量和复杂度。这个方法需要修改架构,因此当训练流程已经非常深入时,我们一般不会考虑这个方法。
5)使用提前停止Early Stopping
提前停止可以在模型刚开始出现过拟合的现象时就停止训练,而我们现在所使用的架构上迭代的结果也说明我们确实在比较恰当的地方实现了提前停止。

在这里插入图片描述

如果上面5种手段你都已经尝试过,但你的模型还是处于过拟合的状态,那你可能需要剑走偏锋了:

6)检查你是否使用了不恰当的数据处理过程方式、或参数初始化过程、或预训练过程
比如说,你训练集和测试集上使用的归一化参数不同,或者在过拟合的情况下使用了加速迭代的Xavier初始化、或者使用完全与训练数据不相关的预训练流程等,这些都可能导致模型从训练集上学到的内容和测试集完全不同。同时,还有可能是我们忽视了测试集中的一些奇特的样本,导致模型缺乏足够的“不变性”,这种情况下我们可以再次探索数据,更换数据增强的方式。

对SVHN现在的架构而言,我们在最初就已经使用了2)、5)两种防止过拟合的方法,并且我们也使用了数据增强,还出于让模型迭代更稳定的目的撤销了数据增强。现在我们可以尝试增加权重衰减参数,并且降低batch_size进行训练。我们来尝试一下:

modelname = "MyResNet_overfit"
torch.manual_seed(1412)
resnet18_ = m.resnet18()
net = MyResNet()
net.load_state_dict(torch.load("D:\Pythonwork\DEEP LEARNING\WEEK 
9\models\ConfirmedResNet\MyResNet_stable6.pt"))
#在stable6的基础上继续训练
print(modelname)
torch.manual_seed(1412)
net = net.to(device,non_blocking=True)
start = time() #计算训练时间
trainloss, testloss = full_procedure(net,epochs=30, bs=128 #令batch_size下降一半
                                     ,modelname=modelname
                                     ,PATH = PATH
                                     ,lr = 0.0001
                                     ,gamma= 0.0001
                                     ,tol = 10**(-10)
                                     ,wd = 0.00005) #wd非常敏感,因此要小心地进行调整
print(time()-start)
plotloss(trainloss,testloss)

来看下面的结果:

在这里插入图片描述

很明显,模型的过拟合进程并没有停止,随着迭代的加深,我们依然在向着过拟合的方向进发。这很可能说明模型已经突破了应该提前停止的界限,现在进行再进行过拟合调节的作用已经不大了。这种情况下,我们可以从0开始迭代,重新寻找应该提前停止的位置。

6.4 基于新参数训练

在这次迭代中,我们先设置0.001作为初次学习的学习率,允许迭代大约15个左右的epochs,然后再使用0.00001这个更小的学习率继续迭代。

PATH = r"D:\Pythonwork\DEEP LEARNING\WEEK 9\models\ConfirmedResNet"
modelname = "myResNet_test0_1"
print(modelname)
torch.manual_seed(1412)
resnet18_ = m.resnet18()
net = MyResNet().to(device,non_blocking=True) #完全重启,不借用任何过去训练好的参数
start = time() #计算训练时间
trainloss, testloss = full_procedure(net,epochs=15, bs=256
                                     ,modelname=modelname
                                     ,PATH = PATH
                                     ,lr=0.001
                                     ,tol = 10**(-10)
                                   )
print(time()-start)
plotloss(trainloss,testloss)

结果如下所示:

在这里插入图片描述

模型依然极度不稳定,但是也将测试集上的损失迭代到了0.1以下。我们保存了测试集上预测准确率最高的一组参数,接下来就依赖于这组参数继续进行迭代:

modelname = "MyResNet_retrain"
torch.manual_seed(1412)
resnet18_ = m.resnet18()
net.load_state_dict(torch.load("D:\Pythonwork\DEEP LEARNING\WEEK 
9\models\ConfirmedResNet\myResNet_test0_1.pt"))
print(modelname)
torch.manual_seed(1412)
net = net.to(device,non_blocking=True)
start = time() #计算训练时间
trainloss, testloss = full_procedure(net,epochs=30, bs=128 #令batch_size下降一半
                                     ,modelname=modelname
                                     ,PATH = PATH
                                     ,lr = 0.00001
                                     ,gamma= 0.0001
                                     ,tol = 10**(-10)
                                     ,wd = 0.00005) #wd非常敏感,因此要小心地进行调整
print(time()-start)
plotloss(trainloss,testloss)

得到的结果如下:

在这里插入图片描述

最终,我们得到了稳定在96%以上的模型,并且最高准确率为96.608%,这对于SVHN来说是一个还不错的结果,但还有更多可以调整的方向。很遗憾在有限的时间内我无法在课程中呈现所有的方法,但我可以抛砖引玉,为大家提供更多进阶的路径。

6.5 架构升级,其他可探索的方向

现在我们得到了测试集准确率在96.608%的架构,这是个不错的数字,但依然远远低于我们在训练集上可以达到的99.9%,模型现在依然处于轻度过拟合的状态。轻度过拟合说明现在使用的架构没有提取出能够令测试集做出更准确判断的特征,模型的学习能力或许已经满足了训练集,但是低于测试集的要求,因此我们现在需要再次更改模型架构,提升参数量、并提升模型的复杂度。

在这里插入图片描述

先来看现在的架构。我们现在使用了总共9个卷积层、1个输出全连接层,其中8个卷积层都在残差单元中,输出特征图数目分别是128,128,128,256,256,256,256,256。对于SVHN数据集来说,我们很难在现有的架构中继续增加特征图的数目,但增加层也会有所困难,因为SVHN的尺寸决定了我们通常来说只能有2个步长为2的卷积层,除非自己重新写架构,否则想要在成熟的残差网络架构中插入更
多的卷积层并不是容易的事儿。

在这里插入图片描述

在现有知识范围和架构下,我们是否能够将网络效果提升到更高层次呢?答案是可以,不过要使用一些非常规的手段。对于数字识别数据来说,图像中所含有的信息本身并不多,但是我们确实需要更多的层来加深对信息的提取,或许我们可以让最终输出的特征图变得更小,例如,尝试在7x7大小的特征图后继续增加卷积层或残差单元,让最终输出的特征图尺寸变小为4x4。我们来看看这种操作:

class MyResNet2(nn.Module):
    def __init__(self):
        super().__init__()
        
        self.block1 = 
nn.Sequential(nn.Conv2d(3,64,kernel_size=3,stride=1,padding=1,bias=False)
                                   ,resnet18_.bn1
                                   ,resnet18_.relu) 
        self.block2 = resnet18_.layer2
        self.block3 = resnet18_.layer3
        self.block4 = resnet18_.layer4 #其他地方都不变,但增加原始残差网络中的layer4
        self.avgpool = resnet18_.avgpool
        self.fc = nn.Linear(in_features=512, out_features=10, bias=True)
    
    def forward(self,x):
        x = self.block1(x)
        x = self.block2(x)
        x = self.block3(x)
        x = self.block4(x)
        x = self.avgpool(x)
        x = x.view(x.shape[0],512) #512个输出
        x = self.fc(x)
        return x
    
torch.manual_seed(1412)
resnet18_ = m.resnet18()
net2 = MyResNet2()
summary(net2,(10,3,28,28),depth=2,device="cpu")

这个操作会让网络中的卷积层增加到13个,让最终输出的特征图数量达到512个,模型总参数会因此进入千万级别。

在这里插入图片描述

让我们在没有数据增强和有数据增强的情况下分别尝试这个新架构:

'''
===========TIME WARNING==========
===========运行时间警告===========
CPU:Intel i5-10600 @4.10Ghz
GPU:RTX 2060S GPU
MyResNet
 1 epoch on CPU: 9min50s~10mins
 1 epoch on GPU: 18s~20s
===========运行时间警告============
===========TIME WARNING===========
'''
#没有数据增强,回归原始参数
modelname = "MyResNet2_test1"
print(modelname)
torch.manual_seed(1412)
resnet18_ = m.resnet18()
net2 = MyResNet2().to(device,non_blocking=True)
start = time() #计算训练时间
trainloss, testloss = full_procedure(net2,epochs=30, bs=256
                                     ,modelname=modelname
                                     ,PATH = PATH
                                     ,tol = 10**(-10))
print(time()-start)
plotloss(trainloss,testloss)

结果如下所示:

在这里插入图片描述

在这里插入图片描述

首先,训练集上的结果看起来和原来的架构差不多,测试集开始出现一些高分,但同时也变得极度不稳定。为了修正不稳定的问题,我们将使用之前《6.2 让迭代更稳定》中没有生效的、缩小学习率的方式:

modelname = "MyResNet2_test2"
start = time() #计算训练时间
trainloss, testloss = full_procedure(net2,epochs=30, bs=256
                                     ,modelname=modelname
                                     ,PATH = PATH
                                     ,tol = 10**(-10)
                                     ,lr = 0.0001)
print(time()-start)
plotloss(trainloss,testloss)

来看稳定性和模型结果的变化:

在这里插入图片描述

你会发现,没有经过太多参数的尝试和调整,我们的架构轻易地超过了96.608%这个准确率,得到了96.716%的准确率。在有足够算力的前提下,如果能在新架构上继续训练,想必能够将模型准确率提高至97%以上。我们通过强行加深网络、将特征图最终的尺寸修改为4x4提升了模型效果,可以看出SVHN数据集上的信息的确需要更深的神经网络来进行学习。然而,这种更改模型架构的方式毕竟还是剑走偏锋,并不能轻易推广到其他数据集或其他架构上。如果我们希望继续提升模型的效果,我建议以下的几种手段,操作难度逐渐递增:
1、尝试使用预处理、各类初始化等手段,看看能否继续优化网络
2、深入研究标签类别中,究竟哪些数字不容易被判断正确,针对这些数字或图像做特定的数据增强
3、学习论文中提到的各种手段和新兴模型,在新兴模型上尝试SVHN数据集的结果
基本上来说,能够在SVHN数据集上稳定输出更高结果(超过97%)的架构都借用了更高级的数据处理手段、初始化手段、迭代方式或者更强大的模型(像我们这样硬生生跑到接近97%的估计很少)。其中,影响数据和迭代的技术包括Maxout正则化、梯度池化、DropConnect、AutoAugment、Cutout等,更强大的模型则包括了密集链接的卷积网络(DCNN,也叫作DenseNet)、多层残差网络(Multilevel ResNet)、宽残差网络(WRN)等等。如果我们希望提升模型的效果,这些论文都可以作为参考。
4、获取最原始的数据集,重新创造图像尺寸更大的数据集,帮助我们加深网络
SVHN数据集的数字图像是经过SVHN创作团队处理后,可以被用于简单分类的图像。但SVHN数据集的原始图像是谷歌地球上街景实拍图,这些实拍图是开放下载的。我们可以找到实拍图后,重新对实拍图进行描框、生成尺寸更大的数据集(例如3x64x64),这样我们就可以更自由地使用更深的网络对SVHN数据集进行训练了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值