Kaggle实战:狗的品种识别(ImageNet Dogs)

Frieren 发布于 2025-09-04 185 次阅读


AI 摘要

实战Kaggle图像分类:从数据增广到迁移学习,手把手教你用ResNet34识别120种狗。代码详解+两阶段训练策略,提升模型精度的关键技巧全在这里。

本篇仅用来记录作者的个人理解。实际的代码来源参照《动手深度学习-Pytorch》(第二版)。

这几天做了那个Kaggle的一个图像分类识别的比赛,各种比赛虽然是各不相同的,但是里面的工作和构建流程是一致的。所以觉得还是记录下来比较好,有助于自己的记忆和理解。

在训练的过程中,主要采用了以下的训练方法:

  1. 迁移学习(Transfer Learning)
  2. 冻结特征提取器(Frozen Feature Extractor)
  3. 两阶段训练策略(Two-Stage Training Strategy)

接下来,我将逐步介绍训练的各个步骤。

一般来说,参加Kaggle比赛训练一个模型分为四个步骤:

  • 第一部分:数据准备
  • 第二部分:模型构建
  • 第三部分:训练循环
  • 第四部分:预测与提交

注:以下所有代码建议在JupyterNoteBook上运行

开始之前,我们先导入进行训练所需要的包。

import os
import torch
import torchvision
from torch import nn
from d2l import torch as d2l

data_dir = r"../data" # 根据你的下载地址来填写

1.数据准备

需要的数据可以在这里下载,需要科学上网(内容较大,我就不上传到博客了)。里面包含120多种犬类的图片,分为测试集和训练集,labels.cvs包含训练图像的标签。

1.1 整理数据集

我们从原始数据集上拆分验证集,然后将图像移动到按标签分组的子文件夹中。这里的 reorg_dog_data 函数来读取训练数据标签、拆分验证集并整理训练集。

"""
这段代码的作用是自动化地将一个包含训练集(带有标签的图片)和测试集(不带标签的图片)的原始狗数据集,重组为一个标准的、分层的目录结构。
新的目录结构通常为 train_valid_test,其中包含 train、valid 和 test 三个子目录,以便于使用深度学习框架(如 PyTorch)的 ImageFolder 等工具来方便地加载数据。
"""
def reorg_dog_data(data_dir,valid_ratio):
    labels = d2l.read_csv_labels(os.path.join(data_dir,'labels.csv')) # 函数会返回一个字典,将文件名映射到狗的品种标签
    # 它会将原始训练集(通常在 train 目录下)按照 valid_ratio 的比例划分为新的训练集和验证集。具体操作是将原始的 train 目录中的图片移动到新的 train_valid_test 目录结构下的 train 和 valid 子目录中。
    d2l.reorg_train_valid(data_dir,labels,valid_ratio)
    # 整理测试集。它会将原始的 test 目录(通常包含用于最终提交的未标记图片)中的图片移动到新的 train_valid_test 目录结构下的 test 子目录中。
    d2l.reorg_test(data_dir)
    

batch_size = 128
valid_ratio = 0.1 # 验证集占训练集的比例
reorg_dog_data(data_dir,valid_ratio)

1.2 图像增广

图像增广是一种通过对训练图像进行一系列随机变换来扩充数据集的技术。简单来说,他就是人为地制造更多的训练数据

transform_train = torchvision.transforms.Compose([
    # 随机裁剪图像,所得图像为原始面积的0.08~1之间,高宽比在3/4和4/3之间
    # 然后,缩放图像以创建224x224的新图像
    torchvision.transforms.RandomResizedCrop(224,scale=(0.08,1.0),
                                             ratio=(3.0/4.0,4.0/3.0)),
    # 随机水平翻转图像
    torchvision.transforms.RandomHorizontalFlip(),
    # 随机更改亮度,对比度饱和度
    torchvision.transforms.ColorJitter(brightness=0.4,
                                       contrast=0.4,
                                       saturation=0.4,),
    
    # 添加随机噪声
    torchvision.transforms.ToTensor(),
    # 标准化图像的每个通道
    torchvision.transforms.Normalize([0.485,0.456,0.406],
                                     [0.229,0.224,0.225])
])

