Datawhale X 李宏毅苹果书AI夏令营:图像分类实践与自适应学习率初探

260 阅读14分钟

前言

在这一章节中我们会介绍几个与深度学习优化有关的优化算法(主要是自适应学习率方面的),以及通过一个例子来进一步了解深度学习的实现。

1.卷积神经网络实现图像分类

在这个例子中我们会通过卷积神经网络架构来实现一个基本的图像分类任务,老规矩还是先跑一下代码,再带大家来仔细游览代码细节。

1.1 速通baseline

这里我们还是使用魔搭平台的GPU模式

image.png

这里我们先导入一下仓库,使用如下bash命令

git lfs install
git clone https://www.modelscope.cn/datasets/Datawhale/LeeDL-HW3-CNN.git

运行一下 image.png

直接运行一下HW3-ImageClassification.iqynb文件

image.png

训练一下

image.png

看看结果如何

image.png

image.png

这里需要简单介绍一下t-SNE技术,t-SNE(TSNE)将数据点之间的相似度转换为概率。原始空间中的相似度由高斯联合概率表示,嵌入空间的相似度由“学生t分布”表示。核心思想是在高维空间中保持数据点之间的局部相似性,将数据映射到低维(通常是二维或三维)空间,这样的特性使得其可以将不同类别的数据点在低维空间中形成清晰的聚类结构。

简单来说就是t-SNE技术的可视化效果会好些

1.2 关键代码段解析

这里的代码比较多,我们还是对主要的代码段进行一些解析

1.2.1 数据预处理

这个地方主要是对于我们测试集、训练集、验证集中设计涉及的图片进行读取,并且对其大小进行调整, 同时将图像转换为tensor形式,并且构建测试集、训练集合与数据集。

class FoodDataset(Dataset):
    """
    用于加载食品图像数据集的类。

    该类继承自Dataset,提供了对食品图像数据集的加载和预处理功能。
    它可以自动从指定路径加载所有的jpg图像,并对这些图像应用给定的变换。
    """

    def __init__(self, path, tfm=test_tfm, files=None):
        """
        初始化FoodDataset实例。

        参数:
        - path: 图像数据所在的目录路径。
        - tfm: 应用于图像的变换方法(默认为测试变换)。
        - files: 可选参数,用于直接指定图像文件的路径列表(默认为None)。
        """
        super(FoodDataset).__init__()
        self.path = path
        # 列出目录下所有jpg文件,并按顺序排序
        self.files = sorted([os.path.join(path, x) for x in os.listdir(path) if x.endswith(".jpg")])
        if files is not None:
            self.files = files  # 如果提供了文件列表,则使用该列表
        self.transform = tfm  # 图像变换方法

    def __len__(self):
        """
        返回数据集中图像的数量。

        返回:
        - 数据集中的图像数量。
        """
        return len(self.files)

    def __getitem__(self, idx):
        """
        获取给定索引的图像及其标签。

        参数:
        - idx: 图像在数据集中的索引。

        返回:
        - im: 应用了变换后的图像。
        - label: 图像对应的标签(如果可用)。
        """
        fname = self.files[idx]
        im = Image.open(fname)
        im = self.transform(im)  # 应用图像变换

        # 尝试从文件名中提取标签
        try:
            label = int(fname.split("/")[-1].split("_")[0])
        except:
            label = -1  # 如果无法提取标签,则设置为-1(测试数据无标签)

        return im, label
        
    # 构建训练和验证数据集
    # "loader" 参数定义了torchvision如何读取数据
    train_set = FoodDataset("./hw3_data/train", tfm=train_tfm)
    # 创建训练数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
    train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)
    # 构建验证数据集
    # "loader" 参数定义了torchvision如何读取数据
    valid_set = FoodDataset("./hw3_data/valid", tfm=test_tfm)
    # 创建验证数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
    valid_loader = DataLoader(valid_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)

1.2.2 模型部分

这里是一个比较标准的卷积神经网络的模型结构,一个卷积神经网络的基本结构一般包括致包括:卷积层、激活函数、池化层、全连接层、输出层等,卷积神经网络的大致结构一般如下图所示

image.png

这里我们先上一波代码

