前面的一些章节介绍了如何在只有6万张图像的Fashion-MNIST训练数据集上训练模型。

我们还描述了学术界当下使用最广泛的大规模图像数据集ImageNet,它有超过1000万的图像和1000类的物体。

然而,我们平常接触到的数据集的规模通常在这两者之间。

假如我们想识别图片中不同类型的椅子,然后向用户推荐购买链接。

一种可能的方法是首先识别100把普通椅子,为每把椅子拍摄1000张不同角度的图像,然后在收集的图像数据集上训练一个分类模型。 尽管这个椅子数据集可能大于Fashion-MNIST数据集,但实例数量仍然不到ImageNet中的十分之一。

适合ImageNet的复杂模型可能会在这个椅子数据集上过拟合。

​​用小数据(10万张图)去训练一个为大数据(上千万张图)设计的大模型,会导致过拟合和性能不佳。​好比让一个天才博士生(复杂模型)去死记硬背一本小学课本(小数据集)。他能轻松把整本书背得滚瓜烂熟(训练集准确率高),但并没有真正理解知识的内涵。一旦考试题目换一种问法(遇到新图片),他就可能答不上来(预测失败)。同样,一个为ImageNet设计的复杂模型,参数太多,学习能力太强,我们的10万张椅子图片根本“喂不饱”它。它很快就会记住所有训练图片的细节(甚至包括背景、拍照时光线的斑点),而无法学会“椅子”的真正抽象特征(如椅子腿、椅背、可坐等),从而导致过拟合。

此外,由于训练样本数量有限,训练模型的准确性可能无法满足实际要求。

这句话讲的是另一个层面——​​性能天花板​​。即使我们通过一些技巧(如下文提到的解决方法)缓解了过拟合,模型可能表现得“不错”,但因为数据本身不够多样和丰富,模型学到的知识终究是有限的。它的准确率可能最高只能达到90%,而实际应用可能需要95%或更高。​​数据量从根本上限制了模型性能的上限​​。

为了解决上述问题,一个显而易见的解决方案是收集更多的数据。 但是,收集和标记数据可能需要大量的时间和金钱。 例如,为了收集ImageNet数据集,研究人员花费了数百万美元的研究资金。 尽管目前的数据收集成本已大幅降低,但这一成本仍不能忽视。

另一种解决方案是应用迁移学习(transfer learning)将从源数据集学到的知识迁移到目标数据集。

例如,尽管ImageNet数据集中的大多数图像与椅子无关,但在此数据集上训练的模型可能会提取更通用的图像特征,这有助于识别边缘、纹理、形状和对象组合。 这些类似的特征也可能有效地识别椅子。

1. 步骤

本节将介绍迁移学习中的常见技巧:微调(fine-tuning)。如 图所示,微调包括以下四个步骤。

  • 在源数据集(例如ImageNet数据集)上预训练神经网络模型,即源模型。

  • 创建一个新的神经网络模型,即目标模型。这将复制源模型上的所有模型设计及其参数(输出层除外)。我们假定这些模型参数包含从源数据集中学到的知识,这些知识也将适用于目标数据集。我们还假设源模型的输出层与源数据集的标签密切相关;因此不在目标模型中使用该层。

  • 向目标模型添加输出层,其输出数是目标数据集中的类别数。然后随机初始化该层的模型参数。

  • 在目标数据集(如椅子数据集)上训练目标模型。输出层将从头开始进行训练,而所有其他层的参数将根据源模型的参数进行微调。


当目标数据集比源数据集小得多时,微调有助于提高模型的泛化能力。

2. 热狗识别

让我们通过具体案例演示微调:热狗识别。

我们将在一个小型数据集上微调ResNet模型。该模型已在ImageNet数据集上进行了预训练。 这个小型数据集包含数千张包含热狗和不包含热狗的图像,我们将使用微调模型来识别图像中是否包含热狗。

