0. Transfer learning for Computer Vision tutorials
我们将使用迁移学习来将一个卷积神经网络(ConvNet)用于图像分类。当我们需要解决一个问题时,通常不会从头开始训练一个网络,而是利用一些在大量数据集中训练好的模型进行初始化。通常我们有下面两个方式:
我们使用预先训练的网络初始化网络,而不是随机初始化,例如在imagenet 1000数据集中训练的网络,模型剩余部分的训练和通常一样
我们将冻结除最终完全连接层外的所有网络的权重。最后一个完全连接的层被一个具有随机权重的新层所取代,并且只有这个层被训练。
1. 加载数据
我们将使用torchvision和torch.utils.data软件包来加载数据。我们今天要解决的问题是训练一个模型来分类蚂蚁和蜜蜂。我们大约有120张蚂蚁和120张蜜蜂的训练图像,每个类别还有75张验证图像。如果是从头开始训练,通常这么小的数据集很难获得良好的泛化能力。但由于我们使用了迁移学习,应该能够获得相当不错的泛化效果。
下面是数据的加载过程:
1 | # License: BSD |
2. 可视化图像
1 | def imshow(inp, title=None): |
关键代码解释
这段代码定义了一个函数来显示图像张量,然后获取一批训练数据,并将它们以网格形式展示出来,同时在标题中显示每个图像的类别名称。
一、imshow 函数
这个函数的主要功能是处理并显示 PyTorch 张量 (Tensor) 格式的图像。
inp = inp.numpy().transpose((1, 2, 0)).numpy(): 这个方法将输入的 PyTorch 张量(Tensor)转换为 NumPy 数组,因为matplotlib的imshow函数需要 NumPy 数组作为输入。.transpose((1, 2, 0)): 这是一个关键步骤。PyTorch 中的图像张量通常遵循(C, H, W)的维度顺序,即(通道数, 高度, 宽度)。然而,matplotlib显示图像时要求维度顺序为(H, W, C),即(高度, 宽度, 通道数)。transpose((1, 2, 0))就是将数组的维度从(0, 1, 2)重新排列为(1, 2, 0),从而实现这个转换。
mean = ...,std = ...,inp = std * inp + mean- 这几行代码的作用是反归一化 (Denormalization)。在训练深度学习模型时,通常会对输入的图像数据进行归一化处理(减去均值
mean,再除以标准差std),这样有助于模型更快、更稳定地收敛。 - 代码中定义的
mean和std值是 ImageNet 数据集常用的标准值。 - 为了能正确地、像原始图像一样显示这些已经归一化过的图像数据,需要执行相反的操作:先乘以标准差,再加上均值。
- 这几行代码的作用是反归一化 (Denormalization)。在训练深度学习模型时,通常会对输入的图像数据进行归一化处理(减去均值
inp = np.clip(inp, 0, 1)- 这个函数将数组
inp中的所有像素值“裁剪”到 的范围内。因为经过反归一化后,某些像素值可能会由于浮点数计算的误差而略微超出 0 或 1。imshow函数期望的浮点数像素值范围是,超出这个范围的图像可能无法正常显示。
- 这个函数将数组
plt.imshow(inp)和plt.title(title)plt.imshow(inp): 使用matplotlib库来显示处理好的图像。if title is not None: plt.title(title): 如果调用函数时提供了title参数,就将它设置为图像的标题。plt.pause(0.001): 暂停一小段时间,确保图像能够被更新和显示出来,尤其是在脚本连续运行时。
二、主代码块
这部分代码使用上面定义的 imshow 函数来实际显示一批数据。
1 | # 1. 获取一批训练数据 |
inputs, classes = next(iter(dataloaders['train']))dataloaders['train']是一个 PyTorch 的DataLoader对象,它负责批量加载训练数据。iter()会为DataLoader创建一个迭代器。next()从迭代器中获取下一批数据。 这批数据通常是一个包含两部分的元组:inputs(图像数据张量) 和classes(对应的标签)。
out = torchvision.utils.make_grid(inputs)torchvision.utils.make_grid是一个非常方便的工具函数。它接收一个 4D 的小批量张量(形状通常为[B, C, H, W],其中 B 是批量大小),然后将这批图像排列成一个网格,最终输出为一个单独的图像张量。 这对于一次性可视化多张图片非常有用。
imshow(out, title=[class_names[x] for x in classes])- 这里调用了前面定义的
imshow函数来显示网格图像out。 title=[class_names[x] for x in classes]: 这是一个列表推导式,用于生成图像的标题。它会遍历批次中所有的标签classes,并从一个名为class_names的列表中(这个列表没有在代码片段中给出,但它应该包含了从类别索引到类别名称的映射,例如['猫', '狗', ...])查找对应的名称。最终,它会生成一个包含这批图像所有类别名称的列表,并将其作为标题显示在图像上方。
- 这里调用了前面定义的
3. 训练模型函数
1 | def train_model(model, criterion, optimizer, scheduler, num_epochs=25): |
关键代码解释
函数定义和参数
1 | def train_model(model, criterion, optimizer, scheduler, num_epochs=25): |
这个函数接收以下参数:
model: 你想要训练的 PyTorch 模型。criterion: 损失函数,用于衡量模型预测与真实标签之间的差距(例如nn.CrossEntropyLoss)。optimizer: 优化器,用于根据损失来更新模型的权重(例如optim.SGD或optim.Adam)。scheduler: 学习率调度器 (Learning Rate Scheduler),用于在训练过程中动态调整优化器的学习率(例如lr_scheduler.StepLR)。num_epochs: 训练的总轮数,默认为 25。一个 epoch 指的是整个训练数据集被模型完整地过了一遍。
初始化和设置
1 | since = time.time() |
since = time.time(): 记录开始时间,用于最后计算总训练时长。with TemporaryDirectory() as tempdir:: 创建一个临时目录。这个目录会在代码块执行完毕后自动被删除,非常适合用来存放训练过程中的临时文件,比如模型的权重。best_model_params_path = ...: 在临时目录中定义一个文件路径,用于保存效果最好的模型参数。torch.save(model.state_dict(), ...):model.state_dict()返回一个包含了模型所有可学习参数(权重和偏置)的字典。这行代码在训练开始前,先将模型的初始状态保存下来。best_acc = 0.0: 初始化一个变量,用于追踪在验证集上达到的最高准确率。
主训练循环
1 | for epoch in range(num_epochs): |
for epoch in range(num_epochs):: 外层循环,遍历指定的训练轮数。for phase in ['train', 'val']:: 内层循环。在每一个 epoch 中,代码都会先后执行一个训练阶段 ('train') 和一个验证阶段 ('val')。
训练与验证模式的切换
1 | if phase == 'train': |
这是非常关键的一步:
model.train(): 将模型设置为训练模式。在这个模式下,模型会启用诸如 Dropout 和 BatchNorm 等在训练时需要但评估时不需要的层。model.eval(): 将模型设置为评估模式。在这个模式下,Dropout 和 BatchNorm 等层会以固定的方式工作,确保每次评估的结果都是确定性的。
遍历数据
1 | for inputs, labels in dataloaders[phase]: |
- 这个循环从对应阶段的
DataLoader(dataloaders['train']或dataloaders['val']) 中批量获取数据。 inputs.to(device): 将输入的图像数据移动到指定的计算设备上(device通常是cpu或cuda(GPU))。labels.to(device): 将标签也移动到相同的设备上。
前向传播和损失计算
1 | optimizer.zero_grad() |
optimizer.zero_grad(): 在计算梯度之前,必须将优化器中所有参数的梯度清零。因为 PyTorch 的梯度是累加的,不清零的话,新计算的梯度会叠加上旧的梯度。with torch.set_grad_enabled(phase == 'train'):: 这是一个上下文管理器,它根据phase的值来决定是否需要计算梯度。- 当
phase == 'train'时,torch.set_grad_enabled(True),内部的代码会跟踪计算图,以便后续进行反向传播。 - 当
phase == 'val'时,torch.set_grad_enabled(False),会禁用梯度计算,这样可以节省内存并加速计算,因为验证阶段不需要更新权重。
- 当
outputs = model(inputs): 将输入数据送入模型,得到模型的预测输出(通常是每个类别的分数)。_, preds = torch.max(outputs, 1):torch.max会在outputs张量的第 1 维(即类别分数那一维)上寻找最大值的索引。这个索引就是模型预测的类别。_用来接收最大值本身,我们这里只关心它的索引preds。loss = criterion(outputs, labels): 使用损失函数计算模型输出outputs和真实标签labels之间的损失。
反向传播和优化 (仅在训练阶段)
1 | if phase == 'train': |
这两行代码是模型学习的核心,并且只在 phase == 'train' 时执行:
loss.backward(): 计算损失相对于模型所有参数的梯度(反向传播)。optimizer.step(): 优化器根据loss.backward()计算出的梯度来更新模型的权重。
统计损失和准确率
1 | running_loss += loss.item() * inputs.size(0) |
running_loss += loss.item() * inputs.size(0):loss.item()获取当前批次的平均损失值。乘以inputs.size(0)(即批次大小)是为了累加整个批次的总损失,而不是平均损失。running_corrects += torch.sum(preds == labels.data): 比较预测标签preds和真实标签labels,torch.sum会计算出这个批次中预测正确的样本数量,并累加起来。scheduler.step(): 在一个训练 epoch 完成后,调用学习率调度器来更新学习率。epoch_loss = ...,epoch_acc = ...: 在一个 epoch 的所有批次都处理完后,用累加的总损失和总正确数除以该阶段的数据集总大小 (dataset_sizes[phase]),从而计算出这个 epoch 的平均损失和平均准确率。
保存最佳模型
1 | if phase == 'val' and epoch_acc > best_acc: |
- 在每个验证 (
val) 阶段结束后,检查当前的验证准确率epoch_acc是否超过了之前记录的最高准确率best_acc。 - 如果是,就更新
best_acc,并调用torch.save将当前模型的参数 (model.state_dict()) 保存到best_model_params_path文件中。
训练结束
1 | time_elapsed = time.time() - since |
time_elapsed = ...: 计算总训练时间。model.load_state_dict(...): 在所有 epoch 结束后,从文件中加载之前保存的最佳模型权重到model对象中。这确保了函数返回的是在验证集上表现最好的那个模型,而不是训练到最后一轮的模型。return model: 返回训练好的、加载了最佳权重的模型。
4. 展示模型预测结果
1 | def visualize_model(model, num_images=6): |
关键代码解释
保存和设置模型状态
1 | was_training = model.training |
was_training = model.training: 这行代码首先检查并记录模型当前是否处于训练模式(model.training会返回True或False)。model.eval(): 这是非常关键的一步。它将模型切换到评估模式。这会影响像 Dropout 和 BatchNorm 这样的层,确保在预测时它们的行为是确定的,而不是像训练时那样随机或基于批次统计。
禁用梯度计算
1 | with torch.no_grad(): |
- 这是一个上下文管理器,它会告诉 PyTorch 在这个代码块内部不要计算梯度。因为在做预测(推理)时,我们不需要进行反向传播和权重更新,所以禁用梯度可以显著减少内存消耗并加快计算速度。
进行预测
1 | outputs = model(inputs) |
outputs = model(inputs): 将一批输入的图像数据inputs送入模型,得到模型的原始输出(通常是每个类别的分数)。_, preds = torch.max(outputs, 1): 从模型的输出outputs中,沿着类别维度(维度1)找到分数最高的那个类别的索引。这个索引preds就是模型对每张图片的预测结果。
torch.max(outputs,dim=1)和torch.topk(outputs,1,dim=1)
简单来说:
torch.max(): 用来找到 唯一的、最好的 一个结果。torch.topk(): 用来找到 前 K 个最好的 结果。
下面是详细的对比解释。
torch.max(outputs, dim)
功能
这个函数沿着指定的维度 dim 返回一个元组 (values, indices),其中 values 是每个切片中的最大值,indices 是该最大值对应的索引。它只返回一个最大值。
主要用途
- 获取最终预测结果:在分类任务中,模型输出的分数最高的类别就是预测结果。
torch.max正是用于此目的。 - 计算标准的 Top-1 准确率:当预测的索引(来自
torch.max)与真实标签匹配时,即为一次正确预测。
返回值
它返回一个包含两个张量的元组:
values: 一个张量,包含了每个输入切片中的最大值。indices: 一个张量,包含了每个最大值在原张量中的索引。
torch.topk(outputs, k, dim)
功能
这个函数更加通用。它沿着指定的维度 dim 返回前 k 个最大的元素和它们的索引。
主要用途
- 计算 Top-k 准确率:在一些复杂的分类任务(如 ImageNet)中,只要真实标签出现在模型预测的前 k 个结果中,就认为预测是正确的。例如,计算 Top-5 准确率。
- 提供备选预测:在一个应用中,除了显示最可能的预测外,还可以向用户展示第二、第三可能的选项。
- 模型分析:可以用来分析模型的“置信度”。如果正确答案经常出现在 Top-2 或 Top-3 中,但不是 Top-1,这可能说明模型学到了一些相关特征,但还不够精确。
返回值
它也返回一个包含两个张量的元组 (values, indices):
values: 一个张量,形状为(..., k),包含了每个输入切片中最大的k个值。indices: 一个张量,形状为(..., k),包含了这k个最大值在原张量中的索引。
从功能上来说:torch.max(outputs,1)和torch.topk(outputs,1,1)是等价的,这也是为什么在有的教程中使用的是后者,功能上没有任何区别,但在效率上可能会有略微差异。
显示图像和预测标题
1 | ax.set_title(f'predicted: {class_names[preds[j]]}') |
ax.set_title(...): 为子图设置标题。它使用 f-string 将预测的类别索引preds[j]在class_names列表中转换为可读的类别名称(例如,从2转换为'dog'),然后显示为 “predicted: dog”。imshow(inputs.cpu().data[j]): 调用之前定义的imshow函数来显示图像。.cpu()是必需的,因为图像数据可能在 GPU 上,而 Matplotlib 和 NumPy 通常在 CPU 上工作。.data[j]则是从批次中取出单个图像数据。
恢复模型原始状态
1 | model.train(mode=was_training) |
- 在函数结束(无论是正常结束还是提前返回)时,这行代码会将模型恢复到调用此函数之前的状态。如果模型之前是训练模式,它就会被设置回训练模式。这是一个很好的编程实践,可以防止这个可视化函数意外地改变了模型的训练状态。
5. 微调ConvNet
5.1 加载模型
1 | # 加载预训练的ResNet18模型(ImageNet权重) |
关键代码解释
这段代码是进行迁移学习 (Transfer Learning) 的一个非常核心和典型的设置。它加载一个在大型数据集上预训练好的模型,然后修改它的最后一层以适应我们自己的、通常是更小的数据集。
下面是关键代码的解释。
加载预训练模型
1 | model_ft = models.resnet18(weights='IMAGENET1K_V1') |
- 这行代码从
torchvision.models中加载了一个 ResNet-18 模型。 weights='IMAGENET1K_V1'是一个关键参数。它告诉 PyTorch 不仅要加载 ResNet-18 的模型结构,还要加载它在ImageNet(一个包含超过一百万张图像和 1000 个类别的大型数据集)上已经训练好的权重。- 这个预训练模型已经学会了如何识别图像中的通用特征(如边缘、纹理、形状等),我们可以利用这些学到的知识来帮助我们完成新的任务,而无需从零开始训练。
修改模型的最后一层(分类头)
1 | num_ftrs = model_ft.fc.in_features |
model_ft.fc: 在 ResNet 模型中,fc是指模型的最后一个全连接层 (Fully Connected layer)。这个层负责将模型前面卷积层提取到的高级特征映射到最终的类别分数上。num_ftrs = model_ft.fc.in_features:in_features属性可以获取到这个全连接层所接收的输入特征数量。我们不需要手动去查 ResNet-18 的结构,直接通过这个属性就能拿到这个数值(对于 ResNet-18 来说是 512)。model_ft.fc = nn.Linear(num_ftrs, 2): 这是迁移学习中最关键的一步。我们创建了一个新的全连接层 (nn.Linear),它的输入维度num_ftrs和原始层一样,但是输出维度被我们改成了2。这表示我们的新任务是一个二分类问题(比如“猫 vs 狗”)。通过这行代码,我们 фактически用这个新的、未训练的层替换掉了原始的、为 1000 个 ImageNet 类别设计的全连接层。
设置损失函数和优化器
1 | criterion = nn.CrossEntropyLoss() |
criterion = nn.CrossEntropyLoss(): 定义了损失函数。交叉熵损失是分类任务中最常用的损失函数,它尤其适合与输出原始分数(logits)的模型配合使用。optimizer_ft = optim.SGD(...): 定义了优化器。这里使用的是随机梯度下降 (SGD)。model_ft.parameters(): 这是一个非常重要的参数。它告诉优化器,模型中所有的参数(包括预训练的卷积层权重和我们新加的全连接层权重)都应该被优化和更新。这意味着在训练过程中,整个模型都会进行微调 (Fine-tuning)。lr=0.001: 设置了学习率,即每次更新权重时的步长。momentum=0.9: 使用了动量,这是一种帮助 SGD 加速收敛并越过局部最优点的技术。
设置学习率调度器
1 | exp_lr_scheduler = lr_scheduler.StepLR(optimizer_ft, step_size=7, gamma=0.1) |
lr_scheduler.StepLR: 创建了一个学习率调度器,它可以在训练过程中动态地调整学习率。step_size=7: 这个参数表示每当训练进行了 7 个 epoch,调度器就会触发一次学习率的调整。gamma=0.1: 这个参数表示在每次调整时,将当前的学习率乘以0.1。- 效果: 这段代码设置了一个策略:训练开始时学习率为
0.001,7 个 epoch 后,学习率会变为0.001 * 0.1 = 0.0001,再过 7 个 epoch(即第 14 个 epoch 后),学习率会变为0.0001 * 0.1 = 0.00001,以此类推。这种在训练后期降低学习率的做法有助于模型在最优解附近进行更精细的搜索,从而收敛得更好。
5.2 训练和评估
1 | model_ft = train_model(model_ft, criterion, optimizer_ft, exp_lr_scheduler, |
1 | visualize_model(model_ft) |
6. ConvNet作为固定特征提取器
6.1 加载模型
这部分与微调ConvNet部分代码基本一直,也是加载一个与训练模型
1 | # 加载预训练ResNet18模型(固定所有底层参数) |
6.2 训练和评估
1 | model_conv = train_model(model_conv, criterion, optimizer_conv, |
1 | visualize_model(model_conv) |
7. 图像验证
对于训练好的模型,我们通过输入一张图片来预测他的类别,查看模型的分类能力。这部分的代码和模型训练函数中对 val进行的操作相同。
1 | def visualize_model_predictions(model, img_path): |