测试,我们只使用确定性的图像预处理操作。

transform_test = torchvision.transforms.Compose([
    torchvision.transforms.Resize(256),
    # 从图像中心裁剪224x224大小的图片
    torchvision.transforms.CenterCrop(224),
    torchvision.transforms.ToTensor(),
    torchvision.transforms.Normalize([0.485,0.456,0.406],
                                     [0.229,0.224,0.225])
])

1.3 读取数据集

我们读取整理后的含原始图像文件的数据集

train_ds, train_valid_ds = [torchvision.datasets.ImageFolder(
    os.path.join(data_dir,'train_valid_test', folder),
    transform=transform_train) for folder in ['train', 'train_valid']]

valid_ds, test_ds = [torchvision.datasets.ImageFolder(
    os.path.join(data_dir,'train_valid_test',folder),
    transform = transform_test) for folder in ['valid','test']]

这里借助 torchvisionImageFolder 类,从不同的文件夹中加载图像数据集。

  • train_ds:用于模型的训练。它加载的是训练集文件夹中的数据,并应用了数据增强。
  • train_valid_ds:用于最终的模型重新训练。它加载的是训练集和验证集合并之后的数据,同样应用了数据增强。
  • valid_ds:用于在训练过程中验证模型的性能。
  • test_ds:用于在训练结束之后对模型进行测试,以生成最终的提交结果。

接下来,我们创建数据加载器实例:

train_iter, train_valid_iter = [torch.utils.data.DataLoader(
    dataset,batch_size,shuffle=True,drop_last=True)
    for dataset in (train_ds,train_valid_ds)]
valid_iter = torch.utils.data.DataLoader(valid_ds,batch_size,shuffle=False,
                                         drop_last=True)
test_iter = torch.utils.data.DataLoader(test_ds,batch_size,shuffle=False,
                                         drop_last=False)

这里使用 Pytorch DataLoader 类,将之前创建的四个数据集(Dataset)转化为可迭代的数据加载器(DataLoader),以便在训练、验证和测试时高效地批量处理数据。

创建了两个加载器:train_iter train_valid_iter,其中 train_iter 用于常规训练,train_valid_iter 用于最终使用所有标记数据进行再训练。

参数解释

  • dataset: 这是你的数据集对象,比如 train_ds 或 valid_ds。DataLoader 会从这个数据集中读取数据。
  • batch_size: 每个小批量中包含的样本数量。例如,如果 batch_size 为 128,那么每次迭代都会得到 128 个样本。
  • shuffle:
    • True: 在每个训练周期开始时,随机打乱数据集的顺序。这对于训练集至关重要,因为它能防止模型学习到数据顺序带来的偏差。
    • False: 不打乱数据集。这对于验证集测试集是必要的,因为你需要确保评估结果的可重复性和一致性。
  • drop_last:
    • True: 如果数据集的总样本数不能被 batch_size 整除,那么会丢弃最后一个不完整的小批量。这样做能确保所有批次的形状都是一致的,这在某些计算场景下很有用。
    • False: 保留最后一个不完整的小批量。这通常用于验证和测试,因为你需要评估所有样本。

2.模型构建

2.1 微调训练模型

由于比赛的数据集是ImageNet的子集,所以我们可以在对应的数据集上选择预训练的模型Resnet34,我们将在该模型上进行微调。

def get_net(device):
    finetune_net = nn.Sequential()
    finetune_net.features = torchvision.models.resnet34(pretrained=True)
    # 定义一个新的输出网络,共有120个输出类别
    finetune_net.output_new = nn.Sequential(
        nn.Linear(1000,256),
        nn.ReLU(),
        nn.Linear(256,120)
    )
    # 将模型参数分配给用于计算的cpu或gou
    finetune_net = finetune_net.to(device[0])
    # 冻结参数
    for param in finetune_net.features.parameters():
        param.requires_grad = False
    return finetune_net

