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

1.1 AlexNet

我们首先从最知名的AlexNet开始介绍主流CNN。AlexNet是由Alex Krizhevsky、Ilya Sutskever和Geoffrey Hinton等人共同设计的。到目前为止,这项工作的论文已被引用超过12万次,其创新性和影响力深深地推动了深度学习的发展,至今仍对该领域有着深远的启示。

1.1.1 AlexNet简介

AlexNet模型的设计灵感源于LeNet,但其规模远超LeNet,由5个卷积层和3个全连接层组成。它引入了一些新的技术,比如使用ReLU激活函数替代Sigmoid激活函数,以及利用Dropout进行正则化以防止过拟合等。AlexNet还是第一个在大规模数据集上训练的CNN模型,它在ImageNet数据集上获得了优异的成绩,并在计算机视觉领域引发了广泛关注。

AlexNet模型结构如图1-2所示,输入数据是224×224的三维图像,模型是一个8层的CNN,其中包括5个卷积层和3个全连接层。

图1-2 AlexNet模型结构示意

第一层是卷积层,用于提取图像的特征。它包括96个步长为4的11×11卷积核,并使用最大池化。

第二层是卷积层,包括256个5×5的卷积核,并使用最大池化。

第三、四、五层也是卷积层,分别包括384个3×3的卷积核、384个3×3的卷积核和256个3×3的卷积核。这三层之后使用最大池化。

第六层是全连接层,包括4096个神经元。

第七层是全连接层,包括4096个神经元。

第八层是输出层,包括1000个神经元,用于预测图像在ImageNet数据集中的类别。

小 白:AlexNet看起来就是一个深层CNN,没什么其他特点,为什么要放到第一个讲呢?

梗老师:AlexNet本身的网络结构现在看起来确实平淡无奇,这是因为它出现得早,后续的Inception、ResNet以及DenseNet等都是受到它的启发而出现的,所以我们先来学习它。

1.1.2 代码实现

了解了AlexNet模型结构设计之后,接下来看一下如何用代码实现相关网络结构,这样会更直观。先导入torch、torch.nn和《破解深度学习(基础篇):模型算法与实现》中用过的torchinfo。

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

下面进行结构定义。

首先用nn.Sequential()实现前面5个卷积层并定义为features。这里整体结构不变,但对卷积核略作调整。第一层是11×11的卷积层,后接ReLU+最大池化层。第二层是5×5的卷积层,后接ReLU+最大池化层。第3~5层均为3×3的卷积层,后接ReLU激活函数。最后接一个最大池化层。至此,前面5个卷积层就定义完成了。

然后是3个全连接层classifer,其实就是两个Dropout +全连接+ ReLU的组合,最后的全连接层降维到指定类别数。

最后定义forward()函数。先经过定义好的features卷积层,然后调用flatten()函数将每个样本张量展平为一维,再经过classifier的全连接层即可输出。

至此,我们实现了AlexNet的核心结构,对此还有些困惑的读者可以再看看AlexNet模型结构图,多多思考其设计思路。当然这里实际代码实现中的卷积层参数是经过优化的,与原始论文以及配图中的参数设置可能略有差异,读者着重了解其设计思路和实现技巧即可,后续不再单独说明。