class Classifier(nn.Module):
    """
    定义一个图像分类器类,继承自PyTorch的nn.Module。
    该分类器包含卷积层和全连接层,用于对图像进行分类。
    """
    def __init__(self):
        """
        初始化函数,构建卷积神经网络的结构。
        包含一系列的卷积层、批归一化层、激活函数和池化层。
        """
        super(Classifier, self).__init__()
        # 定义卷积神经网络的序列结构
        self.cnn = nn.Sequential(
            nn.Conv2d(3, 64, 3, 1, 1),  # 输入通道3,输出通道64,卷积核大小3,步长1,填充1
            nn.BatchNorm2d(64),        # 批归一化,作用于64个通道
            nn.ReLU(),                 # ReLU激活函数
            nn.MaxPool2d(2, 2, 0),      # 最大池化,池化窗口大小2,步长2,填充0
            
            nn.Conv2d(64, 128, 3, 1, 1), # 输入通道64,输出通道128,卷积核大小3,步长1,填充1
            nn.BatchNorm2d(128),        # 批归一化,作用于128个通道
            nn.ReLU(),
            nn.MaxPool2d(2, 2, 0),      # 最大池化,池化窗口大小2,步长2,填充0
            
            nn.Conv2d(128, 256, 3, 1, 1), # 输入通道128,输出通道256,卷积核大小3,步长1,填充1
            nn.BatchNorm2d(256),        # 批归一化,作用于256个通道
            nn.ReLU(),
            nn.MaxPool2d(2, 2, 0),      # 最大池化,池化窗口大小2,步长2,填充0
            
            nn.Conv2d(256, 512, 3, 1, 1), # 输入通道256,输出通道512,卷积核大小3,步长1,填充1
            nn.BatchNorm2d(512),        # 批归一化,作用于512个通道
            nn.ReLU(),
            nn.MaxPool2d(2, 2, 0),       # 最大池化,池化窗口大小2,步长2,填充0
            
            nn.Conv2d(512, 512, 3, 1, 1), # 输入通道512,输出通道512,卷积核大小3,步长1,填充1
            nn.BatchNorm2d(512),        # 批归一化,作用于512个通道
            nn.ReLU(),
            nn.MaxPool2d(2, 2, 0),       # 最大池化,池化窗口大小2,步长2,填充0
        )
        # 定义全连接神经网络的序列结构
        self.fc = nn.Sequential(
            nn.Linear(512*4*4, 1024),    # 输入大小512*4*4,输出大小1024
            nn.ReLU(),
            nn.Linear(1024, 512),        # 输入大小1024,输出大小512
            nn.ReLU(),
            nn.Linear(512, 11)           # 输入大小512,输出大小11,最终输出11个类别的概率
        )

    def forward(self, x):
        """
        前向传播函数,对输入进行处理。
        
        参数:
        x -- 输入的图像数据,形状为(batch_size, 3, 128, 128)
        
        返回:
        输出的分类结果,形状为(batch_size, 11)
        """
        out = self.cnn(x)               # 通过卷积神经网络处理输入
        out = out.view(out.size()[0], -1)  # 展平输出,以适配全连接层的输入要求
        return self.fc(out)             # 通过全连接神经网络得到最终输出

代码的可视化大致如下图所示

image.png

这段代码的本质是这段代码定义了一个图像分类器类(Classifier),继承自PyTorch的nn.Module。该分类器通过一系列卷积层、批归一化层、激活函数和池化层构建卷积神经网络(CNN),用于提取图像特征。随后,这些特征被输入到全连接层进行分类,最终输出11个类别的概率,用于图像分类任务。

1.2.3 损失函数与优化器

接下来就到了最为重要(玄学)的部分,这里我们需要设置训练环境与参数,注意留意一下学习率,在之后的章节中我们会提到这个参数的重要性以及优化方案

# 根据GPU是否可用选择设备类型
device = "cuda" if torch.cuda.is_available() else "cpu"

# 初始化模型,并将其放置在指定的设备上
model = Classifier().to(device)

# 定义批量大小
batch_size = 64

# 定义训练轮数
n_epochs = 8

# 如果在'patience'轮中没有改进,则提前停止
patience = 5

# 对于分类任务,我们使用交叉熵作为性能衡量标准
criterion = nn.CrossEntropyLoss()

# 初始化优化器,您可以自行调整一些超参数,如学习率
optimizer = torch.optim.Adam(model.parameters(), lr=0.0003, weight_decay=1e-5)

1.2.4 训练阶段代码:

经典的训练环节,我们会跑几个epochs一个epoch是指所有数据经过神经网络一次完整的前向传播和反向传播过程。在训练时,通常数据会被分成多个batch进行训练,多次迭代以优化权重。随着epoch数量增加,模型从欠拟合逐渐变为过拟合。训练阶段我们会通过前向传播、计算损失、反向传播和参数更新来优化模型。验证阶段主要是对于模型在目前情况下对未知数据集的表现,如果当前的模型的评分较好,则保存当前的模型。

这里还需要补充一个概念损失函数:损失函数(loss function)就是用来度量模型的预测值f(x)与真实值Y的差异程度的运算函数,它是一个非负实值函数,通常使用L(Y, f(x))来表示,损失函数越小,模型的鲁棒性越好

# 初始化追踪器,这些不是参数,不应该被更改
stale = 0
best_acc = 0

for epoch in range(n_epochs):
    # ---------- 训练阶段 ----------
    # 确保模型处于训练模式
    model.train()

    # 这些用于记录训练过程中的信息
    train_loss = []
    train_accs = []

    for batch in tqdm(train_loader):
        # 每个批次包含图像数据及其对应的标签
        imgs, labels = batch
        # imgs = imgs.half()
        # print(imgs.shape,labels.shape)

        # 前向传播数据。(确保数据和模型位于同一设备上)
        logits = model(imgs.to(device))

        # 计算交叉熵损失。
        # 在计算交叉熵之前不需要应用softmax,因为它会自动完成。
        loss = criterion(logits, labels.to(device))

        # 清除上一步中参数中存储的梯度
        optimizer.zero_grad()

        # 计算参数的梯度
        loss.backward()

        # 为了稳定训练,限制梯度范数
        grad_norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)

        # 使用计算出的梯度更新参数
        optimizer.step()

        # 计算当前批次的准确率
        acc = (logits.argmax(dim=-1) == labels.to(device)).float().mean()

        # 记录损失和准确率
        train_loss.append(loss.item())
        train_accs.append(acc)

    train_loss = sum(train_loss) / len(train_loss)
    train_acc = sum(train_accs) / len(train_accs)

    # 打印信息
    print(f"[ 训练 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {train_loss:.5f}, acc = {train_acc:.5f}")

1.2.5 进行预测

最后的代码构建一个测试数据集和数据加载器,以便高效地读取数据。实例化并加载预训练的分类器模型,并将其设置为评估模式。在不计算梯度的情况下,遍历测试数据,使用模型进行预测,并将预测标签存储在列表中。将预测结果与测试集的ID生成一个DataFrame,并将其保存为submission.csv文件。

# 构建测试数据集
# "loader"参数指定了torchvision如何读取数据
test_set = FoodDataset("./hw3_data/test", tfm=test_tfm)
# 创建测试数据加载器,批量大小为batch_size,不打乱数据顺序,不使用多线程,启用pin_memory以提高数据加载效率
test_loader = DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=0, pin_memory=True)

# 实例化分类器模型,并将其转移到指定的设备上
model_best = Classifier().to(device)

# 加载模型的最优状态字典
model_best.load_state_dict(torch.load(f"{_exp_name}_best.ckpt"))

# 将模型设置为评估模式
model_best.eval()

# 初始化一个空列表,用于存储所有预测标签
prediction = []

# 使用torch.no_grad()上下文管理器,禁用梯度计算
with torch.no_grad():
    # 遍历测试数据加载器
    for data, _ in tqdm(test_loader):
        # 将数据转移到指定设备上,并获得模型的预测结果
        test_pred = model_best(data.to(device))
        # 选择具有最高分数的类别作为预测标签
        test_label = np.argmax(test_pred.cpu().data.numpy(), axis=1)
        # 将预测标签添加到结果列表中
        prediction += test_label.squeeze().tolist()

# 创建测试csv文件
def pad4(i):
    """
    将输入数字i转换为长度为4的字符串,如果长度不足4,则在前面补0。
    :param i: 需要转换的数字
    :return: 补0后的字符串
    """
    return "0" * (4 - len(str(i))) + str(i)

# 创建一个空的DataFrame对象
df = pd.DataFrame()
# 使用列表推导式生成Id列,列表长度等于测试集的长度
df["Id"] = [pad4(i) for i in range(len(test_set))]
# 将预测结果赋值给Category列
df["Category"] = prediction
# 将DataFrame对象保存为submission.csv文件,不保存索引
df.to_csv("submission.csv", index=False)        

2 优化理论:自适应学习率

在上面的实践中我们观察到了一个概念叫做学习率,在深度学习中,学习率,决定着目标函数能否收敛到局部最小值以及何时收敛到最小值。合适的学习率能够使目标函数在合适的时间内收敛到局部最小值。