这里首先初始化一个新的模型容器:nn.Sequential(),随后将将一个预训练好的 ResNet-34 模型加载进来,并将其作为 finetune_net 的一个属性,命名为 features 。其中,pretrained=True 表示加载已经预训练好的模型。

随后定义一个新的、用于最终分类任务的网络层,并命名为 output_new 。因为预训练的 ResNet-34 模型的输出通常是一个1000维的向量(对应于ImageNet 的1000个类别)。

其中,nn.Linear(1000,256) 是第一个全连接层,将输入从1000维转换为256维。nn.ReLU() 是激活函数,用来增加模型的非线性能力。nn.Linear(256,120) 是第二个全连接层,将256维的向量转换为120维的输出(我们的数据集有120个类别)。

for param in finetune_net.features.parameters():
    param.requires_grad = False

这是微调过程中的关键一步。

  • finetune_net.features.parameters() 遍历了 ResNet-34 模型中的所有参数。
  • param.requires_grad = False 将这些参数的 requires_grad 属性设置为 False,这意味着在训练过程中,这些参数不会被更新(梯度不会被计算和反向传播)。
  • 这种做法的目的是保留预训练模型学习到的通用特征,只训练新添加的 output_new 层,从而有效防止过拟合,并加速训练。

最后,函数返回这个配置好的 finetune_net 模型,这个模型可以被传入 Pytorch的优化器和训练循环中,进行针对120个类别的新任务的训练。

2.2 计算损失函数

我们需要获取训练过程的损失,用来绘制训练过程的图像。

loss = nn.CrossEntropyLoss(reduction='none')

def evaluate_loss(data_iter,net,devices):
    l_sum,n = 0.0,0
    for features, labels in data_iter:
        features, labels = features.to(devices[0]),labels.to(devices[0])
        outputs = net(features)
        l = loss(outputs, labels)
        l_sum += l.sum()
        n += labels.numel()
    return (l_sum/n).to('cpu')

其中,

loss = nn.CrossEntropyLoss(reduction='none')

  • nn.CrossEntropyLoss 是一个交叉熵损失函数,常用于多类别分类任务。它能计算模型预测结果与真实标签之间的差异。
  • reduction='none' 是一个关键参数,它告诉损失函数不要对每个批次(batch)的损失求平均或求和。相反,它会返回一个张量,其中包含每个独立样本的损失值

而 evaluate_loss 函数,这是一个自定义函数,通过以下步骤计算平均损失:

  • 初始化变量
    • l_sum, n = 0.0, 0l_sum 用于累加所有样本的损失总和,n 用于累加样本总数。
  • 遍历数据集
    • for features, labels in data_iter::循环遍历 data_iter(一个数据加载器),每次获取一个批次的输入特征和标签。
  • 数据和模型计算
    • features, labels = features.to(devices[0]), labels.to(devices[0]):将数据移动到指定的计算设备(通常是 GPU)上,确保数据和模型在同一设备。
    • outputs = net(features):将特征数据传入神经网络 net 进行前向传播,得到模型的预测结果。
  • 损失计算与累加
    • l = loss(outputs, labels):使用之前定义的损失函数计算当前批次中每个样本的损失。由于 reduction='none'l 是一个包含多个值的张量。
    • l_sum += l.sum():将 l 中的所有损失值相加,并累加到 l_sum 中。
    • n += labels.numel():将当前批次的样本数量labels.numel()累加到 n
  • 返回结果
    • return (l_sum/n).to('cpu'):在循环结束后,函数将总损失 l_sum 除以总样本数 n,得到平均损失值。最后,将结果移回 CPU,以便于打印或记录。

3. 训练循环

3.1 定义训练函数