# 定义AlexNet的网络结构
class AlexNet(nn.Module):
    def __init__(self, num_classes=1000, dropout=0.5):
        super().__init__()
        # 定义卷积层
        self.features = nn.Sequential(
            # 卷积+ReLU+最大池化
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            # 卷积+ReLU+最大池化
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            # 卷积+ReLU
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            # 卷积+ReLU
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            # 卷积+ReLU
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            # 最大池化
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        # 定义全连接层
        self.classifier = nn.Sequential(
            # Dropout+全连接层+ReLU
            nn.Dropout(p=dropout),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            # Dropout+全连接层+ReLU
            nn.Dropout(p=dropout),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            # 全连接层
            nn.Linear(4096, num_classes),
        )

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

接下来查看网络结构。调用torchinfo.summary()可以看一下刚刚实现的AlexNet模型信息。需要稍微注意的是,使用summary()方法时传入的input_size参数表示示例中输入数据的维度信息,这要与模型可接收的输入数据吻合。

# 查看模型结构及参数量,input_size表示示例输入数据的维度信息
summary(AlexNet(), input_size=(1, 3, 224, 224))
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
AlexNet                                  [1, 1000]                 --
├─Sequential: 1-1                       [1, 256, 6, 6]            --
│    └─Conv2d: 2-1                     [1, 64, 55, 55]           23,296
│    └─ReLU: 2-2                       [1, 64, 55, 55]           --
│    └─MaxPool2d: 2-3                  [1, 64, 27, 27]           --
│    └─Conv2d: 2-4                     [1, 192, 27, 27]          307,392
│    └─ReLU: 2-5                       [1, 192, 27, 27]          --
│    └─MaxPool2d: 2-6                  [1, 192, 13, 13]          --
│    └─Conv2d: 2-7                     [1, 384, 13, 13]          663,936
│    └─ReLU: 2-8                       [1, 384, 13, 13]          --
│    └─Conv2d: 2-9                     [1, 256, 13, 13]          884,992
│    └─ReLU: 2-10                      [1, 256, 13, 13]          --
│    └─Conv2d: 2-11                    [1, 256, 13, 13]          590,080
│    └─ReLU: 2-12                      [1, 256, 13, 13]          --
│    └─MaxPool2d: 2-13                 [1, 256, 6, 6]            --
├─Sequential: 1-2                       [1, 1000]                 --
│    └─Dropout: 2-14                   [1, 9216]                 --
│    └─Linear: 2-15                    [1, 4096]                 37,752,832
│    └─ReLU: 2-16                      [1, 4096]                 --
│    └─Dropout: 2-17                   [1, 4096]                 --
│    └─Linear: 2-18                    [1, 4096]                 16,781,312
│    └─ReLU: 2-19                      [1, 4096]                 --
│    └─Linear: 2-20                    [1, 1000]                 4,097,000
==========================================================================================
Total params: 61,100,840
Trainable params: 61,100,840
Non-trainable params: 0
Total mult-adds (M): 714.68
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 3.95
Params size (MB): 244.40
Estimated Total Size (MB): 248.96
==========================================================================================

当然我们也可以直接使用torchvision.models中集成的AlexNet模型。对比一下输出的模型结构,可以看到和前面手动实现的网络是一致的,参数量也完全一样,用这种方式能更简单地使用AlexNet模型。

# 查看torchvision自带的模型结构及参数量
from torchvision import models
summary(models.alexnet(), input_size=(1, 3, 224, 224))
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
AlexNet                                  [1, 1000]                 --
├─Sequential: 1-1                       [1, 256, 6, 6]            --
│    └─Conv2d: 2-1                     [1, 64, 55, 55]           23,296
│    └─ReLU: 2-2                       [1, 64, 55, 55]           --
│    └─MaxPool2d: 2-3                  [1, 64, 27, 27]           --
│    └─Conv2d: 2-4                     [1, 192, 27, 27]          307,392
│    └─ReLU: 2-5                       [1, 192, 27, 27]          --
│    └─MaxPool2d: 2-6                  [1, 192, 13, 13]          --
│    └─Conv2d: 2-7                     [1, 384, 13, 13]          663,936
│    └─ReLU: 2-8                       [1, 384, 13, 13]          --
│    └─Conv2d: 2-9                     [1, 256, 13, 13]          884,992
│    └─ReLU: 2-10                      [1, 256, 13, 13]          --
│    └─Conv2d: 2-11                    [1, 256, 13, 13]          590,080
│    └─ReLU: 2-12                      [1, 256, 13, 13]          --
│    └─MaxPool2d: 2-13                 [1, 256, 6, 6]            --
├─AdaptiveAvgPool2d: 1-2                [1, 256, 6, 6]            --
├─Sequential: 1-3                       [1, 1000]                 --
│    └─Dropout: 2-14                   [1, 9216]                 --
│    └─Linear: 2-15                    [1, 4096]                 37,752,832
│    └─ReLU: 2-16                      [1, 4096]                 --
│    └─Dropout: 2-17                   [1, 4096]                 --
│    └─Linear: 2-18                    [1, 4096]                 16,781,312
│    └─ReLU: 2-19                      [1, 4096]                 --
│    └─Linear: 2-20                    [1, 1000]                 4,097,000
==========================================================================================
Total params: 61,100,840
Trainable params: 61,100,840
Non-trainable params: 0
Total mult-adds (M): 714.68
==========================================================================================
Input size (MB): 0.60
Forward/backward pass size (MB): 3.95
Params size (MB): 244.40
Estimated Total Size (MB): 248.96
==========================================================================================

1.1.3 模型训练

最后看一下模型训练部分,这部分代码与《破解深度学习(基础篇):模型算法与实现》中实现LeNet所使用的代码基本一致,只做了几处较小的改动,下面着重对改动的部分进行讲解。

首先增加了一项设备检测的代码,若能检测到CUDA设备则在GPU上加速运行,若未检测到则在CPU上运行。然后定义模型,这里替换为刚刚实现的AlexNet模型,至于类别数为什么设置为102,稍后再讲。后面的to(device)是指将模型加载到对应的计算设备上。学习率也适当调整了一下,损失函数不变,还是交叉熵。

# 导入必要的库
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms, models
from tqdm import *
import numpy as np
import sys

# 设备检测,若未检测到cuda设备则在CPU上运行
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 设置随机数种子
torch.manual_seed(0)

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

接下来着重讲一下数据加载部分,这里的改动比较大。先解释为什么换了一套数据集,前面讲到AlexNet的输入是224 × 224的三维图像,那么之前使用的MNIST手写数据集就不再适用了。

为了便于复现,我们使用torchvision.datasets中自带的Flowers102数据集。从名字上能看出来,这是一个包含102个类别的花朵数据集,每个类别由40~258张图像组成。其实简单来说,Flowers102数据集与手写数据集的差异主要在于,这是一套三通道彩色图像数据。同时图片分辨率变大,类别数也从10变成了102,这就是前面类别数设置为102的原因。关于更加详细的介绍,读者可以自行查看torchvision的官方文档里或者数据集的介绍页面。

关于数据集还有两个地方需要注意。

Flowers102用于区分训练集和测试集的参数不再是MNIST数据集的train参数,而是split参数,细心的读者可能已经发现我们指定的split参数值好像反了,这是因为Flowers102数据集中测试集的数据量比训练集多,所以为简单起见,这里直接将测试集和训练集数据对调使用。大家第一次运行的时候会自动下载数据,耐心等待即可。

DataLoader中的num_workers参数表示并行加载数据的子进程数。如果你发现这部分在运行的时候报错,可以适当减小该参数的数值或直接设置为0即可。

讲解完数据集,再来看一下数据变换的部分,相比MNIST数据集,这部分也复杂了不少。首先是区分了训练集和测试集所使用的不同数据变换,有别于手写数据集中规整的数据,花朵图片的不同形态会影响其分类效果,所以对于训练集进行简单的数据增强,包括随机旋转;以随机比例裁剪并resize,这里直接指定为224即可;随机水平方向和竖直方向的翻转;转换为张量后进行归一化。需要注意,这里是对三通道的数据进行归一化,指定其均值和标准差。这三组数值通过从ImageNet数据集上的百万张图片中随机抽样计算得到,是一套适用于预训练模型的魔法常数,但久而久之大家也就都这么用了,大家了解即可。测试集的数据变换就不需要进行数据增强了,仅使用resize和归一化。最后将这里定义的方法传入Flowers102。

到这里,新数据集部分就介绍完了。建议还有疑惑的读者对照代码多看几遍。本书后面的几个代码实现中还会反复用到Flowers102数据集。如果该数据集报错,可以尝试升级torchvision的版本。

# 设置训练集的数据变换,进行数据增强
trainform_train = transforms.Compose([
    transforms.RandomRotation(30), # 随机旋转 -30度和30度之间
    transforms.RandomResizedCrop((224, 224)), # 随机比例裁剪并进行resize
    transforms.RandomHorizontalFlip(p = 0.5), # 随机水平翻转
    transforms.RandomVerticalFlip(p = 0.5), # 随机垂直翻转
    transforms.ToTensor(),  # 将数据转换为张量
    # 对三通道数据进行归一化(均值,标准差),数值是从ImageNet数据集上的百万张图片中随机抽样计算得到
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 设置测试集的数据变换,不进行数据增强,仅使用resize和归一化
transform_test = transforms.Compose([
    transforms.Resize((224, 224)),  # resize
    transforms.ToTensor(),  # 将数据转换为张量
    # 对三通道数据进行归一化(均值,标准差),数值是从ImageNet数据集上的百万张图片中随机抽样计算得到
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 加载训练数据,需要特别注意的是,Flowers102数据集中test簇的数据量较多,所以这里使用"test"作为训练集
train_dataset = datasets.Flowers102(root='../data/flowers102', split="test", 
                                    download=True, transform=trainform_train)

# 实例化训练数据加载器
train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True, num_workers=6)

# 加载测试数据,使用"train"作为测试集
test_dataset = datasets.Flowers102(root='../data/flowers102', split="train", 
                                   download=True, transform=transform_test)

# 实例化测试数据加载器
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=6)

下面我们需要设置好epoch数,还需要将数据也加载到指定的计算设备上。对于训练集和测试集都需要进行上述操作,和模型部分一样加个to(device)即可。其他内容都和LeNet部分的代码完全一样。最后输出的损失和准确率曲线如图1-3所示。500个epoch之后,测试集上的准确率约为65.8%,大家可以自行调整参数进行实验。

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

# 设置epoch数并开始训练
num_epochs = 500  # 设置epoch数
loss_history = []  # 创建损失历史记录列表
acc_history = []   # 创建准确率历史记录列表

# tqdm用于显示进度条并评估任务时间开销
for epoch in tqdm(range(num_epochs), file=sys.stdout):
    # 记录损失和预测正确数
    total_loss = 0
    total_correct = 0
    
    # 批量训练
    model.train()
    for inputs, labels in train_loader:

        # 将数据转移到指定计算资源设备上
        inputs = inputs.to(device)
        labels = labels.to(device)
        
        # 预测、损失函数、反向传播
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        
        # 记录训练集loss
        total_loss += loss.item()
    
    # 测试模型,不计算梯度
    model.eval()
    with torch.no_grad():
        for inputs, labels in test_loader:
            # 将数据转移到指定计算资源设备上
            inputs = inputs.to(device)
            labels = labels.to(device)
            
            # 预测
            outputs = model(inputs)
            # 记录测试集预测正确数
            total_correct += (outputs.argmax(1) == labels).sum().item()
        
    # 记录训练集损失和测试集准确率
    loss_history.append(np.log10(total_loss))  # 将损失加入损失历史记录列表,由于数值有时较大,这里取对数
    acc_history.append(total_correct / len(test_dataset))# 将准确率加入准确率历史记录列表
    
    # 打印中间值
    if epoch % 50 == 0:
        tqdm.write("Epoch: {0} Loss: {1} Acc: {2}".format(epoch, loss_history[-1], acc_history[-1]))

# 使用Matplotlib绘制损失和准确率的曲线图
import matplotlib.pyplot as plt
plt.plot(loss_history, label='loss')
plt.plot(acc_history, label='accuracy')
plt.legend()
plt.show()

# 输出准确率
print("Accuracy:", acc_history[-1])
Epoch: 0 Loss: 2.056710654079528 Acc: 0.00980392156862745
Epoch: 50 Loss: 1.81992189286025 Acc: 0.2568627450980392
Epoch: 100 Loss: 1.7028522357427933 Acc: 0.40588235294117647
Epoch: 150 Loss: 1.655522045895868 Acc: 0.3480392156862745
Epoch: 200 Loss: 1.4933272565532425 Acc: 0.5362745098039216
Epoch: 250 Loss: 1.4933084530330363 Acc: 0.592156862745098
Epoch: 300 Loss: 1.3643970408766197 Acc: 0.6303921568627451
Epoch: 350 Loss: 1.349363543060287 Acc: 0.6107843137254902
Epoch: 400 Loss: 1.3387303540492963 Acc: 0.6411764705882353
Epoch: 450 Loss: 1.2464284899548854 Acc: 0.6696078431372549
100%|██████████| 500/500 [2:41:21<00:00, 19.36s/it]

1.1.4 小结

在本节中,我们首先回顾一些经典CNN,随后着重介绍了AlexNet的特点、网络结构及代码实现。从模型定义、网络结构实现以及与torchvision中集成的模型进行对比,让大家对它有了全面深入的理解。同时,我们引入了一套新的实验数据集Flowers102,并基于这套数据集训练,展示了模型测试效果。