2.1 传统学习率设置的问题与自适应学习率设置的必要性

**在传统的学习率设置中,所有的参数都会被设置同样的一个学习率 ** 这里的问题是梯度范数(梯度向量的长度)往往会出现震荡的问题,

image.png

image.png

这里的问题是,一般的梯度下降在周到鞍点或者局部点附近的时候,损失已经降了下去,多数时候训练在还没有走到临界点的时候就已经完全停止了。

我们所给出的解决方案实际上也很简单,在梯度下降中,我们为每一个参数设定一个单独的学习率 如果在某一个方向上,梯度的值很小,非常平坦,我们会希望学习率调 大一点;如果在某一个方向上非常陡峭,坡度很大,我们会希望学习率可以设得小一点。

image.png

对于学习率的调整会有若干个算法,这里我们给出几个比较经典的算法

2.1 AdaGrad

是典型的自适应学习率方法,其能够根据梯度大小自 动调整学习率。AdaGrad 可以做到梯度比较大的时候,学习率就减小,梯度比较小的时候,学习率就放大。其根据参数历史梯度调整学习率,比较适合稀疏数据,其主要算法表示如下

  1. 初始化: - 初始化参数 θ\theta 和累积梯度平方和(G = 0)。
  2. 梯度计算: - 对每个参数(\theta_i),计算梯度(g_it)git=Lθi(g \_ i^t): g_i^t= \frac{\partial L}{\partial \theta_i}
  3. 累积梯度更新: - 更新累积梯度平方和: Gi=Gi+(git)2G_i = G_i + (g_i^t)^2
  4. 更新参数θi=θiηGi+ϵgit\theta_i = \theta_i - \frac{\eta}{\sqrt{G_i + \epsilon}} \cdot g_i^t 其中,(\eta)是全局学习率,(\epsilon)是一个小常数,用于防止除零错误。

以下是可视化视角

image.png

2.2 RMSProp

在同一个参数中,学习率的需求可能会和实践挂钩,以下图为例,绿色箭头处坡度比较陡峭,需要较小的学习率,但是走到红色箭头处,坡度变得平坦了起来,需要较大的学习率。

image.png

以下是一个比较典型的RMSprop的步骤示例

image.png

数学表达式

  1. 初始化:参数θ \theta 和累积平方梯度的移动平均值。
  2. 更新步骤: 计算梯度平方的移动平均: E[g2]t=αE[g2]t1+(1α)gt2E[g^2]_t = \alpha E[g^2]_{t-1} + (1 - \alpha)g_t^2 -
  3. 参数更新: θ=θηE[g2]t+ϵgt\theta = \theta - \frac{\eta}{\sqrt{E[g^2]_t + \epsilon}} \cdot g_t - 其中,α\alpha是衰减率,η\eta是学习率,ϵ\epsilon是防止除零的小常数。

2.3 Adam

Adam属于是RMSprop的改进版本,其使用动量作为参数更新方向,同时能够自适应地调整学习率

数学表达式

image.png

3 学习率调度:

学习率调度是指我们在训练过程中动态调整学习率的方法,这个方法主要针对的是学习率突然间变大的问题 导致这个的问题是当在BC段的时候梯度非常小,自动调整的学习率会突然间变得很大。 image.png

image.png 这里有两个比较常见的方法,第一个是学习率退火,就是随着参数更新,η逐渐变小,让学习率慢慢衰减下来

image.png

image.png

优化后如下图所示

image.png

这里还有另外一个方案,学习率预热,预热的方法是让学习率先变大后变小,我们之所以需要预热主要是因为我们需要足够的统计结构来知道误差面的情报,也就是σ的值,我们需要等σ统计的次数比较精准后才能让学习率慢慢爬升起来

4 分类问题的优化

分类问题通俗来说就是根据已知样本的某些特征,判断一个新的样本属于哪种已知的样本类,上方的实践就属于是一个经典的分类问题。

image.png

分类问题主要需要关注的点在于分类损失

当我们把 x 输入到一个网络里面产生 yˆ 后,通过 softmax 得到 y ′,再去计算 y ′ 跟 y 之 间的距离 e,

image.png

这里使用了softmax后进行了优化

image.png

交叉熵损失和均方误差是分类问题中常用的损失函数,选择不同的损失函数往往会对优化产生很大的影响。

以下是交叉熵函数的损失

image.png

以下是均方误差的损失

image.png