def train(net, train_iter, valid_iter,num_epochs, lr, wd, devices, lr_period,
          lr_decay):
    # 只训练小型自定义输出网络
    net = nn.DataParallel(net,device_ids=devices).to(devices[0])
    trainer = torch.optim.SGD((param for param in net.parameters()
                               if param.requires_grad),lr=lr,
                              momentum=0.9,weight_decay=wd)
    scheduler = torch.optim.lr_scheduler.StepLR(trainer,lr_period,lr_decay)
    num_batches, timer = len(train_iter), d2l.Timer()
    legend = ['train loss']
    if valid_iter is not None:
        legend.append('valid loss')
    animator = d2l.Animator(xlabel='epoch',xlim=[1,num_epochs],legend=legend)
    for epoch in range(num_epochs):
        metric = d2l.Accumulator(2)
        for i,(features, labels) in enumerate(train_iter):
            timer.start()
            features, labels = features.to(devices[0]),labels.to(devices[0])
            trainer.zero_grad()
            output = net(features)
            l = loss(output,labels).sum()
            l.backward()
            trainer.step()
            metric.add(l,labels.shape[0])
            timer.stop()
            if (i+1)%(num_batches // 5) == 0 or i == num_batches -1:
                animator.add(epoch + (i+1) / num_batches,
                             (metric[0] / metric[1],None))
        measures = f'train loss {metric[0] / metric[1]:.3f}'
        if valid_iter is not None:
            valid_loss = evaluate_loss(valid_iter,net,devices)
            animator.add(epoch + 1,(None,valid_loss.detach().cpu()))
        scheduler.step()
    if valid_iter is not None:
        measures += f',valid loss {valid_loss:.3f}'
        
    print(measures + f'\n{metric[1] * num_epochs / timer.sum():.1f}'
          f' examples/sec on {str(devices)}')

这个函数用于训练一个小型自定义输出网络,它基于预训练模型进行微调

1. 初始化和准备

  • net = nn.DataParallel(net, device_ids=devices).to(devices[0])
    • 如果有多个 GPU,nn.DataParallel 会将模型拷贝到所有指定的设备上,并并行处理数据。这有助于加速训练。to(devices[0]) 确保主模型位于第一个设备上。
  • trainer = torch.optim.SGD(...)
    • 定义了一个 随机梯度下降(SGD)优化器
    • (param for param in net.parameters() if param.requires_grad) 这是一个生成器表达式,它只选择那些 requires_gradTrue 的参数进行优化。这很重要,因为在之前的代码中,您冻结了预训练模型的参数,所以这里只会训练您自定义的小型输出网络
    • lr (学习率), momentum (动量) 和 weight_decay (权重衰减) 都是 SGD 优化器的超参数,用于控制训练过程。
  • scheduler = torch.optim.lr_scheduler.StepLR(...)
    • 定义了一个学习率调度器。它会每隔 lr_period 个周期(epoch),将学习率乘以 lr_decay,以帮助模型在训练后期更好地收敛。
  • num_batches, timer, legend, animator
    • 这些是用于训练过程监控可视化的辅助工具。animator 用于绘制训练和验证损失曲线。

2. 训练循环

  • for epoch in range(num_epochs):
    • 外部循环,遍历指定的训练周期数。
  • metric = d2l.Accumulator(2)
    • 这是一个累加器,用于在每个周期内累积训练过程中的损失总和样本总数

3. 批次训练循环

  • for i, (features, labels) in enumerate(train_iter):
    • 内部循环,遍历训练数据迭代器中的每个批次。
  • trainer.zero_grad()
    • 在每次反向传播前,清空所有参数的梯度。
  • output = net(features)
    • 前向传播。将输入数据传入模型,得到预测结果。
  • l = loss(output, labels).sum()
    • 计算损失。.sum() 是必要的,因为您之前定义的损失函数 reduction='none' 会返回一个张量,这里需要将它求和。
  • l.backward()
    • 反向传播。根据损失 l 计算每个参数的梯度。
  • trainer.step()
    • 更新参数。根据计算出的梯度和优化器规则来更新模型的参数。
  • metric.add(l, labels.shape[0])
    • 将当前批次的损失总和和样本数累加到 metric 中。
  • animator.add(...)
    • 定期更新训练损失的曲线图。

4. 周期结束时的评估和调度

  • if valid_iter is not None:
    • 如果提供了验证数据集,调用 evaluate_loss 函数(您之前提供的代码)来计算验证集上的平均损失
  • animator.add(epoch + 1, (None, valid_loss.detach().cpu()))
    • 将验证损失添加到图表中。.detach().cpu() 将张量从 GPU 移到 CPU 并移除计算图,以避免内存泄漏。
  • scheduler.step()
    • 每个周期结束后,更新学习率

5. 结果打印

  • print(...)
    • 打印最终的训练和验证损失,以及训练速度(每秒处理的样本数)。

3.2 训练和验证模型

# 以下参数都是可以调的
devices, num_epochs, lr, wd = d2l.try_all_gpus(),9,1e-4,5e-4
lr_period,lr_decay,net = 2,0.9,get_net(devices)
train(net, train_iter,valid_iter,num_epochs,lr,wd,devices,lr_period,lr_decay)

1. 设置设备和超参数

Python

devices, num_epochs, lr, wd = d2l.try_all_gpus(), 9, 1e-4, 5e-4
  • devices = d2l.try_all_gpus():这行代码会尝试找到并使用所有可用的 GPU 设备。如果系统没有 GPU,它会自动使用 CPU。
  • num_epochs = 9:设置训练的总周期数为 9。这意味着模型会完整遍历训练数据集 9 次。
  • lr = 1e-4:设置初始**学习率(Learning Rate)**为 10−4。学习率决定了模型参数在梯度下降过程中更新的步长大小。
  • wd = 5e-4:设置**权重衰减(Weight Decay)**为 5times10−4。这是一种正则化技术,用于防止模型过拟合,它会在损失函数中增加一个惩罚项,促使模型的权重保持较小的值。

2. 学习率调度和网络初始化

Python

lr_period, lr_decay, net = 2, 0.9, get_net(devices)
  • lr_period = 2:设置学习率调度器每隔 2 个周期(epoch)调整一次学习率。
  • lr_decay = 0.9:设置学习率的衰减率。每次调整时,学习率会乘以 0.9。
  • net = get_net(devices):调用您之前定义的 get_net 函数来初始化神经网络模型。这个函数会加载一个预训练的 ResNet-34 模型,并添加一个用于新任务的自定义输出层。

3. 启动训练

Python

train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period, lr_decay)

这行代码是整个训练过程的启动点。它调用了您之前定义的 train 函数,并将所有准备好的参数传递给它。

  • net:要训练的神经网络模型。
  • train_iter:训练数据集的迭代器,用于提供训练数据。
  • valid_iter:验证数据集的迭代器,用于在训练期间评估模型性能。
  • num_epochs, lr, wd, devices, lr_period, lr_decay:这些都是上面定义好的超参数,用于控制 train 函数的行为,包括训练周期、优化器设置和学习率调度。

4. 预测和提交

net = get_net(devices)
train(net, train_valid_iter,None,num_epochs,lr,wd,devices,lr_period,lr_decay)

preds = []
for data, label in test_iter:
    output = torch.nn.functional.softmax(net(data.to(devices[0])),dim=1)
    preds.extend(output.cpu().detach().numpy())
ids = sorted(os.listdir(
    os.path.join(data_dir,'train_valid_test','test','unknow')
))
with open('submission.csv','w') as f:
    f.write('id,'+ ','.join(train_valid_ds.classes) + '/n')
    for i,output in zip(ids, preds):
        f.write(i.split('.')[0]+','+','.join(
            [str(num) for num in output])+ '\n')

此作者没有提供个人介绍。
最后更新于 2025-09-04