# Jupyter Notebook/JupyterLab的魔法命令,用于在Notebook中内嵌显示Matplotlib图形
# 在常规Python脚本中不需要此命令
%matplotlib inline

# 导入操作系统接口模块,用于处理文件和目录路径
import os

# 导入PyTorch深度学习框架的核心库
import torch

# 导入TorchVision计算机视觉库
import torchvision

# 从PyTorch中导入神经网络模块
from torch import nn

# 从d2l库中导入PyTorch相关的实用函数
from d2l import torch as d2l

2.1. 获取数据集

我们使用的热狗数据集来源于网络。

该数据集包含1400张热狗的“正类”图像,以及包含尽可能多的其他食物的“负类”图像。 含着两个类别的1000张图片用于训练,其余的则用于测试。

解压下载的数据集,我们获得了两个文件夹hotdog/train和hotdog/test。

data_dir/
├── train/
│   ├── hotdog/        # 包含热狗的训练图像
│   └── not_hotdog/    # 包含非热狗的训练图像
└── test/
    ├── hotdog/        # 包含热狗的测试图像
    └── not_hotdog/    # 包含非热狗的测试图像

这两个文件夹都有hotdog(有热狗)和not-hotdog(无热狗)两个子文件夹, 子文件夹内都包含相应类的图像。

# @save 是一个特殊注释,表示这个代码块应该被保存到d2l库中
# 向d2l库的数据中心注册一个名为'hotdog'的数据集
# 参数1: 数据集的下载URL (d2l.DATA_URL + 'hotdog.zip')
# 参数2: 数据集的SHA-1校验码,用于验证文件完整性
d2l.DATA_HUB['hotdog'] = (d2l.DATA_URL + 'hotdog.zip',
                         'fba480ffa8aa7e0febbb511d181409f899b9baa5')

# 下载并解压'hotdog'数据集,返回解压后的目录路径
data_dir = d2l.download_extract('hotdog')
Downloading ../data/hotdog.zip from http://d2l-data.s3-accelerate.amazonaws.com/hotdog.zip...

我们创建两个实例来分别读取训练和测试数据集中的所有图像文件。

# 创建训练集数据加载器
# os.path.join(data_dir, 'train') 会生成完整的训练数据路径
train_imgs = torchvision.datasets.ImageFolder(os.path.join(data_dir, 'train'))

# 创建测试集数据加载器
test_imgs = torchvision.datasets.ImageFolder(os.path.join(data_dir, 'test'))

下面显示了前8个正类样本图片和最后8张负类样本图片。正如所看到的,图像的大小和纵横比各有不同。

# 从训练数据集中选择前8张热狗图像
# train_imgs[i]返回一个元组 (image, label)
# train_imgs[i][0] 获取图像数据本身
hotdogs = [train_imgs[i][0] for i in range(8)]

# 从训练数据集中选择最后8张非热狗图像
# 使用负索引从数据集末尾开始选择
not_hotdogs = [train_imgs[-i - 1][0] for i in range(8)]

# 使用d2l库的show_images函数显示所有选择的图像
# 参数说明:
#   hotdogs + not_hotdogs: 将两个列表合并为一个包含16张图像的列表
#   2: 显示2行图像
#   8: 显示8列图像 (2×8=16)
#   scale=1.4: 图像显示缩放比例为1.4倍
d2l.show_images(hotdogs + not_hotdogs, 2, 8, scale=1.4)

在训练期间,我们首先从图像中裁切随机大小和随机长宽比的区域,然后将该区域缩放为224 x 224 输入图像。

在测试过程中,我们将图像的高度和宽度都缩放到256像素,然后裁剪中央224 x 224 区域作为输入。

此外,对于RGB(红、绿和蓝)颜色通道,我们分别标准化每个通道。

具体而言,该通道的每个值减去该通道的平均值,然后将结果除以该通道的标准差。

