2 从经典架构出发
2.1 调用经典架构
我们已经学习了大量经典架构以及他们的经典思想,可以开始考虑自己的架构了。大多数时候,我们不会从0去创造我们的自己的架构,而是在经典架构中挑选一个适合的架构或适合的思路,并在经典架构上依据数据的需要对架构进行自定义和修改(当然了,我们只能够调用我们已经学过、并且掌握原理的架构,否则我们在修改的时候将无从下手)。在PyTorch中,基本所有经典架构都已经被实现了,所以我们可以直接从PyTorch中“调库”来进行使用。遗憾的是,直接调出来的大部分库是无法满足我们自己需求的,但我们还是可以调用PyTorch中的架构类来作为我们自己架构的基础。
在torchvision下,我们已经很熟悉datasets和transforms这两个模块,现在我们需要从torchvision中调用完整的模型架构,这些架构都位于“CV常用模型”模块torchvision.models里。在torchvision.models中,架构/模型被分为4大类型:分类、语义分割、目标检测/实例分割以及关键点检测、视频分类。我们之前学习的经典架构都是最基础的分类架构。
分类架构中包含以AlexNet为代表的、诞生于ILSVRC的各大架构极其变体,也包含了现在被广泛使用的众多融合多种思想的强力架构。对每种类型的架构,models中都包含了至少一个实现架构本身的父类(呈现为“驼峰式”命名)以及一个包含预训练功能的子类(全部小写)。对于拥有不同深度、不同结构的架构而言,可能还包含多个子类。以AlexNet和ResNet为例子:
import torchimport torch.nn as nnfrom torchvision import models as mdir(m) #查看models里全部的类#['AlexNet',# 'DenseNet',# 'EfficientNet',# 'GoogLeNet',# 'GoogLeNetOutputs',# 'Inception3',# 'InceptionOutputs',## 'vgg16_bn',# 'vgg19',# 'vgg19_bn',# 'video',# 'wide_resnet101_2',# 'wide_resnet50_2']#对于只有一个架构、不存在不同深度的AlexNet来说,两个类调出的结构是一模一样的m.AlexNet() #查看需要填写的参数是什么?m.alexnet() #将AlexNet父类的功能包含在里面,不允许对原始架构进行参数输入,但是可以进行预训练#对残差网络来说,父类是实现具体架构的类,子类是已经填写好必填参数的类m.ResNet() #可以从这个类中实现各种不同深度的结构m.resnet152() #具体的深度和参数都已锁定,可以在这个类上执行预训练
在实际使用模型时,我们几乎总是直接调用小写的类来进行使用,除非我们想大规模修改内部架构。如下所示,调用类的方式非常简单:
import torchvision.models as m #查看每个类中的结构resnet18_ = m.resnet18()vgg16_ = m.vgg16()resnet18_# ResNet(# (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)# (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)# (layer1): Sequential(# (0): BasicBlock(# (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# (1): BasicBlock(# (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (layer2): Sequential(# (0): BasicBlock(# (conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (downsample): Sequential(# (0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)# (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (1): BasicBlock(# (conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (layer3): Sequential(# (0): BasicBlock(# (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (downsample): Sequential(# (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)# (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (1): BasicBlock(# (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (layer4): Sequential(# (0): BasicBlock(# (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (downsample): Sequential(# (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)# (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (1): BasicBlock(# (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))# (fc): Linear(in_features=512, out_features=1000, bias=True)# )resnet18_.conv1#Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)resnet18_.layer1# Sequential(# (0): BasicBlock(# (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# (1): BasicBlock(# (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )resnet18_.layer1[0]#BasicBlock(#(conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)#(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)#(relu): ReLU(inplace=True)#(conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)#(bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)#)resnet18_.layer1[0].conv1#Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)vgg16_ = m.vgg16()vgg16_vgg16_.features[0] #需要替换掉整个层resnet18_resnet18_.conv1 = nn.Conv2d(1, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)resnet18_.fc = nn.Linear(in_features=512, out_features=10, bias=True)resnet18_ #重新实例化resnet18_ = m.resnet18()#另一种方式也可以成功修改resnet18_中显示的内容,但是实际输入数据的时候还是会报错resnet18_.conv1.in_channels = 1resnet18_ data = torch.ones(10,1,224,224)resnet18_(data)
不难发现,如果我们想要修改经典架构,我们必须逐层修改。而卷积网路的一层可能对后续的所有层都产生影响,因此我们常常只会对网络的输入、输出层进行微调,并不会修改架构的中间层。然而,大部分时候完全套用经典架构都不能满足我们建模的需求,因此我们需要基于经典架构构建我们自己的架构。
2.2 基于经典架构自建架构
尽管修改经典架构是一件冒险的事儿,我们确实有可能存在大规模修改架构的需求:比如说,几乎所有现代经典架构都是基于ImageNet数据集224×224的尺寸、1000分类构建起来的,因此几乎所有的经典架构都会有5次下采样(池化层或步长为2的卷积层)。当我们的数据集足够大时,我们会优先考虑使用经典架构在数据集上跑一跑,但当我们的图像尺寸较小时,我们不一定需要将图像拓展到224×224的尺寸以适应经典架构(我们的确可以使用transform.Resize这么做,但是放大转换后预测效果不一定会很好)。这样不仅会让算力要求提升、计算时间变长,还不一定能够获得很好的效果。如果可能的话,在较小的图片上,我们希望能够尽量保持原状以控制整体计算量。
对卷积架构来说,改变特征图的输入输出数量的行为只与一两个层有关,要改变特征图尺寸的行为则会影响整个架构。因此,我们一般会从经典架构中“抽取”一部分来进行使用,也有很小的可能会从0建立起自己的新架构。假设我们现在使用的是类似于Fashion-MNIST尺寸的,28×28的数据集,在这样的数据集上,我们可能执行下采样的机会只有2次,一次是从28×28降维到14×14,另一次是从14×14降维到7×7。这样的数据集并不是非常适合几千万、上亿参数的经典架构们。在这种情况下,torchvision.models下自带的架构就不能灵活满足需求,因此我们往往不会直接使用自带架构,而是在自带架构的基础上进行架构重构。接下来我们来学习如何基于经典架构来构筑自己的架构。
#灰度的小数据集(32x32) import torchfrom torch import nnfrom torchvision import models as mfrom torchinfo import summary#ResNet#卷积层(k=3,p=1) + 残差块 + GLOBALAVG + FCvgg16_bn_ = m.vgg16_bn() #带bn层的vggresnet18_ = m.resnet18()vgg16_bn_.features[7:14]#Sequential(#(7): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))#(8): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)#(9): ReLU(inplace=True)#(10): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))#(11): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)#(12): ReLU(inplace=True)#(13): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)#)resnet18_.layer3# Sequential(# (0): BasicBlock(# (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (downsample): Sequential(# (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)# (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )# (1): BasicBlock(# (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# (relu): ReLU(inplace=True)# (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)# (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)# )# )
class MyNet1(nn.Module):def __init__(self):super().__init__()self.conv1 = nn.Sequential(nn.Conv2d(1,64,kernel_size=3,stride=1,padding=1) ,nn.BatchNorm2d(64) ,nn.ReLU(inplace=True))self.block2 = vgg16_bn_.features[7:14]self.block3 = resnet18_.layer3#此时特征图的尺寸8*8,特征图的数量为256self.avgpool = resnet18_.avgpool #-> 1x1self.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)return xdata = torch.ones(10,1,32,32)net = MyNet1()net(data).shape#torch.Size([10, 10])summary(net,input_size=(10,1,32,32),depth=3,device="cpu")# ==========================================================================================# Layer (type:depth-idx) Output ShapeParam ## ==========================================================================================# ├─Sequential: 1-1[10, 64, 32, 32]--# |└─Conv2d: 2-1 [10, 64, 32, 32]640# |└─BatchNorm2d: 2-2[10, 64, 32, 32]128# |└─ReLU: 2-3 [10, 64, 32, 32]--# ├─Sequential: 1-2[10, 128, 16, 16] --# |└─Conv2d: 2-4 [10, 128, 32, 32] 73,856# |└─BatchNorm2d: 2-5[10, 128, 32, 32] 256# |└─ReLU: 2-6 [10, 128, 32, 32] --# |└─Conv2d: 2-7 [10, 128, 32, 32] 147,584# |└─BatchNorm2d: 2-8[10, 128, 32, 32] 256# |└─ReLU: 2-9 [10, 128, 32, 32] --# |└─MaxPool2d: 2-10 [10, 128, 16, 16] --# ├─Sequential: 1-3[10, 256, 8, 8] --# |└─BasicBlock: 2-11[10, 256, 8, 8] --# ||└─Conv2d: 3-1[10, 256, 8, 8] 294,912# ||└─BatchNorm2d: 3-2 [10, 256, 8, 8] 512# ||└─ReLU: 3-3[10, 256, 8, 8] --# ||└─Conv2d: 3-4[10, 256, 8, 8] 589,824# ||└─BatchNorm2d: 3-5 [10, 256, 8, 8] 512# ||└─Sequential: 3-6[10, 256, 8, 8] 33,280# ||└─ReLU: 3-7[10, 256, 8, 8] --# |└─BasicBlock: 2-12[10, 256, 8, 8] --# ||└─Conv2d: 3-8[10, 256, 8, 8] 589,824# ||└─BatchNorm2d: 3-9 [10, 256, 8, 8] 512# ||└─ReLU: 3-10 [10, 256, 8, 8] --# ||└─Conv2d: 3-11 [10, 256, 8, 8] 589,824# ||└─BatchNorm2d: 3-12[10, 256, 8, 8] 512# ||└─ReLU: 3-13 [10, 256, 8, 8] --# ├─AdaptiveAvgPool2d: 1-4 [10, 256, 1, 1] --# ├─Linear: 1-5[10, 10]2,570# ==========================================================================================# Total params: 2,325,002# Trainable params: 2,325,002# Non-trainable params: 0# Total mult-adds (M): 365.76# ==========================================================================================# Input size (MB): 0.04# Forward/backward pass size (MB): 65.54# Params size (MB): 9.30# Estimated Total Size (MB): 74.88# ==========================================================================================
借用经典架构,我们就不必再重新打造整个网络,但缺点是网络的架构和具体的层无法在代码中清晰地显示出来,同时层与层内部的层次结构也不一致,如果我们需要将代码提供给同事或他人进行使用,最好将代码重写为基本层构成的网络,或者准备好完整的批注。
除了在残差网络上进行修改,我们也可以基于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#灰度的小数据集(32x32)#2卷积+池化 + 3卷积+池化 + 2个线性层 - AlexNet#找到泛化能力最强的模型/架构#1、先建立一个欠拟合的模型,加深层、加特征图数量、加参数 - 能够拟合的程度#2、先建立一个过拟合的模型,减去层、减去特征图数量、加上抗过拟合的参数 - 正常的程度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 outputdata = torch.ones(10,1,28,28)net2 = MyNet2()net2(data).shape#torch.Size([10, 10])#查看自己构建的网络架构和参数量summary(net2,input_size=(10,1,28,28),depth=3,device="cpu")# ==========================================================================================# Layer (type:depth-idx) Output ShapeParam ## ==========================================================================================# MyNet2 ----# ├─Sequential: 1-1[10, 32, 14, 14]--# │└─BasicConv2d: 2-1[10, 32, 28, 28]--# ││└─Sequential: 3-1[10, 32, 28, 28]896# │└─BasicConv2d: 2-2[10, 32, 28, 28]--# ││└─Sequential: 3-2[10, 32, 28, 28]25,696# │└─MaxPool2d: 2-3[10, 32, 14, 14]--# │└─Dropout2d: 2-4[10, 32, 14, 14]--# ├─Sequential: 1-2[10, 64, 7, 7]--# │└─BasicConv2d: 2-5[10, 64, 14, 14]--# ││└─Sequential: 3-3[10, 64, 14, 14]18,624# │└─BasicConv2d: 2-6[10, 64, 14, 14]--# ││└─Sequential: 3-4[10, 64, 14, 14]37,056# │└─BasicConv2d: 2-7[10, 64, 14, 14]--# ││└─Sequential: 3-5[10, 64, 14, 14]37,056# │└─MaxPool2d: 2-8[10, 64, 7, 7]--# │└─Dropout2d: 2-9[10, 64, 7, 7]--# ├─Sequential: 1-3[10, 10]--# │└─Linear: 2-10[10, 256] 803,072# │└─BatchNorm1d: 2-11 [10, 256] 512# │└─ReLU: 2-12[10, 256] --# │└─Linear: 2-13[10, 10]2,570# │└─LogSoftmax: 2-14[10, 10]--# ==========================================================================================# Total params: 925,482# Trainable params: 925,482# Non-trainable params: 0# Total mult-adds (M): 396.55# ==========================================================================================# Input size (MB): 0.03# Forward/backward pass size (MB): 14.09# Params size (MB): 3.70# Estimated Total Size (MB): 17.82# ==========================================================================================
这是小型数据集不适用于经典架构,而必须自建架构的情况。如果是面对大型数据集,我们通常都会将其处理为类似于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 torchimport torch.nn as nnfrom torchvision import models as mresnet18_ = m.resnet18() #没有执行预训练#执行此代码时注意关闭VPNrs18pt = m.resnet18(pretrained=True) #resnet18_pretrainedresnet18_.conv1.weight[0][0] #初始化的参数,准备好训练# tensor([[-0.0516, -0.0285, -0.0125, -0.0347,0.0014,0.0049, -0.0056],# [-0.0093, -0.0088, -0.0021, -0.0232, -0.0219, -0.0043,0.0256],# [ 0.0298,0.0211, -0.0074, -0.0238, -0.0210,0.0256,0.0349],# [-0.0038, -0.0273,0.0218, -0.0020,0.0003, -0.0173,0.0357],# [ 0.0205,0.0178,0.0156,0.0121, -0.0409, -0.0250,0.0090],# [-0.0068,0.0247,0.0722, -0.0244,0.0359,0.0493, -0.0065],# [-0.0487,0.0235, -0.0214, -0.0092, -0.0184, -0.0276,0.0153]],#grad_fn=)rs18pt.conv1.weight[0][0] #经过预训练的参数#tensor([[-0.0104, -0.0061, -0.0018,0.0748,0.0566,0.0171, -0.0127],#[ 0.0111,0.0095, -0.1099, -0.2805, -0.2712, -0.1291,0.0037],#[-0.0069,0.0591,0.2955,0.5872,0.5197,0.2563,0.0636],#[ 0.0305, -0.0670, -0.2984, -0.4387, -0.2709, -0.0006,0.0576],#[-0.0275,0.0160,0.0726, -0.0541, -0.3328, -0.4206, -0.2578],#[ 0.0306,0.0410,0.0628,0.2390,0.4138,0.3936,0.1661],#[-0.0137, -0.0037, -0.0241, -0.0659, -0.1507, -0.0822, -0.0058]],# grad_fn=)#属性requires_grad为True,意味着可以参与反向传播#预训练的参数刚被导入时,都是默认可以被训练的rs18pt.conv1.weight[0].requires_grad#Truers18pt.parameters()##将导入的预训练模型中所有的参数锁住for param in rs18pt.parameters():param.requires_grad = Falsers18pt.conv1.weight[0][0]#注意到grad_fn属性的消失吗?这意味着这些参数将不能够再参与反向传播等训练流程了#tensor([[-0.0104, -0.0061, -0.0018,0.0748,0.0566,0.0171, -0.0127],#[ 0.0111,0.0095, -0.1099, -0.2805, -0.2712, -0.1291,0.0037],#[-0.0069,0.0591,0.2955,0.5872,0.5197,0.2563,0.0636],#[ 0.0305, -0.0670, -0.2984, -0.4387, -0.2709, -0.0006,0.0576],#[-0.0275,0.0160,0.0726, -0.0541, -0.3328, -0.4206, -0.2578],#[ 0.0306,0.0410,0.0628,0.2390,0.4138,0.3936,0.1661],#[-0.0137, -0.0037, -0.0241, -0.0659, -0.1507, -0.0822, -0.0058]])#同时requires_grad属性也会变化为Falsers18pt.conv1.weight[0].requires_grad#False#使用新的层覆盖原来的层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#True#按照这一逻辑定义架构#让18层残差网络的前2个layers都被冻结,后面两个layers从0开始训练resnet18_ = m.resnet18() #没有预训练的模型rs18pt = m.resnet18(pretrained=True) #resnet18_pretrainedfor param in rs18pt.parameters():param.requires_grad = Falsefcin = rs18pt.fc.in_featuresclass 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)return xnet = MyNet_pretrained()net.pretrained[0].weight.requires_grad#Falsenet.train_[0][0].conv1.weight.requires_grad#Truefrom torchinfo import summarysummary(net,input_size=(10,3,224,224),depth=3,device="cpu")#不能训练的参数被括号括了起来,表示被锁定# ===============================================================================================# Layer (type:depth-idx)Output ShapeParam ## ===============================================================================================# MyNet_pretrained----# ├─Sequential: 1-1 [10, 128, 28, 28] --# │└─Conv2d: 2-1[10, 64, 112, 112](9,408)# │└─BatchNorm2d: 2-2 [10, 64, 112, 112](128)# │└─ReLU: 2-3[10, 64, 112, 112]--# │└─MaxPool2d: 2-4 [10, 64, 56, 56]--# │└─Sequential: 2-5[10, 64, 56, 56]--# ││└─BasicBlock: 3-1 [10, 64, 56, 56](73,984)# ││└─BasicBlock: 3-2 [10, 64, 56, 56](73,984)# │└─Sequential: 2-6[10, 128, 28, 28] --# ││└─BasicBlock: 3-3 [10, 128, 28, 28] (230,144)# ││└─BasicBlock: 3-4 [10, 128, 28, 28] (295,424)# ├─Sequential: 1-2 [10, 512, 1, 1] --# │└─Sequential: 2-7[10, 256, 14, 14] --# ││└─BasicBlock: 3-5 [10, 256, 14, 14] 919,040# ││└─BasicBlock: 3-6 [10, 256, 14, 14] 1,180,672# │└─Sequential: 2-8[10, 512, 7, 7] --# ││└─BasicBlock: 3-7 [10, 512, 7, 7] 3,673,088# ││└─BasicBlock: 3-8 [10, 512, 7, 7] 4,720,640# │└─AdaptiveAvgPool2d: 2-9 [10, 512, 1, 1] --# ├─Linear: 1-3 [10, 10]5,130# ===============================================================================================# Total params: 11,181,642# Trainable params: 10,498,570# Non-trainable params: 683,072# Total mult-adds (G): 18.14# ===============================================================================================# Input size (MB): 6.02# Forward/backward pass size (MB): 397.39# Params size (MB): 44.73# Estimated Total Size (MB): 448.14# ===============================================================================================#当你训练一段时间后,希望解锁部分层,可以怎么做呢?#解锁被锁定的部分中最后一个layersfor param in net.pretrained[5][1].parameters():param.requires_grad = True
可以发现,在代码上实现迁移学习并不困难,但我们学习的只是迁移学习最浅层的知识。除了pytorch提供的各个可以迁移的架构外,我们还可以在github上找到大量其他可以供我们迁移的模型,这些模型的权重可能储存在github上的url中,也可能我们可以直接从github下载模型本身。对任意pytorch可以调用的模型来说,训练好的权重都储存在参数state_dict中。我们可以直接从url获取权重,也可以从现有模型中调出权重来迁移使用。
#github上的资源/实验室获得的模型/权重#pt,pth#先将模型文件转化为pytorch可以读取的类型net.state_dict #模型的完整形式,包括结构和参数#本段代码仅做事例,不可运行#=======【从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之后是否呈现出过拟合、欠拟合或其他趋势,可能还会评估计算时间等信息。遗憾的是,交叉验证并不能适用于所有的深度学习的场合,当架构和数据量远远超出我们能够提供的算力时,我们会高度依赖于测试集上的结果来判断模型的泛化能力。我们使用数据增强来构造不同的训练集,最终我们会选择初始化相对稳定、计算时间较短、模型表现也更好的架构(虽然大多数时候,我们选择架构不太可能在这三点上都占优)。