破解深度学习(核心篇):模型算法与实现
上QQ阅读APP看书,第一时间看更新

1.2 VGGNet

2012年提出的AlexNet模型奠定了深度学习在计算机视觉领域中的地位。而到2014年,VGGNet凭借其更深更宽的网络结构获得了ILSVRC(ImageNet Large Scale Visual Recognition Competition)
定位任务的第一名和分类任务的第二名。接下来,我们将详细介绍VGGNet,看看它有哪些创新点,并对其进行简单的代码实现。

1.2.1 VGGNet简介

VGGNet论文的作者Karen Simonyan和Andrew Zisserman当时都是牛津大学工程科学系Visual Geometry Group成员,这也是“VGG”名字的由来。从原始论文摘要中能看出关键研究主要集中在CNN深度对模型性能的影响。它使用了3×3的小卷积核,并将网络深度提升到了11~19层。基于这种设计,VGGNet在其他数据集上也取得了前所未有的成果,这意味着它具有极强的泛化能力和扩展性。

VGGNet的模型结构非常简洁,如图1-4所示,整个网络使用了小尺寸的卷积核以及相同的池化尺寸(2×2)。到目前为止,VGGNet依然广泛应用于计算机视觉领域的各类任务,常被用来提取图像特征,相关论文引用次数已经超过9万。

图1-4 VGGNet的模型结构示意

VGGNet的主要思想其实在于解决了平衡问题。在当时的CNN模型中,网络深度越深,表现就越好,但同时也会带来较高的计算复杂度、较大的模型以及过拟合现象。VGGNet通过设计一种更深但同时较小的CNN而解决了这一问题,提高了模型的泛化能力,在保证较高精度的同时,兼顾了模型的计算效率。

VGGNet模型结构如表1-1所示,这是一个典型的CNN模型,由若干卷积层和全连接层组成。从中我们可以看到,VGGNet包含多种级别的网络,网络深度从11~19层不等,但每个网络均由三个部分组成。

表1-1 VGGNet模型结构

卷积层:VGGNet卷积层的卷积核大小均为3×3,表1-1中的conv3-256表示的就是3×3的卷积核、256个通道。

池化层:在卷积层之后使用了最大池化层来缩小图像尺寸,通过池化层的下采样来减小计算复杂度。

全连接层:包括两个4096维的全连接层和一个输出层,输出层的大小取决于任务的类别数。最后经过一个Sofmax得到最终类别上的概率分布。

只要明白了上述三部分,VGGNet的精髓就不难掌握了。

表1-1中还有以下两点需要特别注意。

在A网络后面有一个A-LRN网络,所谓LRN其实是局部响应归一化(local response normalization,LRN),但在VGGNet论文中通过对比后发现,LRN对网络性能的提升没有帮助,现在很少有人使用了,所以大家只要了解有这样一个结构即可,后续代码实现中也会略过这个部分。

C网络和D网络都是16层网络,区别在于,C网络其实是在B网络的基础上增加了3个1×1的卷积层。经实验发现1×1的卷积核增加了额外的非线性提升效果,而D网络用3×3卷积层替换1×1卷积层之后实现了更好的效果,所以我们所说的VGG16模型一般是指这个D网络模型,后面的代码实现是A、B、D、E这四个网络,一般将它们分别称为VGG11、VGG13、VGG16、VGG19。

那么为什么要选用小卷积核,或者说它有什么优势呢?

VGGNet中使用的都是3×3的小卷积核。本质上讲,两个3×3卷积层串联相当于1个5×5的卷积层,如图1-5所示。而如果是3个3×3的卷积层串联,就相当于1个7×7的卷积层,即3个3×3卷积层的感受野大小相当于1个7×7的卷积层。但是3个3×3卷积层的参数量只有7×7卷积层的一半左右,同时前者可以有3个非线性操作,而后者只有1个非线性操作,这使得多个小卷积层对于特征的学习能力更强,同时参数更少。

图1-5 感受野效果示意

梗老师:VGGNet相对于AlexNet有什么不同之处?

小 白:VGGNet继承了AlexNet的思想,但采用了更深的网络结构,具有更多的卷积层,从而进一步提高了对于特征学习的能力。

梗老师:没错,也因为VGGNet相对较深,需要大量的参数和计算资源,因此不太适合轻量级应用或资源受限的环境。

1.2.2 代码实现

了解了VGGNet的基本思想和结构设计之后,我们来看一下如何用代码实现相关网络结构。

# 导入必要的库,torchinfo用于查看模型结构
import torch
import torch.nn as nn
from torchinfo import summary

下面用代码定义网络结构,开始实现VGGNet,模式和之前AlexNet基本一致。其中features是卷积层提取特征的网络结构,为了适配多种级别的网络,其需要单独生成,后面会专门讲解构建这部分的内容。classifier是最后的全连接层生成分类的网络结构,其中包含三个全连接层,当然也可以看成两个全连接层+ ReLU + Dropout层的组合,最后一个全连接层降维。

forward()函数定义前向传播过程,描述各层间的连接关系,经过features卷积层提取特征,然后调用flatten()函数将每个样本张量展平为一维,最后classifier进行分类输出。到这里其基本结构就定义完成了,对结构还有些困惑的读者可以再看看表1-1所示的VGGNet模型结构表,多多思考其设计思路。