2.2. 定义和初始化模型

我们使用在ImageNet数据集上预训练的ResNet-18作为源模型。

在这里,我们指定pretrained=True以自动下载预训练的模型参数。

如果首次使用此模型,则需要连接互联网才能下载。

# 从torchvision.models中加载ResNet-18模型
# pretrained=True: 加载在ImageNet数据集上预训练好的权重
pretrained_net = torchvision.models.resnet18(pretrained=True)

预训练的源模型实例包含许多特征层和一个输出层fc。

fc是 ​​Fully Connected​​ 的缩写,中文叫​​全连接层​​。在 ResNet-18这个模型中,fc特指整个网络的​​最后一个层,也就是输出层​​。

此划分的主要目的是促进对除输出层以外所有层的模型参数进行微调。 下面给出了源模型的成员变量fc。

pretrained_net.fc
Linear(in_features=512, out_features=1000, bias=True)

在ResNet的全局平均汇聚层后,全连接层转换为ImageNet数据集的1000个类输出。

之后,我们构建一个新的神经网络作为目标模型。

它的定义方式与预训练源模型的定义方式相同,只是最终层中的输出数量被设置为目标数据集中的类数(而不是1000个)。

在下面的代码中,目标模型finetune_net中成员变量features的参数被初始化为源模型相应层的模型参数。

由于模型参数是在ImageNet数据集上预训练的,并且足够好,因此通常只需要较小的学习率即可微调这些参数。

成员变量output的参数是随机初始化的,通常需要更高的学习率才能从头开始训练。 假设Trainer实例中的学习率为η,我们将成员变量output中参数的学习率设置为10η。

# 加载在ImageNet上预训练的ResNet-18模型
# pretrained=True表示下载并加载预训练权重
finetune_net = torchvision.models.resnet18(pretrained=True)

# 修改模型的最后一层全连接层(fc)
# 原始模型有1000个输出(对应ImageNet的1000个类别)
# 我们将其改为2个输出(热狗 vs 非热狗)
finetune_net.fc = nn.Linear(finetune_net.fc.in_features, 2)

# 使用Xavier均匀初始化新添加的全连接层的权重
# 这是一种常用的权重初始化方法,有助于训练稳定性
nn.init.xavier_uniform_(finetune_net.fc.weight)
原始模型: ... → 全局平均池化 → Linear(512 → 1000)
修改后:   ... → 全局平均池化 → Linear(512 → 2)

2.3. 微调模型

首先,我们定义了一个训练函数train_fine_tuning,该函数使用微调,因此可以多次调用。

# 定义微调训练函数
# 参数说明:
#   net: 要训练的神经网络模型
#   learning_rate: 基础学习率
#   batch_size: 批量大小,默认为128
#   num_epochs: 训练轮数,默认为5
#   param_group: 是否对参数分组并使用不同的学习率,默认为True
def train_fine_tuning(net, learning_rate, batch_size=128, num_epochs=5,
                      param_group=True):
    # 创建训练数据加载器
    train_iter = torch.utils.data.DataLoader(torchvision.datasets.ImageFolder(
        os.path.join(data_dir, 'train'), transform=train_augs),  # 使用训练数据增强
        batch_size=batch_size, shuffle=True)  # 批量大小和打乱顺序
    
    # 创建测试数据加载器
    test_iter = torch.utils.data.DataLoader(torchvision.datasets.ImageFolder(
        os.path.join(data_dir, 'test'), transform=test_augs),  # 使用测试数据预处理
        batch_size=batch_size)  # 批量大小
    
    # 获取所有可用的GPU设备
    devices = d2l.try_all_gpus()
    
    # 定义损失函数:交叉熵损失
    # reduction="none"表示不自动求平均或求和,返回每个样本的损失
    loss = nn.CrossEntropyLoss(reduction="none")
    
    # 根据param_group参数决定如何设置优化器
    if param_group:
        # 获取所有不是全连接层(fc)的参数
        # 这些是预训练的特征提取层
        params_1x = [param for name, param in net.named_parameters()
             if name not in ["fc.weight", "fc.bias"]]
        
        # 创建优化器,对不同参数组使用不同的学习率
        trainer = torch.optim.SGD([
            # 预训练层使用基础学习率
            {'params': params_1x},
            # 全连接层使用10倍的学习率
            {'params': net.fc.parameters(), 'lr': learning_rate * 10}
        ], lr=learning_rate, weight_decay=0.001)  # 基础学习率和权重衰减
    
    else:
        # 如果param_group为False,所有参数使用相同的学习率
        trainer = torch.optim.SGD(net.parameters(), lr=learning_rate,
                                  weight_decay=0.001)
    
    # 调用d2l库中的训练函数进行训练
    d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
                   devices)

