在第3章中,使用了名为ResNet的流行的卷积神经网络(Convolutional Neural Network,CNN)架构构建了一个图像分类器,我们将此模型作为黑盒使用。本章将讨论卷积网络的重要组成部分。本章将涵盖如下重要主题:
本章将探讨如何从零开始构建一个解决图像分类问题的架构,这是最常见的用例。本章还将讲解如何使用迁移学习,这将有助于我们使用非常小的数据集构建图像分类器。
除了学习如何使用CNN,还将探讨这些卷积网络的学习内容。
在过去几年中,CNN已经在图像识别、对象检测、分割以及计算机视觉领域的许多其他任务中得到广泛应用。它们也在自然语言处理(Natural Language Processing,NLP)领域变得流行,尽管还没有被普遍使用。全连接层和卷积层之间的根本区别在于权重在中间层中彼此连接的方式。图5.1描述了全连接层或线性层是如何工作的。
图5.1
在计算机视觉中使用线性层或全连接层的最大挑战之一是它们丢失了所有空间信息,并且就全连接层使用的权重数量而言复杂度太高。例如,当将224像素的图像表示为平面阵列时,我们最终得到的数组长度是150,528(224×224×3通道)。当图像扁平化后,我们失去了所有的空间信息。让我们来看看CNN的简化版本是什么样子的,如图5.2所示。
图5.2
所有卷积层所做的是在图像上施加一个称为滤波器的权重窗口。在详细理解卷积和其他构建模块之前,先为MNIST数据集构建一个简单但功能强大的图像分类器。一旦构建了这个分类器,我们将遍历网络的每个组件。构建图像分类器可分为以下步骤。
MNIST数据集包含60,000个用于训练的0~9的手写数字图片,以及用于测试集的10,000张图片。PyTorch的torchvision库提供了一个MNIST数据集,它下载并以易于使用的格式提供数据。让我们用MNIST函数把数据集下载到本机,并封装成DataLoader。我们将使用torchvision变换将数据转换成PyTorch张量并进行归一化。下面的代码负责下载数据、把数据封装成DataLoader以及数据的归一化处理:
transformation = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]) train_dataset = datasets.MNIST('data/',train=True,transform=transformation, download=True) test_dataset = datasets.MNIST('data/',train=False,transform=transformation, download=True) train_loader= torch.utils.data.DataLoader(train_dataset,batch_size=32,shuffle=True) test_loader= torch.utils.data.DataLoader(test_dataset,batch_size=32,shuffle=True)
上述代码提供了train数据集和test数据集的DataLoader。让我们可视化展示一些图片,以理解要处理的内容。下面的代码可以可视化MNIST图片:
def plot_img(image): image = image.numpy()[0] mean = 0.1307 std = 0.3081 image = ((mean * image) + std) plt.imshow(image,cmap='gray')
现在通过传入plot_img方法来可视化数据集。下面的代码从DataLoader中提取出一批记录,并绘制图像:
sample_data = next(iter(train_loader)) plot_img(sample_data[0][l]) plot_img(sample_data[0][2])
图像按照图5.3所示的方式进行显示。
图5.3
对于这个例子,让我们从头开始构建自己的架构。我们的网络架构将包含不同层的组合,即:
让我们看看将要实现的架构的图形表示(见图5.4)。
图5.4
用PyTorch实现这个架构,然后详细了解每个层的作用:
class Net(nn.Module): def init (self): super(). init () self.convl =nn.Conv2d(l, 1〇,kernel_size=5) self.conv2 = nn.Conv2d(10, 2〇,kernel_size=5) self.conv2_drop = nn.Dropout2d() self.fcl = nn.Linear(320, 50) self.fc2 = nn.Linear(50, 10) def forward(self, x): x = F.relu(F.max_pool2d(self.convl(x), 2)) x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2)) x=x.view(-l, 320) x = F.relu(self.fcl(x)) x = F.dropout(x, training=self.training) x = self.fc2(x) return F.log_softmax(x)
下面来详细了解每一层所做的事情。
Conv2d负责在MNIST图像上应用卷积滤波器。让我们试着理解如何在一维数组上应用卷积,然后转向如何将二维卷积应用于图像。我们查看图5.5,将大小为3的滤波器(或内核)Conv1d应用于长度为7的张量:
图5.5
底部框表示7个值的输入张量,连接框表示应用3个卷积滤波器后的输出。在图像的右上角,3个框表示Conv1d层的权重和参数。卷积滤波器像窗口一样应用,并通过跳过一个值移动到下一个值。要跳过的值称为步幅,并默认设置为1。下面通过写下第一个和最后一个输出的计算来理解如何计算输出值:
Output 1 -> (-0.5209 x 0.2286) + (-0.0147 x 2.4488) + (-0.4281 x -0.9498)
Output 5 -> (-0.5209 x -0.6791) + (-0.0147 x -0.6535) + (-0.4281 x 0.6437)
所以,到目前为止,对卷积的作用应该比较清楚了。卷积基于移动步幅值在输入上应用滤波器,即一组权重。在前面的例子中,滤波器每次移动一格。如果步幅值是2,滤波器将每次移动2格。下面看看PyTorch的实现,来理解它是如何工作的:
conv = nn.Convld(l,l,3,bias=False) sample = torch.randn(l,l,7) conv(Variable(sample)) #检查卷积滤波器的权重 conv.weight
还有另一个重要的参数,称为填充,它通常与卷积一起使用。如果仔细地观察前面的例子,大家可能会意识到,如果直到数据的最后才能应用滤波器,那么当数据没有足够的元素可以跨越时,它就会停止。填充则是通过在张量的两端添加0来防止这种情况。下面看一个关于如何填充一维数组的例子。
在图5.6中,我们应用了填充为2步幅为1的Conv1d层。
图5.6
让我们看看Conv2d如何在图像上工作。
在了解Conv2d的工作原理之前,强烈建议大家查看一个非常好的博客(http:// setosa.io/ev/image-kernels/),其中包含一个关于卷积如何工作的现场演示。花几分钟看完演示之后,请阅读下文。
我们来理解一下演示中发生的事情。在图像的中心框中,有两组不同的数字:一个在方框中表示;另一个在方框下方。在框中表示的那些是像素值, 如左边照片上的白色框所突出显示的那样。在框下面表示的数字是用于对图像进行锐化的滤波器(或内核)值。这些数字是精心挑选的,以完成一项特定的工作。在本例中,它用于锐化图像。如前面的例子中一样,我们进行元素级的乘法运算并将所有值相加,生成右侧图像中像素的值。生成的值在图像右侧的白色框中高亮显示。
虽然在这个例子中内核中的值是精心选择的,但是在CNN中我们不会去精选值,而是随机地初始化它们,并让梯度下降和反向传播调整内核的值。学习的内核将负责识别不同的特征,如线条、曲线和眼睛。下面来看图5.7,我们把它看成是一个数字矩阵,看看卷积是如何工作的。
图5.7
在图5.7中,假设用6×6矩阵表示图像,并且应用大小为3×3的卷积滤波器,然后展示如何生成输出。简单起见,我们只计算矩阵的高亮部分。通过执行以下计算生成输出:
Output -> 0.86x 0+ -0.92x 0+ -0.61x 1+ -0.32x -1+ -1.69x -1+ ……
Conv2d函数中使用的另一个重要参数是kernel_size,它决定了内核的大小。常用的内核大小有为1、3、5和7。内核越大,滤波器可以覆盖的面积就越大,因此通常会观察到大小为7或9的滤波器应用于早期层中的输入数据。
通用的实践是在卷积层之后添加池化(pooling)层,因为它们会降低特征平面和卷积层输出的大小。
池化提供两种不同的功能:一个是减小要处理的数据大小;另一个是强制算法不关注图像位置的微小变化。例如,面部检测算法应该能够检测图片中的面部,而不管照片中面部的位置。
我们来看看MaxPool2d的工作原理。它也同样具有内核大小和步幅的概念。它与卷积不同,因为它没有任何权重,只是对前一层中每个滤波器生成的数据起作用。如果内核大小为2×2,则它会考虑图像中2×2的区域并选择该区域的最大值。让我们看看图5.8,它清楚地说明了MaxPool2d的工作原理。
图5.8
左侧的框包含特征平面的值。在应用最大池化之后,输出存储在框的右侧。我们写出输出第一行中值的计算代码,看看输出是如何计算的:
Output1 -> Maximum(3,7,2,8) -> 8
Output2 -> Maximum(-1,-8,9,2) -> 9
另一种常用的池化技术是平均池化,需要把average函数替换成maxinum函数。
图5.9说明了平均池化的工作原理。
图5.9
在这个例子中,我们取的是4个值的平均值,而不是4个值的最大值。让我们写出计算代码,以便更容易理解:
Output1 -> Average(3,7,2,8) -> 84
Output2 -> Average(-1,-8,9,2) -> -37
在最大池化之后或者在应用卷积之后使用非线性层是通用的最佳实践。大多数网络架构倾向于使用ReLu或不同风格的ReLu。无论选择什么非线性函数,它都作用于特征平面的每个元素。为了使其更直观,来看一个示例(见图5.10),其中把ReLU应用到应用过最大池化和平均池化的相同特征平面上:
图5.10
对于图像分类问题,通用实践是在大多数网络的末端使用全连接层或线性层。我们使用一个以数字矩阵作为输入并输出另一个数字矩阵的二维卷积。为了应用线性层,需要将矩阵扁平化,将二维张量转变为一维的向量。图5.11所示为view方法的工作原理。
图5.11
让我们看看在网络中实现该功能的代码:
x.view(-1,320)
可以看到,view方法将使n维张量扁平化为一维张量。在我们的网络中,第一个维度是每个图像。批处理后的输入数据维度是32×1×28×28,其中第一个数字32表示将有32个高度为28、宽度为28和通道为1的图像,因为图像是黑白的。当进行扁平化处理时,我们不想把不同图像的数据扁平化到一起或者混合数据,因此,传给view函数的第一个参数将指示PyTorch避免在第一维上扁平化数据。来看看图5.12中的工作原理。
图5.12
在上面的例子中,我们有大小为2×1×2×2的数据;在应用view函数之后,它会转换成大小为2×1×4的张量。让我们再看一下没有使用参数-1的另一个例子(见图5.13)。
图5.13
如果忘了指明要扁平化哪一个维度的参数,可能会得到意想不到的结果。所以在这一步要格外小心。
线性层
在将数据从二维张量转换为一维张量之后,把数据传入非线性层,然后传入非线性的激活层。在我们的架构中,共有两个线性层,一个后面跟着ReLU,另一个后面跟着log_softmax,用于预测给定图片中包含的数字。
训练模型的过程与之前的狗猫图像分类问题相同。下面的代码片段在提供的数据集上对我们的模型进行训练:
def fit(epoch,model,data_loader,phase='training',volatile=False): if phase == 'training': model.train() if phase == 'validation': model.eval() volatile=True running_loss = 0.0 running_correct = 0 for batch_idx , (data,target) in enumerate(data_loader): if is_cuda: data,target = data.cuda(),target.cuda() data , target = Variable(data,volatile),Variable(target) if phase == 'training': optimizer.zero_grad() output = model(data) loss = F.nll_loss(output,target) running_loss += F.nll_loss(output,target,size_average=False).data[0] preds = output.data.max(dim=1,keepdim=True)[1] running_correct += preds.eq(target.data.view_as(preds)).cpu().sum() if phase == 'training': loss.backward() optimizer.step() loss = running_loss/len(data_loader.dataset) accuracy = 100. * running_correct/len(data_loader.dataset) print(f'{phase} loss is {loss:{5}.{2}} and {phase} accuracy is {running_correct}/{len(data_loader.dataset)}{accuracy:{10}.{4}}') return loss,accuracy
该方法针对training和validation具有不同的逻辑。使用不同模式主要有两个原因:
上一个函数中的大多数代码都是不言自明的,就如前几章所述。在函数的末尾,我们返回特定轮数中模型的loss和accuracy。
让我们通过前面的函数将模型运行20次迭代,并绘制出training和validation上的loss和accuracy,以了解网络表现的好坏。以下代码将fit方法在training和validation数据集上运行20次迭代:
model = Net() if is_cuda: model.cuda() optimizer = optim.SGD(model.parameters(),lr=0.01,momentum=0.5) train_losses , train_accuracy = [],[] val_losses , val_accuracy = [],[] for epoch in range(1,20): epoch_loss, epoch_accuracy = fit(epoch,model,train_loader,phase='training') val_epoch_loss , val_epoch_accuracy = fit(epoch,model,test_loader,phase='validation') train_losses.append(epoch_loss) train_accuracy.append(epoch_accuracy) val_losses.append(val_epoch_loss) val_accuracy.append(val_epoch_accuracy)
以下代码绘制出了训练和测试的损失值:
plt.plot(range(l,len(train_losses)+l),train_losses,'bo',label = 'training loss') plt.plot(range(l,len(val_losses)+l),val_losses,'r',label = 'validation loss') plt.legend()
上述代码生成的图片如图5.14所示。
图5.14
下面的代码绘制出了训练和测试的准确率:
plt.plot(range(l,len(train_accuracy)+l),train_accuracy,'bo',label = 'train accuracy') plt.plot(range(l,len(val_accuracy)+l),val_accuracy,'r',label = 'val accuracy') plt.legend()
上述代码生成的图片如图5.15所示。
在20轮训练后,我们达到了98.9%的测试准确率。我们使用简单的卷积模型,几乎达到了最先进的结果。让我们看看在之前使用的Dogs vs. Cats数据集上尝试相同的网络架构时会发生什么。我们将使用之前第2章中的数据和MNIST示例中的架构并略微修改。一旦训练好了模型,我们将评估模型,以了解架构表现的优异程度。
图5.15
我们将使用相同的体系结构,并进行一些小的更改,如下所示。
让我们来看看实现网络架构的代码:
class Net(nn.Module): def init (self): super ().—init () self.convl =nn.Conv2d(3, 1〇,kernel_size=5) self.conv2 = nn.Conv2d(10, 2〇,kernel_size=5) self.conv2_drop = nn.Dropout2d() self.fcl = nn.Linear(56180, 500) self.fc2 = nn.Linear(500,50) self.fc3 = nn.Linear(5〇,2) def forward(self, x): x = F.relu(F.max_pool2d(self.convl(x), 2)) x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2)) x = x.view(x.size(0),-l) x = F.relu(self.fcl(x)) x = F.dropout(x, training=self.training) x = F.relu(self.fc2(x)) x = F.dropout(x,training=self.training) x = self.fc3(x) return F.log_softmax(x,dim=l)
我们将使用与MNIST示例相同的training函数。所以,这里不再包含代码。但是让我们看一下模型训练20次迭代后生成的结果图。
训练和验证数据集的损失值如图5.16所示。
图5.16
训练和验证数据集的准确率如图5.17所示。
图5.17
从图中可以清楚地看出,对于每次迭代,训练集的损失都在减少,而验证集的损失却变得更糟。在训练过程中,准确率也增加,但在75%时几乎饱和。显而易见,这是一个模型没有泛化的例子。我们将研究另一种称为迁移学习的技术,它可以帮助我们训练更准确的模型,以及加快训练的速度。
迁移学习是指在类似的数据集上使用训练好的算法,而无须从头开始训练。人类并不是通过分析数千个相似的图像来识别新的图像。作为人类,我们只是通过了解不同特征来区分特定动物的,比如狐狸和狗。我们不需要了解线条、眼睛和其他较小的特征来识别狐狸。因此,我们将学习如何使用预训练好的模型来构建只需要很少数据的最先进的图像分类器。
CNN架构的前几层专注于较小的特征,例如线条或曲线的外观。CNN架构的随后几层中的滤波器识别更高级别的特征,例如眼睛和手指,最后几层学习识别确切的类别。预训练模型是在相似的数据集上训练的算法。大多数流行的算法都在流行的ImageNet数据集上进行了预训练,以识别1,000种不同的类别。这样的预训练模型具有可以识别多种模式的调整好的滤波器权重。所以来了解一下如何利用这些预先训练的权重。我们将研究一种名为VGG16的算法,它是在ImageNet竞赛中获得成功的最早的算法之一。虽然有更多的现代算法,但该算法仍然很受欢迎,因为它简单易懂并可用于迁移学习。下面来看看VGG16模型的架构(见图5.18),然后尝试理解架构以及使用它来训练我们的图像分类器。
VGG16架构包含5个VGG块。每个VGG块是一组卷积层、一个非线性激活函数和一个最大池化函数。所有算法参数都是调整好的,可以达到识别1,000个类别的最先进的结果。该算法以批量的形式获取输入数据,这些数据通过ImageNet数据集的均值和标准差进行归一化。在迁移学习中,我们尝试通过冻结架构的大部分层的学习参数来捕获算法的学习内容。通用实践是仅微调网络的最后几层。在这个例子中,我们只训练最后几个线性层并保持卷积层不变,因为卷积学习的特征主要用于具有类似属性的各种图像相关的问题。下面使用迁移学习训练VGG16模型来对狗和猫进行分类。我们看看实现这一目的所需要的不同步骤。
图5.18 VGG16模型的架构
PyTorch在torchvision库中提供了一组训练好的模型。这些模型大多数接受一个称为pretrained的参数,当这个参数为True时,它会下载为ImageNet分类问题调整好的权重。让我们看一下创建VGG16模型的代码片段:
from torchvision import models vgg = models.vggl6(pretrained=True)
现在有了所有权重已经预训练好且可马上使用的VGG16模型。当代码第一次运行时,可能需要几分钟,这取决于网络速度。权重的大小可能在500MB左右。我们可以通过打印快速查看下VGG16模型。当使用现代架构时,理解这些网络的实现方式非常有用。我们来看看这个模型:
VGG ( (features): Sequential ( (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (1): ReLU (inplace) (2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (3): ReLU (inplace) (4): MaxPool2d (size=(2, 2), stride=(2, 2), dilation=(1, 1)) (5): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (6): ReLU (inplace) (7): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (8): ReLU (inplace) (9): MaxPool2d (size=(2, 2), stride=(2, 2), dilation=(1, 1)) (10): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (11): ReLU (inplace) (12): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (13): ReLU (inplace) (14): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (15): ReLU (inplace) (16): MaxPool2d (size=(2, 2), stride=(2, 2), dilation=(1, 1)) (17): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (18): ReLU (inplace) (19): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (20): ReLU (inplace) (21): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (22): ReLU (inplace) (23): MaxPool2d (size=(2, 2), stride=(2, 2), dilation=(1, 1)) (24): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (25): ReLU (inplace) (26): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (27): ReLU (inplace) (28): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (29): ReLU (inplace) (30): MaxPool2d (size=(2, 2), stride=(2, 2), dilation=(1, 1))) (classifier): Sequential ( (0): Linear (25088 -> 4096) (1): ReLU (inplace) (2): Dropout (p = 0.5) (3): Linear (4096 -> 4096) (4): ReLU (inplace) (5): Dropout (p = 0.5) (6): Linear (4096 -> 1000) ) )
模型摘要包含了两个序列模型:features和classifiers。features sequential模型包含了将要冻结的层。
下面冻结包含卷积块的features模型的所有层。冻结层中的权重将阻止更新这些卷积块的权重。由于模型的权重被训练用来识别许多重要的特征,因而我们的算法从第一个迭代开时就具有了这样的能力。使用最初为不同用例训练的模型权重的能力,被称为迁移学习。现在看一下如何冻结层的权重或参数:
for param in vgg.features.parameters(): param.requires_grad = False
该代码阻止优化器更新权重。
VGG16模型被训练为针对1000个类别进行分类,但没有训练为针对狗和猫进行分类。因此,需要将最后一层的输出特征从1000改为2。以下代码片段执行此操作:
vgg.classifier[6].out_features = 2
vgg.classifier可以访问序列模型中的所有层,第6个元素将包含最后一个层。当训练VGG16模型时,只需要训练分类器参数。因此,我们只将classifier.parameters传入优化器,如下所示:
optimizer = optim.SGD(vgg.classifier.parameters(),lr=0.0001,momentum=0.5)
我们已经创建了模型和优化器。由于使用的是Dogs vs. Cats数据集,因此可以使用相同的数据加载器和train函数来训练模型。请记住,当训练模型时,只有分类器内的参数会发生变化。下面的代码片段对模型进行了20轮的训练,在验证集上达到了98.45%的准确率:
train_losses, train_accuracy =[],[] val_losses, val_accuracy =[],[] for epoch in range(l,20): epoch_loss, epoch_accuracy = fit(epoch,vgg,train_data_loader,phase='training') val_epoch_loss, val_epoch_accuracy = fit(epoch,vgg,valid_data_loader,phase='validation') train_losses.append(epoch_loss) train_accuracy.append(epoch_accuracy) val_losses.append(val_epoch_loss) val_accuracy.append(val_epoch_accuracy)
将训练和验证的损失可视化,如图5.19所示。
图5.19
将训练和验证的准确率可视化,如图5.20所示。
图5.20
我们可以应用一些技巧,例如数据增强和使用不同的dropout值来改进模型的泛化能力。以下代码片段将VGG分类器模块中的dropout值从0.5更改为0.2并训练模型:
for layer in vgg.classifier.children(): if(type(layer) == nn.Dropout): layer.p = 0.2 #训练 train_losses, train_accuracy =[],[] val_losses, val_accuracy =[],[] for epoch in range(l,3): epoch_loss , epoch_accuracy = fit(epoch,vgg,train_data_loader,phase='training') val_epoch_loss _**,**_ val_epoch_accuracy = fit(epoch,vgg,valid_data_loader,phase='validation') train_losses.append(epoch_loss) train_accuracy.append(epoch_accuracy) val_losses.append(val_epoch_loss) val_accuracy.append(val_epoch_accuracy)
通过几轮的训练,模型得到了些许改进。还可以尝试使用不同的dropout值。改进模型泛化能力的另一个重要技巧是添加更多数据或进行数据增强。我们将通过随机地水平翻转图像或以小角度旋转图像来进行数据增强。torchvision转换为数据增强提供了不同的功能,它们可以动态地进行,每轮都发生变化。我们使用以下代码实现数据增强:
train_transform =transforms.Compose([transforms.Resize((224,224)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train = ImageFolder('dogsandcats/train/',train_transform) valid = ImageFolder('dogsandcats/valid/',simple_transform) #训练 train_losses, train_accuracy =[],[] val_losses, val_accuracy =[],[] for epoch in range(l,3): epoch_loss, epoch_accuracy = fit(epoch,vgg,train_data_loader,phase='training') val_epoch_loss, val_epoch_accuracy = fit(epoch,vgg,valid_data_loader,phase='validation') train_losses.append(epoch_loss) train_accuracy.append(epoch_accuracy) val_losses.append(val_epoch_loss) val_accuracy.append(val_epoch_accuracy)
前面代码的输出如下:
#结果 training loss is 0.041 and training accuracy is 22657/23000 98.51 validation loss is 0.043 and validation accuracy is 1969/2000 98.45 training loss is 0.04 and training accuracy is 22697/23000 98.68 validation loss is 0.043 and validation accuracy is 1970/2000 98.5
使用增强数据训练模型仅运行两轮就将模型准确率提高了0.1%;可以再运行几轮以进一步改进模型。如果大家在阅读本书时一直在训练这些模型,将意识到每轮的训练可能需要几分钟,具体取决于运行的GPU。让我们看一下可以在几秒钟内训练一轮的技术。
当冻结卷积层和训练模型时,全连接层或dense层(vgg.classifier)的输入始终是相同的。为了更好地理解,让我们将卷积块(在示例中为vgg.features块)视为具有了已学习好的权重且在训练期间不会更改的函数。因此,计算卷积特征并保存下来将有助于我们提高训练速度。训练模型的时间减少了,因为我们只计算一次这些特征而不是每轮都计算。让我们在结合图5.21理解并实现同样的功能。
图5.21
第一个框描述了一般情况下如何进行训练,这可能很慢,因为尽管值不会改变,但仍为每轮计算卷积特征。在底部的框中,一次性计算卷积特征并仅训练线性层。为了计算预卷积特征,我们将所有训练数据传给卷积块并保存它们。为了实现这一点,需要选择VGG模型的卷积块。幸运的是,VGG16的PyTorch实现包含了两个序列模型,所以只选择第一个序列模型的特征就可以了。以下代码执行此操作:
vgg = models.vggl6(pretrained=True) vgg = vgg.cuda() features = vgg.features train_data_loader = torch.utils.data.DataLoader(train,batch_size=32,num_workers=3,shuffle=False ) valid_data_loader= torch.utils.data.DataLoader(valid,batch_size=32,num_workers=3,shuffle=False ) def preconvfeat(dataset,model): conv_features =[] labels_list =[] for data in dataset: inputs,labels = data if is_cuda: inputs,labels = inputs.cuda(),labels.cuda() inputs, labels = Variable(inputs),Variable(labels) output = model(inputs) conv_features.extend(output.data.cpu().numpy()) labels_list.extend(labels.data.cpu().numpy()) conv_features = np.concatenate([[feat] for feat in conv_features]) return (conv_features,labels_list) conv_feat_train,labels_train = preconvfeat(train_data_loader,features) conv_feat_val,labels_val = preconvfeat(valid_data_loader,features)
在上面的代码中,preconvfeat方法接受数据集和vgg模型,并返回卷积特征以及与之关联的标签。代码的其余部分类似于在其他示例中用于创建数据加载器和数据集的代码。
获得了train和validation集的卷积特征后,让我们创建PyTorch的Dataset和DataLoader类,这将简化训练过程。以下代码为卷积特征创建了Dataset和DataLoader类:
class My_dataset(Dataset): def __init__(self,feat,labels): self.conv_feat = feat self.labels = labels def __len__(self): return len(self.conv_feat) def __getitem__(self,idx): return self.conv_feat[idx],self.labels[idx] train_feat_dataset = My_dataset(conv_feat_train,labels_train) val_feat_dataset = My_dataset(conv_feat_val,labels_val) train_feat_loader = DataLoader(train_feat_dataset,batch_size=64,shuffle=True) val_feat_loader = DataLoader(val_feat_dataset,batch_size=64,shuffle=True)
由于有新的数据加载器可以生成批量的卷积特征以及标签,因此可以使用与另一个例子相同的训练函数。现在将使用vgg.classifier作为创建optimizer和fit方法的模型。下面的代码训练分类器模块来识别狗和猫。在Titan X GPU上,每轮训练只需不到5秒钟,在其他CPU上可能需要几分钟:
train_losses , train_accuracy = [],[] val_losses , val_accuracy = [],[] for epoch in range(1,20): epoch_loss, epoch_accuracy = fit_numpy(epoch,vgg.classifier,train_feat_loader,phase='training') val_epoch_loss , val_epoch_accuracy = fit_numpy(epoch,vgg.classifier,val_feat_loader,phase='validation') train_losses.append(epoch_loss) train_accuracy.append(epoch_accuracy) val_losses.append(val_epoch_loss) val_accuracy.append(val_epoch_accuracy)
深度学习模型常常被认为是不可解释的。但是人们正在探索不同的技术来解释这些模型内发生了什么。对于图像,由卷积神经网络学习的特征是可解释的。我们将探索两种流行的技术来理解卷积神经网络。
可视化中间层的输出将有助于我们理解输入图像如何在不同层之间进行转换。通常,每层的输出称为激活(activation)。为了可视化,我们需要提取中间层的输出,可以用几种不同的方式完成提取。PyTorch提供了一个名为register_forward_hook的方法,它允许传入一个可以提取特定层输出的函数。
默认情况下,为了以最佳方式使用内存,PyTorch模型仅存储最后一层的输出。因此,在检查中间层的激活之前,需要了解如何从模型中提取输出。我们先看看下面用于提取的代码,然后再进行详细介绍:
vgg = models.vgg16(pretrained=True).cuda() class LayerActivations(): features=None def __init__(self,model,layer_num): self.hook = model[layer_num].register_forward_hook(self.hook_fn) def hook_fn(self,module,input,output): self.features = output.cpu() def remove(self): self.hook.remove() conv_out = LayerActivations(vgg.features,0) o = vgg(Variable(img.cuda())) conv_out.remove() act = conv_out.features
首先创建一个预先训练的VGG模型,并从中提取特定层的输出。LayerActivations类指示PyTorch将一层的输出保存到features变量。让我们来看看LayerActivations类中的每个函数。
_init_函数取得模型以及用于将输出提取成参数的层的编号。我们在层上调用register_forward_hook方法并传入函数。当PyTorch进行前向传播时——也就是说,当图像通过层传输时——调用传给register_forward_hook方法的函数。此方法返回一个句柄,该句柄可用于注销传递给register_forward_hook方法的函数。
register_forward_hook方法将3个值传入我们传给它的函数。参数module允许访问层本身。第二个参数是input,它指的是流经层的数据。第三个参数是output,它允许访问层转换后的输入或激活。将输出存储到LayerActivations类中的features变量。
第三个函数取得_init_函数的钩子并注销该函数。现在可以传入正在寻找的激活(activation)的模型和层的编号。让我们看看为图5.22创建的不同层的激活。
图5.22
可视化第一个卷积层创建的激活和使用的代码:
fig = plt.figure(figsize=(20,50)) fig.subplots_adjust(left=0,right=l,bottom=0,top=0.8,hspace=0, wspace=0.2) for i in range(30): ax = fig.add_subplot(12,5,i+l,xticks=[],yticks=[]) ax.imshow(act[0][i])
可视化第五个卷积层创建的一些激活,如图5.23所示。
图5.23
来看最后一个CNN层,如图5.24所示。
从不同的层生成的激活来看,可以看出前面的层检测线条和边缘,最后的层倾向于学习更高层次的特征,而解释性较差。在对权重可视化之前,让我们看看在ReLU层之后特征平面或激活如何自我表示。所以,让我们可视化第二层的输出。
图5.24
如果快速查看图5.24第二行中的第5个图像,它看起来像是滤波器正在检测图像中的眼睛。当模型不能执行时,这些可视化技巧可以帮助我们理解模型可能无法正常工作的原因。
获取特定层的模型权重非常简单。可以通过state_dict函数访问所有模型权重。state_dict函数返回一个字典,其中键是层,值是权重。以下代码演示了如何为特定层拉取(pull)权重并将其可视化:
vgg.state_dict().keys() cnn_weights = vgg.state_dict()['features.0.weight'].cpu()
上述代码提供了如图5.25所示的输出。
图5.25
每个框表示大小为3×3的滤波器的权重。每个滤波器都经过训练以识别图像中的某些模式。
本章讲解了如何使用卷积神经网络构建图像分类器,以及如何使用预先训练的模型。本章介绍了如何使用预卷积特征加快训练过程。此外,还介绍了用来理解CNN内部情况的不同技术。
下一章将学习如何使用递归神经网络处理序列化数据。
本文摘自《PyTorch深度学习》
本书是使用PyTorch构建神经网络模型的实用指南,内容分为9章,包括PyTorch与深度学习的基础知识、神经网络的构成、神经网络的高级知识、机器学习基础知识、深度学习在计算机视觉中的应用、深度学习在序列数据和文本中的应用、生成网络、现代网络架构,以及PyTorch与深度学习的未来走向。
本书适合对深度学习领域感兴趣且希望一探PyTorch究竟的业内人员阅读;具备其他深度学习框架使用经验的读者,也可以通过本书掌握PyTorch的用法。