# 定义VGGNet的网络结构
class VGG(nn.Module):
    def __init__(self, features, num_classes=1000):
        super(VGG, self).__init__()
        # 卷积层直接使用传入的结构,后面有专门构建这部分的内容
        self.features = features
        # 定义全连接层
        self.classifier = nn.Sequential(
            # 全连接层+ReLU+Dropout
            nn.Linear(512 * 7 * 7, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            # 全连接层+ReLU+Dropout
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            # 全连接层
            nn.Linear(4096, num_classes),
        )

    # 定义前向传播函数
    def forward(self, x):
        # 先经过feature提取特征,flatten()后送入全连接层
        x = self.features(x)
        x = torch.flatten(x, 1)
        x = self.classifier(x)
        return x

接下来要做的是定义相关配置项。由于VGGNet包含多种级别的网络,因此需要定义一个配置项进行区分,其中每个key都代表一个模型的配置文件,前面提到过这里主要包括VGG11、VGG13、VGG16、VGG19四种级别。配置项里面的数字代表卷积层中卷积核的个数,'M'表示最大池化层。其中的数值和表1-1所示的VGGNet模型结构表中的完全一致,大家可以自行对照一下。

# 定义相关配置项,其中M表示最大池化层,数值完全对应论文中的表格数值
cfgs = {
    'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
    'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 
512, 'M'],
    'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 
'M', 512, 512, 512, 'M'],
    'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 
512, 512, 'M', 512, 512, 512, 512, 'M'],
}

有了配置项,我们就可以根据对应配置拼接卷积层了。

定义一个拼接卷积层的函数,传入的参数就是前面定义的某个配置项,通过遍历传入的配置列表拼接出对应的网络结构。当遍历到字母M时,拼接上一个最大池化层;当遍历到数字时,其对应卷积核的个数,拼接一个卷积层和一个ReLU层,之后记录其对应的卷积核个数的数值,所以一开始in_channels设为3,后面逐层记录作为下一次的in_channels。遍历完成后,最后调用nn.Sequential()将构造出的卷积层返回即可。

通过这种方式,我们就不用逐层地手动定义了,借助一个循环就完成了。

# 根据传入的配置项拼接卷积层
def make_layers(cfg):
    layers = []
    in_channels = 3 #初始通道数为3
    # 遍历传入的配置项
    for v in cfg:
        if v == 'M': # 如果是池化层,则直接新增MaxPool2d即可
            layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
        else: # 如果是卷积层,则新增3×3卷积+ReLU
            conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
            layers += [conv2d, nn.ReLU(inplace=True)]
            in_channels = v #记录通道数,作为下一次的in_channels
    # 返回使用Sequential构造的卷积层
    return nn.Sequential(*layers)

然后封装对应模型的函数,将配置项传入make_layers()函数拼接出卷积层,再将其作为VGGNet中的features层,就可以直接被调用了。这里的num_classes是指类别数,默认设为1000。

# 封装函数,依次传入对应的配置项
def vgg11(num_classes=1000):
    return VGG(make_layers(cfgs['vgg11']), num_classes=num_classes)

def vgg13(num_classes=1000):
    return VGG(make_layers(cfgs['vgg13']), num_classes=num_classes)

def vgg16(num_classes=1000):
    return VGG(make_layers(cfgs['vgg16']), num_classes=num_classes)

def vgg19(num_classes=1000):
    return VGG(make_layers(cfgs['vgg19']), num_classes=num_classes)

接下来看一下网络结构,以较为常用的VGG16为例,调用torchinfo.summary()可以查看刚刚实现的模型信息,包括前面的卷积层以及后面的全连接层,并计算其参数量。可以看到,该模型包含将近一亿四千万个参数,当然大部分参数量还是在全连接层部分。

# 查看模型结构及参数量,input_size表示示例输入数据的维度信息
summary(vgg16(), input_size=(1, 3, 224, 224))

与torchvision.models中集成的VGGNet进行对比,可以看到和前面手动实现的网络是一致的,参数量也完全一样,用这种方式能更简单地使用VGGNet。

# 查看torchvision自带的模型结构及参数量
from torchvision import models
summary(models.vgg16(), input_size=(1, 3, 224, 224))

1.2.3 模型训练

最后看一下模型训练部分,这部分代码与前面实现AlexNet所使用的代码基本一致,只做了几处很小的改动。下面我们还是着重对改动的部分进行讲解,对于没有改动的部分就不作赘述了,建议不太清楚的读者回顾前面的章节。

首先还是模型定义部分,这里替换成刚刚实现的vgg11模型,优化器和损失函数都不变。

# 定义模型、优化器、损失函数
model = vgg11(num_classes=102).to(device)
optimizer = optim.SGD(model.parameters(), lr=0.002, momentum=0.9)
criterion = nn.CrossEntropyLoss()

对于数据集还是使用Flowers102数据集,batch_size适当调小为64,epoch数调为200,大家也可以根据自己的实际情况自行调整。

# 加载训练数据,需要特别注意的是,Flowers102数据集中test簇的数据量较多,所以这里使用"test"作为训练集
train_dataset = datasets.Flowers102(root='../data/flowers102', split="test", 
download=True, transform=trainform_train)
# 实例化训练数据加载器
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4)
# 加载测试数据,使用"train"作为测试集
test_dataset = datasets.Flowers102(root='../data/flowers102', split="train", 
download=True, transform=transform_test)
# 实例化测试数据加载器
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, num_workers=4)

# 设置epoch数并开始训练
num_epochs = 200  # 设置epoch数

其他部分都没有变。最后看一下输出的损失和准确率曲线,如图1-6所示,200个epoch之后,测试集上的准确率约为71.8%,对比前面的AlexNet实现了一定提升,大家可以自行调整参数进行实验。

# 其他部分与AlexNet代码一致
# ...
100%|██████████| 200/200 [1:42:13<00:00, 30.67s/it]

图1-6 损失和准确率曲线

1.2.4 小结

本节详细讨论了VGGNet的特性和模型结构,提供了相关的代码实现,并将其与torchvision库中集成的模型进行了对比。我们还探讨了使用小卷积核的原因及其相关优点。此外,我们还在Flowers102数据集上训练了模型并测试了效果。