我们使用较小的学习率,通过微调预训练获得的模型参数。

train_fine_tuning(finetune_net, 5e-5)
loss 0.220, train acc 0.915, test acc 0.939
999.1 examples/sec on [device(type='cuda', index=0), device(type='cuda', index=1)]

为了进行比较,我们定义了一个相同的模型,但是将其所有模型参数初始化为随机值。 由于整个模型需要从头开始训练,因此我们需要使用更大的学习率。

# 创建一个全新的ResNet-18模型,不加载预训练权重
# 这意味着所有权重都是随机初始化的
scratch_net = torchvision.models.resnet18()

# 修改最后一层全连接层,适应二分类任务
scratch_net.fc = nn.Linear(scratch_net.fc.in_features, 2)

# 使用train_fine_tuning函数训练这个从零开始的模型
# 学习率设置为5e-4(比微调时大10倍)
# param_group=False表示所有层使用相同的学习率
train_fine_tuning(scratch_net, 5e-4, param_group=False)
loss 0.374, train acc 0.839, test acc 0.843
1623.8 examples/sec on [device(type='cuda', index=0), device(type='cuda', index=1)]

意料之中,微调模型往往表现更好,因为它的初始参数值更有效。

3. 小结

  • 迁移学习将从源数据集中学到的知识迁移到目标数据集,微调是迁移学习的常见技巧。

  • 除输出层外,目标模型从源模型中复制所有模型设计及其参数,并根据目标数据集对这些参数进行微调。但是,目标模型的输出层需要从头开始训练。

  • 通常,微调参数使用较小的学习率,而从头开始训练输出层可以使用更大的学习率。

4. 练习


1. 继续提高 finetune_net 的学习率,模型的准确性如何变化?

答案: 模型的准确性很可能会显著下降

原因解释:

  • 微调(Fine-tuning)的核心思想是** gentle tuning(轻柔调整)**。我们想稍微调整预训练模型的参数,让它适应新任务,而不是彻底“洗掉”它从ImageNet学到的宝贵通用特征。
  • 使用一个较小的学习率(例如 0.001, 0.0001)可以实现这一点,它只对参数进行微小的更新。
  • 如果使用一个非常大的学习率,每次参数更新的步长会非常大。这会剧烈地改变预训练模型的权重, effectively “破坏”或“覆盖”掉它原先学到的知识。这个过程被称为 “灾难性遗忘”
  • 结果就是,模型既没能学好新任务(因为训练过程变得不稳定,可能无法收敛),又忘记了旧任务的知识,导致最终性能甚至不如从头开始训练。

结论: 学习率是微调中最重要的超参数之一,并非越大越好。


2. 调整 finetune_netscratch_net 的超参数后,它们的准确性还有不同吗?

答案: 即使经过充分的超参数调整(如学习率、迭代次数、优化器类型、权重衰减等),finetune_net 的准确性几乎总是会高于(或至少不低于)scratch_net

