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,并基于这套数据集训练,展示了模型测试效果。