1、引言
在本篇文章中,我们将深入探讨并实现一些现代卷积神经网络(CNN)架构的变体。近年来,学界提出了众多新颖的网络架构。其中一些最具影响力,并且至今仍然具有重要地位的架构包括:GoogleNet/Inception架构(2014年ILSVRC竞赛的冠军),ResNet(2015年ILSVRC竞赛的冠军),以及DenseNet(2017年CVPR会议的最佳论文奖得主)。这些网络在刚提出时均代表了当时技术的最前沿,它们的核心理念已成为当前大多数顶尖架构的基石。因此,深入理解这些架构的细节,并掌握其实现方法,对于我们来说至关重要。
首先,我们来导入一些常用的库。
## 标准库
import os # 导入操作系统接口
import numpy as np # 导入科学计算库
import random # 导入随机数生成库
from PIL import Image # 从PIL库导入图像处理模块
from types import SimpleNamespace # 导入命名空间类型
## 绘图相关导入
import matplotlib.pyplot as plt # 导入matplotlib的绘图模块
%matplotlib inline # 使matplotlib绘图在Jupyter Notebook中内联显示
from IPython.display import set_matplotlib_formats # 导入设置matplotlib格式的函数
set_matplotlib_formats('svg', 'pdf') # 设置matplotlib输出格式为SVG和PDF,便于导出
import matplotlib # 导入matplotlib库
matplotlib.rcParams['lines.linewidth'] = 2.0 # 设置matplotlib线条宽度为2.0
import seaborn as sns # 导入seaborn库,用于数据可视化
sns.reset_orig() # 重置seaborn的默认设置
## PyTorch
import torch # 导入PyTorch库
import torch.nn as nn # 导入PyTorch的神经网络模块
import torch.utils.data as data # 导入PyTorch的数据工具模块
import torch.optim as optim # 导入PyTorch的优化器模块
# Torchvision
import torchvision # 导入Torchvision库,用于处理图像和视频
from torchvision.datasets import CIFAR10 # 从Torchvision的datasets模块导入CIFAR-10数据集
from torchvision import transforms # 从Torchvision导入图像转换模块
在本文中,我们将沿用之前博文中使用的set_seed
函数,并利用DATASET_PATH
和CHECKPOINT_PATH
这两个路径变量。根据实际情况,你可能需要对这些路径进行相应的调整。这样做可以确保我们的代码在不同环境中都能正确地找到数据集和模型检查点。
# 定义数据集存储路径,例如CIFAR-10数据集的下载位置
DATASET_PATH = "../data"
# 定义预训练模型的保存路径
CHECKPOINT_PATH = "../saved_models/tutorial5"
# 设置随机种子的函数,确保实验结果的可复现性
def set_seed(seed):
random.seed(seed) # 设置Python内置随机数生成器的种子
np.random.seed(seed) # 设置NumPy随机数生成器的种子
torch.manual_seed(seed) # 设置PyTorch随机数生成器的种子
if torch.cuda.is_available(): # 如果CUDA可用
torch.cuda.manual_seed(seed) # 设置CUDA随机数生成器的种子
torch.cuda.manual_seed_all(seed) # 设置CUDA所有设备的随机数生成器种子
# 调用set_seed函数,传入种子值42
set_seed(42)
# 确保在GPU上的所有操作都是确定性的(如果使用GPU),以提高结果的可复现性
torch.backends.cudnn.deterministic = True # 设置为True,确保CuDNN的算法是确定性的
torch.backends.cudnn.benchmark = False # 设置为False,关闭CuDNN的自动调优功能
# 根据CUDA是否可用,选择使用GPU或CPU
device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
在本文中,我们提供了预先训练好的模型以及TensorBoard日志文件(稍后会对这些内容进行详细说明)。你可以通过以下链接下载这些资源。这将帮助你更快速地开始实验,同时确保你能够直观地查看训练过程中的各种指标变化。
import urllib.request # 导入urllib库的request模块,用于处理网络请求
from urllib.error import HTTPError # 导入HTTPError,用于捕获HTTP请求错误
# 定义GitHub仓库中存储预训练模型的URL
base_url = "https://raw.githubusercontent.com/phlippe/saved_models/main/tutorial5/"
# 定义需要下载的文件列表
pretrained_files = ["GoogleNet.ckpt", "ResNet.ckpt", "ResNetPreAct.ckpt", "DenseNet.ckpt",
"tensorboards/GoogleNet/events.out.tfevents.googlenet",
"tensorboards/ResNet/events.out.tfevents.resnet",
"tensorboards/ResNetPreAct/events.out.tfevents.resnetpreact",
"tensorboards/DenseNet/events.out.tfevents.densenet"]
# 如果检查点路径不存在,则创建它
os.makedirs(CHECKPOINT_PATH, exist_ok=True)
# 对于列表中的每个文件,检查它是否已经存在。如果不存在,尝试下载它。
for file_name in pretrained_files:
file_path = os.path.join(CHECKPOINT_PATH, file_name) # 构建文件的完整路径
if "/" in file_name: # 如果文件名中包含"/",则需要创建相应的目录结构
os.makedirs(file_path.rsplit("/", 1)[0], exist_ok=True) # 创建目录
if not os.path.isfile(file_path): # 检查文件是否已经存在
file_url = base_url + file_name # 构建文件的下载URL
print(f"正在下载 {file_url}...")
try:
urllib.request.urlretrieve(file_url, file_path) # 尝试下载文件
except HTTPError as e: # 捕获并处理HTTP请求错误
print("下载过程中出现问题。请尝试从Google Drive文件夹下载文件,或将完整的输出信息,包括以下错误,联系作者:\n", e)
在本文中,我们将对CIFAR-10数据集进行模型的训练与评估。这样,你便可以将本教程中得到的结果与你在首次作业中实现的模型结果进行对比。根据我们在上一个教程中学到的初始化知识,数据经过预处理使其均值归零是至关重要的。因此,我们首先需要计算CIFAR数据集的均值和标准差。
# 导入CIFAR-10数据集,设置数据集的根目录为DATASET_PATH,指定为训练集,并自动下载
train_dataset = CIFAR10(root=DATASET_PATH, train=True, download=True)
# 计算数据集的均值,将数据归一化到[0,1]区间后,沿所有图像通道计算平均值
DATA_MEANS = (train_dataset.data / 255.0).mean(axis=(0, 1, 2))
# 计算数据集的标准差,同样先归一化到[0,1]区间,然后沿所有图像通道计算标准差
DATA_STD = (train_dataset.data / 255.0).std(axis=(0, 1, 2))
# 打印数据集的均值和标准差
print("数据均值:", DATA_MEANS)
print("数据标准差:", DATA_STD)
在本文中,我们将使用这些统计数据来构建一个transforms.Normalize
模块,以便对数据进行适当的标准化处理。此外,为了提高模型的泛化能力并减少过拟合的风险,我们在训练过程中引入了数据增强技术。具体来说,我们会应用两种数据增强方法。
首先,我们将对每张图像以50%的概率进行随机的水平翻转,这是通过transforms.RandomHorizontalFlip
实现的。一般来说,图像的水平翻转不会影响其类别识别,因为物体的类别与图像的水平方向无关。然而,如果我们的目标是图像中的数字或字母识别,那么方向性就变得重要了。
第二种数据增强方法是transforms.RandomResizedCrop
,它通过在一定范围内随机裁剪图像,可能改变图像的纵横比,然后再将其缩放回原始尺寸。这样,尽管图像的像素值发生了变化,但图像的内容和整体语义信息仍然保持一致。
接下来,我们会将训练数据集随机划分为训练子集和验证子集。验证子集将用于评估模型在训练过程中的性能,以便于我们实施早停策略(early stopping)。训练结束后,我们将在CIFAR的测试集上对模型进行评估,以测试其对未知数据的处理能力。
# 定义测试集的转换操作,包括转换为张量以及标准化
test_transform = transforms.Compose([
transforms.ToTensor(), # 将PIL图像或Numpy数组转换为`FloatTensor`,并将数值范围从[0, 255]缩放到[0.0, 1.0]
transforms.Normalize(DATA_MEANS, DATA_STD) # 标准化,使数据具有指定的均值和标准差
])
# 定义训练集的转换操作,包括数据增强和标准化
train_transform = transforms.Compose([
transforms.RandomHorizontalFlip(), # 以50%的概率随机水平翻转图像
transforms.RandomResizedCrop((32, 32), scale=(0.8, 1.0), ratio=(0.9, 1.1)), # 随机裁剪并缩放图像
transforms.ToTensor(), # 转换为张量
transforms.Normalize(DATA_MEANS, DATA_STD) # 标准化
])
# 加载训练数据集,并将其划分为训练集和验证集,注意验证集不使用数据增强
train_dataset = CIFAR10(root=DATASET_PATH, train=True, transform=train_transform, download=True)
val_dataset = CIFAR10(root=DATASET_PATH, train=True, transform=test_transform, download=True)
# 设置随机种子以确保结果的可复现性
set_seed(42)
# 将训练数据集随机划分为45000张图像用于训练,5000张图像用于验证
train_set, _ = torch.utils.data.random_split(train_dataset, [45000, 5000])
set_seed(42)
# 将验证数据集随机划分,这里使用相同的划分策略,但实际上验证集不需要划分
_, val_set = torch.utils.data.random_split(val_dataset, [45000, 5000])
# 加载测试集
test_set = CIFAR10(root=DATASET_PATH, train=False, transform=test_transform, download=True)
# 定义数据加载器,用于后续的训练、验证和测试
train_loader = data.DataLoader(train_set, batch_size=128, shuffle=True, drop_last=True, pin_memory=True, num_workers=4)
val_loader = data.DataLoader(val_set, batch_size=128, shuffle=False, drop_last=False, num_workers=4)
test_loader = data.DataLoader(test_set, batch_size=128, shuffle=False, drop_last=False, num_workers=4)
为了确保我们的标准化处理达到预期效果,我们可以通过打印单个数据批次的均值和标准差来进行检验。理想情况下,每个颜色通道的均值应该趋近于0,标准差趋近于1。这是一种常用的做法,用以确保数据经过标准化处理后,分布更加接近标准正态分布,从而有助于模型的训练和收敛。
# 获取训练数据加载器的第一个批次的数据
imgs, _ = next(iter(train_loader))
# 打印该批次数据的均值,dim参数指定了要计算均值的维度
print("批次均值", imgs.mean(dim=[0, 2, 3]))
# 打印该批次数据的标准差,dim参数指定了要计算标准差的维度
print("批次标准差", imgs.std(dim=[0, 2, 3]))
接下来,我们将对训练集中的部分图像进行可视化展示,并观察它们在应用了随机数据增强技术之后的效果。这有助于我们直观地理解数据增强对图像的影响,以及它是如何帮助改善模型的泛化能力的。
# 定义要显示的图像数量
NUM_IMAGES = 4
# 从训练数据集中获取指定数量的图像
images = [train_dataset[idx][0] for idx in range(NUM_IMAGES)]
# 将原始图像数据转换为PIL图像格式
orig_images = [Image.fromarray(np.transpose(train_dataset.data[idx], (1, 2, 0))) for idx in range(NUM_IMAGES)]
# 应用测试转换操作到原始图像上
orig_images = [test_transform(img) for img in orig_images]
# 使用torchvision的make_grid函数创建图像网格,将原始图像和增强后的图像堆叠起来
# nrow指定每行显示的图像数量,normalize设置为True表示将像素值归一化到[0,1],pad_value设置为0.5表示填充值
img_grid = torchvision.utils.make_grid(torch.stack(images + orig_images, dim=0), nrow=4, normalize=True, pad_value=0.5)
# permute函数用于重新排列图像张量的维度,以适应matplotlib的显示要求
img_grid = img_grid.permute(1, 2, 0)
# 使用matplotlib创建图像展示
plt.figure(figsize=(8, 8)) # 设置图像展示窗口的大小
plt.title("CIFAR10数据增强示例") # 设置图像展示的标题
plt.imshow(img_grid) # 显示图像网格
plt.axis('off') # 不显示坐标轴
plt.show() # 展示图像
plt.close() # 展示完毕后关闭图像展示窗口
2.使用PytTorch Linghtning
在本文和后续的文章中,我们将利用一个名为PyTorch Lightning的库。PyTorch Lightning是一个框架,它极大地简化了在PyTorch中编写训练、评估和测试模型的代码。它还负责将日志信息记录到TensorBoard——这是一个用于机器学习实验的可视化工具,并且能够自动保存模型的检查点,而我们几乎不需要编写额外的代码。这对于我们来说极为便利,因为我们更愿意将精力集中在不同模型架构的实现上,而不是花费大量时间处理其他代码问题。本文使用的是PyTorch Lightning1.8版本,请读者自行到官网更新最新的版本。
现在,我们将在PyTorch Lightning中迈出探索的第一步,并在后续的文章中继续深入了解这个框架。首先,我们需要导入这个库。
# 尝试导入PyTorch Lightning库
try:
import pytorch_lightning as pl
except ModuleNotFoundError: # 如果模块未找到异常,例如Google Colab默认没有安装PyTorch Lightning
# 使用pip命令静默安装PyTorch Lightning,版本要求大于等于1.5
!pip install --quiet pytorch-lightning>=1.5
# 再次尝试导入PyTorch Lightning库
import pytorch_lightning as pl
PyTorch Lightning框架内建了大量实用的功能,包括一个用于设定随机种子的便捷方法:
# 设置随机种子以确保实验的可重复性
pl.seed_everything(42)
在未来的工作中,我们将无需再自行定义设置随机种子的函数。
在PyTorch Lightning框架中,我们通过pl.LightningModule
(继承自PyTorch的torch.nn.Module
)来构建我们的模型,它将代码逻辑划分为五个核心部分:
- 初始化 (
__init__
):在这里,我们初始化所有必要的参数和模型结构。 - 配置优化器 (
configure_optimizers
):在这部分,我们定义模型的优化器、学习率调整策略等。 - 训练步骤 (
training_step
):我们仅需指定单个数据批次的损失计算方法,而优化器的梯度清零、损失反向传播和参数更新等步骤,以及日志记录或保存操作,都由框架在后台自动处理。 - 验证步骤 (
validation_step
):与训练类似,我们定义每个验证步骤需要进行的操作。 - 测试步骤(
test_step
):这与验证步骤类似,只不过是应用于测试数据集。
通过这种方式,PyTorch Lightning并没有简化PyTorch的代码,而是对其进行了有序组织,并提供了一些常用的默认操作。如果你需要对训练、验证或测试流程进行特定的调整,框架提供了多种可重写的方法来满足个性化需求(具体细节请参考官方文档)。
接下来,我们可以观察一个使用PyTorch Lightning构建的用于训练卷积神经网络的模块示例。
class CIFARModule(pl.LightningModule):
def __init__(self, model_name, model_hparams, optimizer_name, optimizer_hparams):
"""
构造函数初始化一个用于CIFAR数据集的模型模块。
参数:
model_name - 要运行的模型/CNN名称,用于创建模型。
model_hparams - 模型的超参数字典。
optimizer_name - 使用的优化器名称,目前支持Adam和SGD。
optimizer_hparams - 优化器的超参数字典,包括学习率、权重衰减等。
"""
super().__init__() # 调用父类构造函数
self.save_hyperparameters() # 将超参数导出到YAML文件,并创建"self.hparams"命名空间
# 创建模型,使用给定的模型名称和超参数
self.model = create_model(model_name, model_hparams)
# 创建损失模块
self.loss_module = nn.CrossEntropyLoss()
# 用于在Tensorboard中可视化图的示例输入
self.example_input_array = torch.zeros((1, 3, 32, 32), dtype=torch.float32)
def forward(self, imgs):
# 当可视化图时运行的前向函数
return self.model(imgs)
def configure_optimizers(self):
# 根据选择的优化器名称创建优化器
if self.hparams.optimizer_name == "Adam":
# 使用AdamW,即带有正确实现权重衰减的Adam(详见提供的链接)
optimizer = optim.AdamW(self.parameters(), **self.hparams.optimizer_hparams)
elif self.hparams.optimizer_name == "SGD":
optimizer = optim.SGD(self.parameters(), **self.hparams.optimizer_hparams)
else:
# 如果提供了未知的优化器名称,断言失败
assert False, f"Unknown optimizer: \"{self.hparams.optimizer_name}\""
# 学习率调度器,在第100和150个epoch后将学习率降低0.1
scheduler = optim.lr_scheduler.MultiStepLR(optimizer, milestones=[100, 150], gamma=0.1)
return [optimizer], [scheduler] # 返回优化器和学习率调度器
def training_step(self, batch, batch_idx):
# 训练步骤,对每个批次的数据执行
imgs, labels = batch
preds = self.model(imgs) # 模型预测
loss = self.loss_module(preds, labels) # 计算损失
acc = (preds.argmax(dim=-1) == labels).float().mean() # 计算准确率
# 在Tensorboard中记录每个epoch的准确率(跨批次的加权平均值)
self.log('train_acc', acc, on_step=False, on_epoch=True)
self.log('train_loss', loss)
return loss # 返回损失张量以调用".backward"
def validation_step(self, batch, batch_idx):
# 验证步骤,对验证数据执行
imgs, labels = batch
preds = self.model(imgs).argmax(dim=-1)
acc = (labels == preds).float().mean()
# 默认每个epoch记录一次(跨批次的加权平均值)
self.log('val_acc', acc)
def test_step(self, batch, batch_idx):
# 测试步骤,对测试数据执行
imgs, labels = batch
preds = self.model(imgs).argmax(dim=-1)
acc = (labels == preds).float().mean()
# 默认每个epoch记录一次(跨批次的加权平均值),然后返回
self.log('test_acc', acc)
代码的组织结构清晰有序,这有助于他人理解你的代码逻辑。
PyTorch Lightning框架中一个关键的概念是回调(callbacks)。回调函数是一些自包含的函数,它们包含了Lightning Module中非核心的逻辑。这些回调函数通常在训练周期结束后被调用,但它们也可能会影响到训练循环的其他环节。例如,我们将采用以下两个预定义的回调:LearningRateMonitor
(学习率监控器)和ModelCheckpoint
(模型检查点)。学习率监控器会将当前的学习率信息添加到TensorBoard中,这有助于我们确认学习率调度器是否按预期工作。模型检查点回调则允许你定制检查点的保存策略,比如保留多少个检查点、何时进行保存、根据哪个指标来决定保存等。下面是这些回调的导入代码:
# 回调函数导入
from pytorch_lightning.callbacks import LearningRateMonitor, ModelCheckpoint
为了能够使用同一个Lightning模块来运行多种不同的模型,我们下面定义了一个函数,它将模型名称映射到相应的模型类。目前,model_dict
字典为空,但我们将在接下来的笔记本使用过程中用新模型来填充它。
model_dict = {}
def create_model(model_name, model_hparams):
if model_name in model_dict:
return model_dict[model_name](**model_hparams)
else:
assert False, f"未知的模型名称\"{model_name}\"。可用的模型有:{str(model_dict.keys())}"
类似地,为了在我们的模型中将激活函数作为超参数使用,我们下面定义了一个将名称映射到函数的字典:
act_fn_by_name = {
"tanh": nn.Tanh,
"relu": nn.ReLU,
"leakyrelu": nn.LeakyReLU,
"gelu": nn.GELU
通过这种方式,代码的复用性和灵活性得以增强,同时也方便了不同模型和配置之间的切换,保持了代码的整洁和易于维护。如果我们直接将类或对象作为参数传递给Lightning模块,就无法享受PyTorch Lightning提供的自动保存和加载超参数的特性。
在PyTorch Lightning框架中,除了Lightning模块外,另一个核心组件是Trainer(训练器)。训练器的职责是执行Lightning模块中定义的训练步骤,并确保整个训练流程的完整性。与Lightning模块一样,你可以对任何你不想自动执行的关键部分进行覆盖,但通常情况下,默认设置就是最佳实践。有关全部功能的详细介绍,请查看官方文档。我们下面使用到的最重要的几个函数包括:
trainer.fit
:接收一个Lightning模块、一个训练数据集,以及一个(可选的)验证数据集作为输入。此函数负责在训练数据集上训练指定的模块,并且可以定期进行验证(默认是每个epoch一次,但这个频率可以调整)。trainer.test
:接收一个模型和我们希望进行测试的数据集作为输入。它将返回该数据集上的测试指标。
在进行训练和测试时,我们无需担心如将模型设置为评估模式(model.eval()
)等细节,因为这些操作都会自动完成。以下是我们如何定义模型的训练函数的示例:
def train_model(model_name, save_name=None, **kwargs):
"""
参数:
model_name - 要运行的模型名称,用于在"model_dict"中查找对应的类。
save_name (可选) - 如果提供,这个名称将被用于创建检查点和日志目录。
"""
if save_name是None:
save_name = model_name
# 创建一个PyTorch Lightning训练器,并配置生成回调
trainer = pl.Trainer(default_root_dir=os.path.join(CHECKPOINT_PATH, save_name), # 模型保存位置
accelerator="gpu" if str(device).startswith("cuda") else "cpu", # 尽可能在GPU上运行
devices=1, # 使用的GPU/CPU数量(笔记本示例中1足够了)
max_epochs=180, # 训练的最大周期数,若未设置早停则使用此值
callbacks=[ModelCheckpoint(save_weights_only=True, mode="max", monitor="val_acc"), # 基于最大val_acc记录保存最佳检查点,仅保存权重而非优化器状态
LearningRateMonitor("epoch")], # 每个epoch记录学习率
enable_progress_bar=True) # 是否显示进度条
trainer.logger._log_graph = True # 是否在TensorBoard中绘制计算图
trainer.logger._default_hp_metric = None # 我们不需要的可选日志参数
# 检查预训练模型是否存在,如果存在则加载并跳过训练
pretrained_filename = os.path.join(CHECKPOINT_PATH, save_name + ".ckpt")
if os.path.isfile(pretrained_filename):
print(f"在{pretrained_filename}找到预训练模型,正在加载...")
model = CIFARModule.load_from_checkpoint(pretrained_filename) # 自动加载模型和保存的超参数
else:
pl.seed_everything(42) # 确保结果可复现
model = CIFARModule(model_name=model_name, **kwargs)
trainer.fit(model, train_loader, val_loader)
# 训练完成后加载最佳检查点
model = CIFARModule.load_from_checkpoint(trainer.checkpoint_callback.best_model_path)
# 在验证集和测试集上测试最佳模型的性能
val_result = trainer.test(model, val_loader, verbose=False)
test_result = trainer.test(model, test_loader, verbose=False)
result = {"test": test_result[0]["test_acc"], "val": val_result[0]["test_acc"]}
return model, result
最终,我们可以专注于实现我们今天计划中的卷积神经网络:GoogleNet、ResNet和DenseNet。
3、Inception(初始网络)
2014年提出的GoogleNet因其创新的Inception模块设计而荣获ImageNet挑战赛的冠军。本文将重点介绍Inception的核心概念,而非GoogleNet的具体细节。这是因为Inception理念催生了众多衍生模型,如Inception-v2、Inception-v3、Inception-v4、Inception-ResNet等,这些后续工作主要致力于提升网络效率并构建更深的Inception网络结构。然而,对于基础理解而言,研究最初的Inception模块已经足够。
Inception模块的特色在于它能够在同一特征图上并行地应用四种不同的卷积操作:1x1、3x3和5x5的卷积操作,以及一个最大池化操作。这样的设计使得网络能够以不同的感受野捕捉信息。虽然单纯学习5x5卷积在理论上可能更强大,但这不仅计算量大、内存消耗高,也更容易出现过拟合问题。下图展示了Inception模块的结构(图源:Szegedy等人的研究):
1x1卷积操作在3x3和5x5卷积之前进行,主要作用是降维。这一点至关重要,因为所有分支的特征图最终会合并,我们希望避免特征维度的爆炸式增长。由于5x5卷积的计算成本是1x1卷积的25倍,因此在执行大尺寸卷积前进行降维可以大大节省计算资源和参数数量。
接下来,我们可以尝试实现Inception模块:
class InceptionBlock(nn.Module):
def __init__(self, c_in, c_red: dict, c_out: dict, act_fn):
"""
参数:
c_in - 前一层传递给模块的输入特征图数量
c_red - 一个字典,包含"3x3"和"5x5"两个键,指明1x1卷积降维操作的输出维度
c_out - 一个字典,包含"1x1", "3x3", "5x5", 和 "max"四个键
act_fn - 激活函数的类构造器,例如nn.ReLU
"""
super().__init__()
# 1x1卷积分支
self.conv_1x1 = nn.Sequential(
nn.Conv2d(c_in, c_out["1x1"], kernel_size=1),
nn.BatchNorm2d(c_out["1x1"]),
act_fn()
)
# 3x3卷积分支
self.conv_3x3 = nn.Sequential(
nn.Conv2d(c_in, c_red["3x3"], kernel_size=1),
nn.BatchNorm2d(c_red["3x3"]),
act_fn(),
nn.Conv2d(c_red["3x3"], c_out["3x3"], kernel_size=3, padding=1),
nn.BatchNorm2d(c_out["3x3"]),
act_fn()
)
# 5x5卷积分支
self.conv_5x5 = nn.Sequential(
nn.Conv2d(c_in, c_red["5x5"], kernel_size=1),
nn.BatchNorm2d(c_red["5x5"]),
act_fn(),
nn.Conv2d(c_red["5x5"], c_out["5x5"], kernel_size=5, padding=2),
nn.BatchNorm2d(c_out["5x5"]),
act_fn()
)
# 最大池化分支
self.max_pool = nn.Sequential(
nn.MaxPool2d(kernel_size=3, padding=1, stride=1),
nn.Conv2d(c_in, c_out["max"], kernel_size=1),
nn.BatchNorm2d(c_out["max"]),
act_fn()
)
def forward(self, x):
# 对输入x应用各个分支
x_1x1 = self.conv_1x1(x)
x_3x3 = self.conv_3x3(x)
x_5x5 = self.conv_5x5(x)
x_max = self.max_pool(x)
# 沿着特征维度将各个分支的输出进行合并
x_out = torch.cat([x_1x1, x_3x3, x_5x5, x_max], dim=1)
return x_out
上述代码定义了一个Inception模块的类InceptionBlock
,它接收输入特征图并分别通过不同尺寸的卷积和池化操作处理,最终将结果合并,为后续的网络层提供更丰富的特征表示。
GoogleNet架构通过堆叠多个Inception模块,并在必要时使用最大池化操作来降低特征图的尺寸。最初的GoogleNet是针对ImageNet(224x224像素)设计的,拥有近700万个参数。由于我们在CIFAR10数据集上进行训练,图像尺寸为32x32像素,因此不需要如此复杂的架构,而是采用简化版。每个滤波器(1x1、3x3、5x5和最大池化)的降维和输出通道数需要手动设定,如果需要,这些参数也可以进行调整。通常的建议是为3x3卷积分配更多的滤波器,因为它们能够在参数数量相对较少的情况下捕获足够的上下文信息。
class GoogleNet(nn.Module):
def __init__(self, num_classes=10, act_fn_name="relu", **kwargs):
super().__init__()
self.hparams = SimpleNamespace(
num_classes=num_classes,
act_fn_name=act_fn_name,
act_fn=act_fn_by_name[act_fn_name]
)
self._create_network()
self._init_params()
def _create_network(self):
# 对原始图像进行首次卷积以扩展通道数
self.input_net = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
self.hparams.act_fn()
)
# 堆叠Inception模块
self.inception_blocks = nn.Sequential(
# 此处应包含多个Inception模块的具体配置
# InceptionBlock的具体参数应根据设计要求设定
)
# 特征映射到分类输出
self.output_net = nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(128, self.hparams.num_classes)
)
def _init_params(self):
# 根据我们在第4个教程中的讨论,我们应该根据激活函数初始化卷积层的参数
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, nonlinearity=self.hparams.act_fn_name)
elif isinstance(m, nn.BatchNorm2d):
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
def forward(self, x):
x = self.input_net(x)
x = self.inception_blocks(x)
x = self.output_net(x)
return x
现在,我们可以将GoogleNet模型集成到之前定义的模型字典中:
model_dict["GoogleNet"] = GoogleNet
模型的训练由PyTorch Lightning框架负责,我们只需定义启动命令即可。注意,我们训练了近200个epoch,在Snellius的默认GPU(NVIDIA A100)上不到一个小时即可完成。如果感兴趣,我们建议直接使用保存的模型,并根据需要训练自己的模型。
googlenet_model, googlenet_results = train_model(model_name="GoogleNet",
model_hparams={"num_classes": 10,
"act_fn_name": "relu"},
optimizer_name="Adam",
optimizer_hparams={"lr": 1e-3,
"weight_decay": 1e-4})
# 根据实际环境和模型状态,输出示例可能有所不同
print("GoogleNet Results", googlenet_results)
# GoogleNet Results {'test': 0.8970000147819519, 'val': 0.9039999842643738}
我们将在后续的文章中比较结果,但可以先在这里打印出来以供初步查看。
print("GoogleNet Results", googlenet_results)
3.1.TensorBoard 日志记录
PyTorch Lightning 框架的一个亮点是其能够自动将训练过程中的数据记录到 TensorBoard。为了更直观地展示 TensorBoard 的功能,我们可以参考 PyTorch Lightning 在训练 GoogleNet 模型时生成的日志。TensorBoard 为 Jupyter 笔记本提供了一个便捷的内联显示功能,这里我们将展示如何使用它:
(1)加载 TensorBoard 扩展:
%load_ext tensorboard
(2)启动 TensorBoard:
调整日志目录路径以指向你的模型保存路径:
%tensorboard --logdir ../saved_models/tutorial5/tensorboards/GoogleNet/
c40d590b2e32488fb9b659e4ae7530f3
TensorBoard 的界面被划分为多个标签页。其中最主要的是标量(Scalar)标签页,这里可以记录单一数值的变化,例如训练损失、准确率、学习率等。通过观察训练或验证的准确率,我们可以看到引入学习率调度器的效果。降低学习率能够显著提升模型的训练表现。同样,观察训练损失时,我们会发现在某一点上损失值突然下降。然而,训练集上的数值显著高于验证集,这表明模型出现了过拟合现象,对于这种大型网络来说,过拟合是难以避免的。
另一个引人入胜的标签页是图(Graph)标签页。它展示了从输入到输出的网络架构,按构建模块组织。基本上,它展示了 CIFARModule 在前向传播过程中执行的操作。你可以通过双击某个模块来展开查看。不妨从不同角度探索模型的架构。图形可视化常常有助于验证模型是否按照预期进行操作,确保计算图中没有遗漏任何层。
4.残差网络(ResNet)
ResNet 论文堪称人工智能领域内被引用次数最多的经典之作,它为构建超过千层的深度神经网络提供了理论基础。ResNet 的核心思想——残差连接,虽然简单,却极为高效,它通过维持网络中的梯度稳定传播,解决了深层网络训练中的难题。在传统的神经网络中,我们通常将下一层的输出表达为
x
l
+
1
=
F
(
x
l
)
x_{l+1}=F(x_{l})
xl+1=F(xl),而在 ResNet 中,我们采用的公式是
x
l
+
1
=
x
l
+
F
(
x
l
)
x_{l+1}=x_{l}+F(x_{l})
xl+1=xl+F(xl),其中
F
F
F代表一系列非线性变换,如卷积、激活函数和归一化操作。在残差连接的背景下,反向传播的梯度表达式为:
∂
x
l
+
1
∂
x
l
=
I
+
∂
F
(
x
l
)
∂
x
l
\frac{\partial x_{l+1}}{\partial x_{l}} = \mathbf{I} + \frac{\partial F(x_{l})}{\partial x_{l}}
∂xl∂xl+1=I+∂xl∂F(xl)
其中,对单位矩阵的偏置确保了梯度传播的稳定性,减少了网络深度对梯度衰减的影响。自 ResNet 问世以来,研究者们提出了众多变体,主要集中在 的功能实现或对求和操作的改进上。本文中将重点介绍两种变体:传统的 ResNet 模块和预激活 ResNet 模块,并在下方通过可视化对比这两种模块(图示来源:何等人):
在传统的 ResNet 模块中,跳跃连接之后通常会跟一个非线性激活函数,如 ReLU。而预激活 ResNet 模块则将非线性激活提前至 的开始部分。这两种设计各有利弊。然而,在构建极深的网络时,预激活 ResNet 显示出更优的性能,因为其梯度流始终保证了单位矩阵的存在,不受任何非线性激活的影响。为了对比,本教程实现了这两种浅层网络结构。
现在,让我们深入探讨原始的 ResNet 模块。上述图示已经清晰展示了 中包含的层。特别地,当我们需要在宽度和高度上减少图像的维度时,需要特别注意。基本的 ResNet 模块要求 具有相同的形状。因此,在将 加到 之前,我们需要调整 的维度。最初的实现采用了步长为2的恒等映射,并通过0填充来增加额外的特征维度。但更常见的做法是使用步长为2的 1x1 卷积,这样既能在参数和计算成本上保持高效,又能实现特征维度的调整。ResNet 模块的代码实现相对简洁,具体如下所示:
4.1.传统 ResNet 模块
import torch.nn as nn
class ResNetBlock(nn.Module):
def __init__(self, c_in, act_fn, subsample=False, c_out=-1):
"""
构造函数参数说明:
c_in - 输入特征的数量
act_fn - 激活函数的类构造器(例如:nn.ReLU)
subsample - 如果为 True,则在模块内部应用步长,并将输出形状在高度和宽度上减少2
c_out - 输出特征的数量。注意,这只在 subsample 为 True 时才相关,否则 c_out 默认等于 c_in
"""
super(ResNetBlock, self).__init__()
self.subsample = subsample
if self.subsample:
c_out = c_in if c_out <= 0 else c_out
# 定义网络 F
self.net = nn.Sequential(
nn.Conv2d(c_in, c_out, kernel_size=3, padding=1, stride=2 if subsample else 1, bias=False), # 由于批量归一化处理,不需要偏置
nn.BatchNorm2d(c_out),
act_fn(),
nn.Conv2d(c_out, c_out, kernel_size=3, padding=1, bias=False)
)
# 1x1 卷积,步长为 2,用于维度缩减
self.downsample = nn.Conv2d(c_in, c_out, kernel_size=1, stride=2) if subsample else None
def forward(self, x):
z = self.net(x)
if self.downsample is not None:
x = self.downsample(x)
out = z + x
out = self.act_fn(out)
return out
4.2.预激活 ResNet 模块
class PreActResNetBlock(nn.Module):
def __init__(self, c_in, act_fn, subsample=False, c_out=-1):
"""
构造函数参数说明同上
"""
super(PreActResNetBlock, self).__init__()
self.subsample = subsample
if self.subsample:
c_out = c_in if c_out <= 0 else c_out
# 定义带有预激活的网络 F
self.net = nn.Sequential(
nn.BatchNorm2d(c_in),
act_fn(),
nn.Conv2d(c_in, c_out, kernel_size=3, padding=1, stride=2 if subsample else 1, bias=False),
nn.BatchNorm2d(c_out),
act_fn(),
nn.Conv2d(c_out, c_out, kernel_size=3, padding=1, bias=False)
)
# 1x1 卷积,步长为 2,同时应用预激活
self.downsample = nn.Sequential(
nn.BatchNorm2d(c_in),
act_fn(),
nn.Conv2d(c_in, c_out, kernel_size=1, stride=2, bias=False)
) if subsample else None
def forward(self, x):
z = self.net(x)
if self.downsample is not None:
x = self.downsample(x)
out = z + x
return out
4.3.模块类映射字典
类似于模型选择,我们定义一个字典来创建从字符串到模块类的映射。我们将使用字符串名称作为模型中的超参数值,以便在 ResNet 模块之间进行选择。你也可以自由实现任何其他类型的 ResNet 模块,并在这里添加。
resnet_blocks_by_name = {
"ResNetBlock": ResNetBlock,
"PreActResNetBlock": PreActResNetBlock
}
4.4.残差模块堆叠
整体的ResNet架构由多个残差块堆叠而成,其中一些块会对输入进行降采样。当我们讨论整个网络中的残差块时,通常会根据它们的输出形状将它们分组。例如,当我们说ResNet包含[3,3,3]个残差块时,这意味着网络中有三组残差块,每组包含三个残差块,其中第四个和第七个块会进行子采样。在CIFAR10数据集上应用[3,3,3]块的ResNet结构如下所示。
这三组残差块分别处理不同分辨率的特征图。橙色的块表示进行了降采样的残差块。这种表示方法在许多其他实现中也很常见,例如PyTorch的torchvision库。因此,我们的代码实现如下:
class ResNet(nn.Module):
def __init__(self, num_classes=10, num_blocks=[3,3,3], c_hidden=[16,32,64], act_fn_name="relu", block_name="ResNetBlock", **kwargs):
"""
初始化参数:
num_classes - 分类任务的类别数(对于CIFAR10是10)
num_blocks - 每组中ResNet块的数量列表。每组的第一个块(除了第一组)都会进行降采样
c_hidden - 不同组中隐藏层的通道数。通常随着网络深度增加而翻倍
act_fn_name - 要使用的激活函数名称,在"act_fn_by_name"字典中查找
block_name - 要使用的ResNet块的名称,在"resnet_blocks_by_name"字典中查找
"""
super(ResNet, self).__init__()
assert block_name in resnet_blocks_by_name # 确保指定的块名称在字典中
self.hparams = SimpleNamespace(
num_classes=num_classes,
c_hidden=c_hidden,
num_blocks=num_blocks,
act_fn_name=act_fn_name,
act_fn=act_fn_by_name[act_fn_name], # 获取激活函数
block_class=resnet_blocks_by_name[block_name] # 获取ResNet块的类
)
self._create_network() # 创建网络结构
self._init_params() # 初始化参数
def _create_network(self):
c_hidden = self.hparams.c_hidden
# 对原始图像进行首次卷积,增加通道数
if self.hparams.block_class == PreActResNetBlock: # 如果是预激活块,不在输出上应用非线性激活
self.input_net = nn.Sequential(
nn.Conv2d(3, c_hidden[0], kernel_size=3, padding=1, bias=False)
)
else:
self.input_net = nn.Sequential(
nn.Conv2d(3, c_hidden[0], kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(c_hidden[0]),
self.hparams.act_fn() # 应用激活函数
)
# 创建ResNet块
blocks = []
for block_idx, block_count in enumerate(self.hparams.num_blocks):
for bc in range(block_count):
subsample = (bc == 0 and block_idx > 0) # 除第一组外,每组的第一个块进行子采样
blocks.append(
self.hparams.block_class(
c_in=c_hidden[block_idx if not subsample else (block_idx-1)],
act_fn=self.hparams.act_fn,
subsample=subsample,
c_out=c_hidden[block_idx]
)
)
self.blocks = nn.Sequential(*blocks) # 将所有块串联起来
# 映射到分类输出
self.output_net = nn.Sequential(
nn.AdaptiveAvgPool2d((1,1)), # 适应性平均池化
nn.Flatten(), # 展平特征
nn.Linear(c_hidden[-1], self.hparams.num_classes) # 全连接层到分类输出
)
def _init_params(self):
# 根据激活函数初始化卷积层的权重
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity=self.hparams.act_fn_name)
elif isinstance(m, nn.BatchNorm2d):
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
def forward(self, x):
x = self.input_net(x) # 通过输入网络
x = self.blocks(x) # 通过残差块
x = self.output_net(x) # 通过输出网络
return x
我们还需要将新的ResNet类添加到我们的模型字典中:
model_dict["ResNet"] = ResNet
最终,我们得以训练我们的 ResNet 模型。与训练 GoogleNet 相比,一个显著的区别在于,我们明确地选择了使用带有动量的 SGD(随机梯度下降)作为优化算法,而不是 Adam。在普通的浅层 ResNet 上,Adam 优化器往往只能达到略低的准确度。尽管为何 Adam 在这种情境下表现不佳尚无定论,但可能的一个解释是与 ResNet 的损失面特性有关。研究表明,ResNet 相较于没有跳跃连接的网络,能够产生更平滑的损失面(具体详情参见 Li 等人在 2018 年的研究)。损失面有无跳跃连接的可视化可能如下(图示来源 - Li 等人):
在这个可视化中,X 轴和 Y 轴表示参数空间的投影,而 Z 轴表示由不同参数值计算出的损失值。在像右侧图示那样的平滑表面上,我们可能并不需要 Adam 提供的自适应学习率。实际上,Adam 可能会陷入局部最优解,而 SGD 却能找到更广泛的最小值,这些最小值通常能够带来更好的泛化性能。然而,要详细解答这个问题,我们需要一个额外的教程,因为这不是三言两语能够解释清楚的。目前,我们得出的结论是:对于 ResNet 架构,优化器的选择是一个重要的超参数,建议尝试使用 Adam 和 SGD 进行训练。让我们使用 SGD 来训练下面的模型:
# 训练模型函数调用,具体参数根据需要配置
resnet_model, resnet_results = train_model(
model_name="ResNet",
model_hparams={
"num_classes": 10, # 分类任务的类别数
"c_hidden": [16,32,64], # 隐藏层的通道数
"num_blocks": [3,3,3], # 每组中 ResNet 块的数量
"act_fn_name": "relu" # 激活函数名称
},
optimizer_name="SGD", # 优化器名称
optimizer_hparams={ # 优化器参数
"lr": 0.1, # 学习率
"momentum": 0.9, # 动量
"weight_decay": 1e-4 # 权重衰减
}
)
接下来,我们也将训练预激活 ResNet 作为对比:
resnetpreact_model, resnetpreact_results = train_model(
model_name="ResNet",
model_hparams={
"num_classes": 10,
"c_hidden": [16,32,64],
"num_blocks": [3,3,3],
"act_fn_name": "relu",
"block_name": "PreActResNetBlock" # 使用预激活 ResNet 块
},
optimizer_name="SGD",
optimizer_hparams={
"lr": 0.1,
"momentum": 0.9,
"weight_decay": 1e-4
},
save_name="ResNetPreAct" # 保存的模型名称
)
4.5.TensorBoard 日志
与我们的 GoogleNet 模型类似,我们的 ResNet 模型也生成了 TensorBoard 日志。我们可以在下方打开它,以进行更深入的分析。
# 在 Jupyter 笔记本中打开 TensorBoard,根据需要调整日志目录路径
%tensorboard --logdir ../saved_models/tutorial5/tensorboards/ResNet/
你可以自由探索 TensorBoard,包括计算图。总体来看,我们可以看到,在训练初期,使用 SGD 的 ResNet 相比于 GoogleNet 有更高的训练损失。然而,在降低学习率之后,ResNet 模型甚至能够达到更高的验证准确率。我们会在本文的末尾比较确切的分数。
5.DenseNet 架构
DenseNet 是一种创新的深度神经网络架构,它通过一种新颖的方式利用残差连接。与常规的残差连接不同,DenseNet 将这些连接视为在不同层之间重用特征的途径,从而避免了学习重复特征图的需求。在网络的深层,模型会学习到抽象的特征以识别复杂的模式。然而,一些复杂的模式可能同时包含抽象特征(如手、脸等)和基础特征(如边缘、基本颜色等)。为了在深层网络中捕捉这些基础特征,传统的卷积神经网络(CNN)不得不学习复制这些特征图,这无疑增加了参数的复杂性。DenseNet 通过让每一层的卷积操作依赖于之前所有层的输入特征,并且只增加少量的滤波器,提供了一种高效的特征重用机制。以下是这种架构的直观展示(图示来源:Hu 等人):
在网络的最后,存在一种称为“过渡层”的结构,它的任务是降低特征图在高度、宽度和通道数上的维度。虽然从理论上讲,这些过渡层会中断恒等映射的反向传播,但由于网络中这样的层并不多,因此对梯度流动的影响有限。
5.1.DenseNet的实现
我们在实现 DenseNet 时,将网络层的构建分为三个部分:DenseLayer(密集层)、DenseBlock(密集块)和 TransitionLayer(过渡层)。DenseLayer 模块负责实现密集块内的单层操作。它首先通过 1x1 卷积进行维度缩减,然后是 3x3 卷积。输出的通道会与原始输入的特征图进行拼接并返回。值得注意的是,我们在每个块的第一层应用了批量归一化(Batch Normalization),这样做可以为不同层提供略微不同的激活,以满足不同层的需求。以下是具体的实现方式:
class DenseLayer(nn.Module):
def __init__(self, c_in, bn_size, growth_rate, act_fn):
"""
初始化参数:
c_in - 输入通道数
bn_size - 瓶颈大小(增长率的倍数)用于 1x1 卷积的输出
growth_rate - 3x3 卷积的输出通道数
act_fn - 激活函数的类构造器,例如 nn.ReLU
"""
super(DenseLayer, self).__init__()
Get an email address at self.net. It's ad-free, reliable email that's based on your own name | self.net = nn.Sequential(
nn.BatchNorm2d(c_in), # 批量归一化
act_fn(), # 激活函数
nn.Conv2d(c_in, bn_size * growth_rate, kernel_size=1, bias=False), # 1x1 卷积
nn.BatchNorm2d(bn_size * growth_rate), # 批量归一化
act_fn(), # 激活函数
nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, padding=1, bias=False) # 3x3 卷积
)
def forward(self, x):
out = Get an email address at self.net. It's ad-free, reliable email that's based on your own name | self.net(x)
out = torch.cat([out, x], dim=1) # 将新特征图与原始特征图在通道维度上进行连接
return out
DenseBlock 模块则将多个密集层按顺序应用。每个密集层的输入是原始输入与之前所有层的特征图的拼接:
class DenseBlock(nn.Module):
def __init__(self, c_in, num_layers, bn_size, growth_rate, act_fn):
"""
初始化参数:
c_in - 输入通道数
num_layers - 块中应用的密集层的数量
bn_size - 密集层中使用的瓶颈大小
growth_rate - 密集层中使用的增长率
act_fn - 密集层中使用的激活函数
"""
super(DenseBlock, self).__init__()
layers = []
for layer_idx in range(num_layers): # 对于每个密集层
layers.append(
DenseLayer(
c_in=c_in + layer_idx * growth_rate, # 输入通道是原始通道加上之前层的特征图
bn_size=bn_size,
growth_rate=growth_rate,
act_fn=act_fn
)
)
self.block = nn.Sequential(*layers) # 将所有密集层串联起来
def forward(self, x):
out = self.block(x)
return out
5.2. DenseNet 添加至模型字典
首先,我们将 DenseNet 模型加入到我们的模型字典中,以便后续使用:
model_dict["DenseNet"] = DenseNet
5.3.训练 DenseNet 网络
接下来,我们开始训练 DenseNet 网络。与 ResNet 不同的是,DenseNet 在使用 Adam 优化器时并没有出现任何问题,因此我们选择使用 Adam 来训练它。我们选择的其他超参数旨在构建一个与 ResNet 和 GoogleNet 具有相似参数规模的网络。通常,在设计非常深的网络时,DenseNet 相较于 ResNet 在参数效率上更胜一筹,同时能够实现相似甚至更优的性能。
densenet_model, densenet_results = train_model(
model_name="DenseNet", # 模型名称
model_hparams={ # 模型超参数
"num_classes": 10, # 类别数
"num_layers": [6,6,6,6], # 每块中的层数
"bn_size": 2, # 批量归一化的瓶颈大小
"growth_rate": 16, # 增长率
"act_fn_name": "relu" # 激活函数名称
},
optimizer_name="Adam", # 优化器名称
optimizer_hparams={ # 优化器超参数
"lr": 1e-3, # 学习率
"weight_decay": 1e-4 # 权重衰减
}
)
5.4.TensorBoard 日志
最后,我们还有另一个 TensorBoard 日志专门用于 DenseNet 的训练。我们可以在下方打开它,以便更深入地了解训练过程:
# 在 Jupyter 笔记本中打开 TensorBoard,需要根据实际情况调整日志目录路径
%tensorboard --logdir ../saved_models/tutorial5/tensorboards/DenseNet/
验证准确率的整体趋势和训练损失与 GoogleNet 的训练过程相似,这也与使用 Adam 进行网络训练有关。你可以自由探索训练指标,以获得更深入的理解。
6. 结论与比较
经过对每个模型的单独讨论和训练,我们现在可以对它们进行综合比较。首先,我们将所有模型的结果汇总到一个表格中以便于分析:
%%html
<!-- 以下HTML代码用于增大表格中的字体大小 -->
<style>
th {font-size: 120%;}
td {font-size: 120%;}
</style>
import tabulate
from IPython.display import display, HTML
all_models = [
("GoogleNet", googlenet_results, googlenet_model),
("ResNet", resnet_results, resnet_model),
("ResNetPreAct", resnetpreact_results, resnetpreact_model),
("DenseNet", densenet_results, densenet_model)
]
table = [[model_name,
f"{100.0*model_results['val']:4.2f}%",
f"{100.0*model_results['test']:4.2f}%",
"{:,}".format(sum([np.prod(p.shape) for p in model.parameters()]))]
for model_name, model_results, model in all_models]
display(HTML(tabulate.tabulate(table, tablefmt='html', headers=["模型", "验证准确率", "测试准确率", "参数数量"])))
模型 | 验证准确率 | 测试准确率 | 参数数量 |
---|---|---|---|
GoogleNet | 90.40% | 89.70% | 260,650 |
ResNet | 91.84% | 91.06% | 272,378 |
ResNetPreAct | 91.80% | 91.07% | 272,250 |
DenseNet | 90.72% | 90.23% | 239,146 |
表格展示的结果显示了各模型在验证集和测试集上的准确率以及它们的参数数量。从数据可以看出,所有模型都展现出了合理的性能。然而,相较于我们实现的简单模型,这些复杂模型不仅参数数量更多,而且性能也更高,这在一定程度上归功于它们的架构设计。
GoogleNet 在验证集和测试集上的表现略低于其他模型,尽管与 DenseNet 的性能相差无几。如果对 GoogleNet 的通道尺寸进行彻底的超参数优化,很可能能够提升其准确率至类似水平,但这需要考虑到大量超参数,因此成本较高。ResNet 在验证集上的性能优于 DenseNet 和 GoogleNet 超过 1%,而原始 ResNet 与预激活 ResNet 之间的性能差异不大。我们可以得出结论,对于浅层网络而言,激活函数的位置似乎并不是决定性因素,尽管有文献报道对于极深的网络情况可能正好相反。
总体而言,ResNet 证明了自己是一种简单而强大的架构。如果我们将这些模型应用于更复杂的任务,例如处理更大图像和需要网络内部更多层的任务,我们可能会观察到 GoogleNet 与带有跳跃连接的架构如 ResNet 和 DenseNet 之间的性能差异更加明显。在 CIFAR10 数据集上与更深层模型的比较分析可以在这里找到。有趣的是,DenseNet 在某些设置上优于原始的 ResNet,但与预激活 ResNet 的性能相近。最佳模型,双路径网络(Chen 等人提出),实际上是 ResNet 和 DenseNet 的结合体,这表明两种架构各自都有其独特的优势。
6.1 模型选择建议
我们已经评估了四种不同的模型。那么,面对新任务时我们应该选择哪一种模型呢?通常,鉴于 ResNet 在 CIFAR 数据集上的卓越性能和简单的实现方式,从 ResNet 开始是一个不错的选择。此外,考虑到我们选择的参数数量,ResNet 在训练速度上也是最快的,因为 DenseNet 和 GoogleNet 有更多的层需要顺序执行。然而,如果你面对的是一项极具挑战性的任务,比如在高清图像上进行语义分割,那么更推荐使用更复杂的 ResNet 或 DenseNet 变体。