原因解释:

  • scratch_net(从头训练的模型)的起点是随机初始化的权重。它需要从零开始学习所有特征,这需要大量的数据和较长的训练时间。
  • finetune_net 的起点是在ImageNet上预训练好的权重。这些权重已经包含了强大的、通用的图像特征(如边缘、纹理、形状)。微调只是在这个高起点上,让它稍微“转个弯”去适应新任务。
  • 超参数调整可以最大化每个模型的潜力。一个调优好的 scratch_net 性能可能会很不错,但它很难超越一个起点更高、同样经过调优的 finetune_net
  • 尤其是在目标数据集(如椅子数据集)较小的情况下,finetune_net 的优势会更加明显,因为它不容易过拟合。

结论: 迁移学习微调提供了强大的初始化优势,这种优势通常无法仅通过超参数调整来弥补。


3. 冻结 finetune_net 输出层之前的参数,模型的准确性如何变化?

答案: 模型的准确性会低于进行微调的情况,但远高于scratch_net。这种方法称为 特征提取(Feature Extraction)

原因解释:

  • param.requires_grad = False 意味着在训练过程中,这些被冻结层的参数不会更新。我们只训练最后新添加的输出层。
  • 发生了什么:我们固定了预训练模型强大的“特征提取器”,只训练一个简单的“分类器”(新的输出层)。也就是说,我们直接利用从ImageNet学到的通用特征来表征我们的椅子图片,然后只学习如何对这些特征进行分类。
  • 为什么准确性比微调低:因为我们的椅子特征和ImageNet的特征分布可能存在差异。冻结参数意味着模型无法根据新数据来优化这些特征提取器,使其更适应“椅子”这个任务,灵活性较差。
  • 为什么远高于scratch_net:因为我们使用的特征提取器是“世界级”的,而scratch_net使用的是“婴儿级”的随机特征提取器。前者提供的特征质量极高。

结论: 这是一种有效的策略,特别是在数据量非常少时,可以极大减少过拟合风险。但通常,解冻部分或全部层进行微调能获得更好的性能。


4. 如何利用ImageNet中“热狗”类别的权重参数?

答案: 这是一种非常巧妙的模型初始化技巧,可以用于二分类热狗检测任务,能显著加快模型收敛速度。

具体方法:
假设我们要构建一个二分类模型,输出为 [非热狗, 是热狗]

  1. 获取热狗权重:正如代码所示,我们从预训练模型的输出层中,提取出对应于“热狗”类别(index=934)的权重向量 hotdog_w。它的形状是 [1, 512],可以看作是一个“热狗模板特征”。
  2. 初始化新输出层
    • 我们的新模型 finetune_net 的输出层应该是 nn.Linear(in_features=512, out_features=2)
    • 我们可以用这个“热狗模板”来初始化新输出层中“是热狗”这个类别的权重。
    # 假设 new_fc 是我们新的输出层
    import torch.nn as nn
    new_fc = nn.Linear(512, 2)
    
    # 初始化新输出层的参数
    # 将“是热狗”类别(假设是index=1)的权重初始化为预训练的热狗权重
    new_fc.weight.data[1] = hotdog_w # 将 hotdog_w 赋值给第二行
    # 对于“非热狗”类别(index=0),我们可以用随机初始化,或者用其他负样本(如汉堡、三明治)的权重来初始化,但简单起见,通常保持随机即可。
    
  3. 为什么有效
    • 在第一次前向传播时,对于一张热狗图片,模型计算 特征向量热狗权重模板 的点积(相似度)。因为这个模板本来就是从热狗图片学来的,所以初始的相似度分数就会很高。
    • 这意味着模型从一开始就对“什么是热狗”有了一个非常好的猜测,极大地缩短了它从随机状态开始学习“热狗”概念所需的时间。

总结: 这相当于直接给了模型一个关于目标的“先天概念”,是一种非常聪明且高效的初始化方法,特别适用于你的目标任务恰好是预训练数据集中某个特定类别的情况。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