从代码学习深度学习 - 实战 Kaggle 比赛:图像分类 (CIFAR-10 PyTorch版)

前言

欢迎来到我们的深度学习实战系列!在本文中,我们将深入探讨一个经典的图像分类问题——CIFAR-10挑战,并通过一个实际的 Kaggle 比赛流程来学习。我们将从原始图像文件开始,一步步进行数据整理、图像增广、模型构建、训练、评估,并最终生成提交结果。本教程将全程使用 PyTorch 框架,并详细解释每一段代码的功能和背后的原理。

在以往的教程中,我们可能更多地依赖深度学习框架的高级API直接获取处理好的张量格式数据集。但在真实的比赛和项目中,我们往往需要从更原始的数据形态(如.jpg, .png 文件和标签列表)入手。本篇博客旨在弥补这一差距,带你体验一个相对完整的小型 Kaggle 比赛pipeline。

我们将使用一个名为 kaggle_cifar10_tiny 的数据集,它是 CIFAR-10 的一个小型子集,方便我们快速迭代和学习。让我们开始吧!

完整代码:下载链接

1. 读取并整理数据集

在任何机器学习项目中,数据准备都是至关重要的一步。对于图像分类任务,这通常意味着读取图像文件,将其与对应的标签关联起来,并组织成适合模型训练的结构。

1.1 读取标签文件

我们的原始数据包含一个 trainLabels.csv 文件,其中记录了训练集中每张图像的文件名及其类别标签。下面的代码定义了一个函数 read_csv_labels 来读取这个 CSV 文件,并将其内容转换为一个字典,方便后续查找。

import os

# 定义数据目录路径
data_dir = 'kaggle_cifar10_tiny'

def read_csv_labels(fname):
    """
    读取CSV文件并返回文件名到标签的映射字典
    
    参数:
        fname (str): CSV文件路径
        
    返回:
        dict: 包含文件名到标签的映射字典
            - 键 (str): 图像文件名 (不含扩展名)
            - 值 (str): 对应的类别标签
    """
    with open(fname, 'r') as f:
        # 跳过CSV文件的第一行(列名)
        lines = f.readlines()[1:]
    
    # 将每行按逗号分割成tokens
    # tokens是一个列表,每个元素是[文件名, 标签]形式的列表
    # 维度: tokens - list[list[str, str]], 长度为样本数量
    tokens = [l.rstrip().split(',') for l in lines]
    
    # 构建字典,将文件名映射到对应的标签
    # 维度: 返回的字典 - dict{str: str}, 键为文件名,值为标签
    return dict(((name, label) for name, label in tokens))

# 读取训练标签文件
# 维度: labels - dict{str: str}, 键为文件名,值为标签
labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))

# 打印训练样本数量(即字典中键值对的数量)
# len(labels) - int, 表示训练样本的数量
print('# 训练样本:', len(labels))

# 打印类别数量(即标签种类的数量)
# len(set(labels.values())) - int, 表示不同类别的数量
print('# 类别:', len(set(labels.values())))

运行上述代码,我们会得到训练样本的总数和类别的总数。例如:

# 训练样本: 1000
# 类别: 10

这表明我们的微型数据集中有1000个训练样本,分布在10个类别中。

1.2 划分训练集和验证集

为了评估模型的泛化能力并进行超参数调整,我们需要从原始训练数据中划分出一部分作为验证集。下面的 reorg_train_valid 函数实现了这个功能。它会根据指定的验证集比例 valid_ratio,从每个类别中抽取一定数量的样本放入验证集,其余的放入新的训练集。同时,它会将所有原始训练图像(未划分前)也复制到一个单独的目录,以便后续在完整训练数据上训练模型。

copyfile 是一个辅助函数,用于将单个文件复制到目标目录,并在目标目录不存在时创建它。

import shutil
import collections
import math

def copyfile(filename, target_dir):
    """
    将文件复制到目标目录
    
    参数:
        filename (str): 源文件路径
        target_dir (str): 目标目录路径
    """
    # 创建目标目录(如果不存在)
    # target_dir - str, 目标目录路径
    os.makedirs(target_dir, exist_ok=True)
    
    # 复制文件到目标目录
    # filename - str, 源文件路径
    # target_dir - str, 目标目录路径
    shutil.copy(filename, target_dir)

def reorg_train_valid(data_dir, labels, valid_ratio):
    """
    将验证集从原始的训练集中拆分出来
    
    参数:
        data_dir (str): 数据集目录路径
        labels (dict): 文件名到标签的映射字典
        valid_ratio (float): 验证集比例,取值范围[0, 1]
    
    返回:
        int: 每个类别中分配给验证集的样本数量
    """
    # 获取样本数最少的类别中的样本数
    # collections.Counter(labels.values()) - Counter{str: int}, 统计每个标签出现的次数
    # most_common() - list[(str, int)], 按出现次数降序排列的(标签, 出现次数)列表
    # most_common()[-1] - tuple(str, int), 出现次数最少的(标签, 出现次数)对
    # most_common()[-1][1] - int, 出现次数最少的标签的出现次数
    # n - int, 样本数最少的类别中的样本数
    n = collections.Counter(labels.values()).most_common()[-1][1]
    
    # 计算验证集中每个类别应有的样本数
    # math.floor(n * valid_ratio) - int, 向下取整的验证集样本数
    # max(1, math.floor(n * valid_ratio)) - int, 确保每个类别至少有1个样本
    # n_valid_per_label - int, 每个类别分配给验证集的样本数
    n_valid_per_label = max(1, math.floor(n * valid_ratio))
    
    # 用于跟踪每个类别已分配到验证集的样本数
    # label_count - dict{str: int}, 键为标签,值为该标签在验证集中的样本数
    label_count = {
   }
    
    # 遍历训练目录中的所有文件
    # train_file - str, 训练集中的文件名
    for train_file in os.listdir(os.path.join(data_dir, 'train')):
        # 获取文件对应的标签
        # train_file.split('.')[0] - str, 去除文件扩展名的文件名
        # label - str, 文件对应的类别标签
        label = labels[train_file.split('.')[0]]
        
        # 构建完整的源文件路径
        # fname - str, 训练文件的完整路径
        fname = os.path.join(data_dir, 'train', train_file)
        
        # 将所有文件复制到train_valid目录下对应类别目录中
        # os.path.join(data_dir, 'train_valid_test', 'train_valid', label) - str, 目标目录路径
        copyfile(fname, os.path.join(data_dir, 'train_valid_test',
                                     'train_valid', label))
        
        # 如果该类别在验证集中的样本数未达到目标数量,则将文件添加到验证集
        if label not in label_count or label_count[label] < n_valid_per_label:
            # 复制文件到验证集目录对应类别文件夹下
            # os.path.join(data_dir, 'train_valid_test', 'valid', label) - str, 验证集目标目录路径
            copyfile(fname, os.path.join(data_dir, 'train_valid_test',
                                         'valid', label))
            # 更新该类别在验证集中的样本计数
            # label_count.get(label, 0) - int, 当前类别已有的验证集样本数,如果不存在则为0
            # label_count[label] - int, 更新后该类别在验证集中的样本数
            label_count[label] = label_count.get(label, 0) + 1
        else:
            # 如果该类别在验证集中的样本数已达到目标数量,则将文件添加到训练集
            # os.path.join(data_dir, 'train_valid_test', 'train', label) - str, 训练集目标目录路径
            copyfile(fname, os.path.join(data_dir, 'train_valid_test',
                                         'train', label))
    
    # 返回每个类别中分配给验证集的样本数量
    # n_valid_per_label - int
    return n_valid_per_label

1.3 整理测试集

测试集图像最初也存放在一个扁平的目录 test 中。为了方便后续使用 PyTorch 的 ImageFolder 类进行读取,我们需要将它们也组织到一个特定的目录结构下。reorg_test 函数将所有测试图像复制到 train_valid_test/test/unknown 目录下。这里将它们归类为 “unknown” 是因为在预测阶段,我们并不知道它们的真实标签。

import os
import shutil

def reorg_test(data_dir):
    """
    在预测期间整理测试集,以方便读取
    
    该函数将测试集图像文件复制到组织化的目录结构中,所有测试图像都归为"unknown"类别
    
    参数:
        data_dir (str): 数据集根目录路径
    """
    # 遍历测试目录中的所有文件
    # data_dir - str, 数据集根目录路径
    # os.path.join(data_dir, 'test') - str, 测试集目录的完整路径
    # os.listdir(...) - list[str], 测试集目录中的所有文件名列表
    # test_file - str, 当前处理的测试文件名
    for test_file in os.listdir(os.path.join(data_dir, 'test')):
        # 构建源文件路径
        # os.path.join(data_dir, 'test', test_file) - str, 测试文件的完整源路径
        # 构建目标目录路径,所有测试文件都放在'unknown'类别文件夹下
        # os.path.join(data_dir, 'train_valid_test', 'test', 'unknown') - str, 目标目录的完整路径
        
        # 将测试文件复制到组织化的目录结构中
        # 源文件: 原始测试集目录下的测试文件
        # 目标位置: train_valid_test/test/unknown/文件名
        copyfile(os.path.join(data_dir, 'test', test_file),
                 os.path.join(data_dir, 'train_valid_test', 'test',
                              'unknown'))

1.4 执行数据整理

现在,我们将上述函数整合到 reorg_cifar10_data 中,并执行它来完成整个数据集的重新组织。

import os
import shutil
import collections
import math

def reorg_cifar10_data(data_dir, valid_ratio):
    """
    重新组织CIFAR-10数据集,划分为训练集、验证集和测试集
    
    该函数完成CIFAR-10数据集的整体重组,包含三个主要步骤:
    1. 读取训练标签文件获取图像标签
    2. 重组训练集和验证集(从原始训练集中划分出验证集)
    3. 重组测试集(将所有测试图像归类为"unknown"类别)
    
    参数:
        data_dir (str): CIFAR-10数据集根目录路径
        valid_ratio (float): 验证集比例,取值范围[0, 1],表示从训练集中划分多少比例作为验证集
    """
    # 步骤1: 读取训练标签文件,获取文件名到标签的映射
    # data_dir - str, 数据集根目录路径
    # os.path.join(data_dir, 'trainLabels.csv') - str, 训练标签文件的完整路径
    # read_csv_labels(...) - 函数调用,读取CSV文件并返回字典
    # labels - dict{str: str}, 键为图像文件名,值为对应的类别标签
    labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
    
    # 步骤2: 重组训练集和验证集
    # 将原始训练集按照指定比例分为训练集和验证集,并保存到对应目录
    # data_dir - str, 数据集根目录路径
    # labels - dict{str: str}, 文件名到标签的映射字典
    # valid_ratio - float, 验证集比例
    # reorg_train_valid(...) - 函数调用,重组训练集和验证集
    reorg_train_valid(data_dir, labels, valid_ratio)
    
    # 步骤3: 重组测试集
    # 将测试集图像整理到组织化的目录结构中,便于后续处理
    # data_dir - str, 数据集根目录路径
    # reorg_test(...) - 函数调用,重组测试集
    reorg_test(data_dir)

我们设定批量大小 batch_size 为32,验证集比例 valid_ratio 为0.1(即10%的训练数据用作验证)。

import os
import shutil
import collections
import math


# batch_size - int, 训练和评估时的批量大小
batch_size = 32 

# 设置验证集比例
# valid_ratio - float, 验证集在原始训练集中的比例,取值范围[0, 1]
# 值为0.1表示将10%的原始训练数据划分为验证集
valid_ratio = 0.1

# 重组CIFAR-10数据集
# data_dir - str, CIFAR-10数据集的根目录路径
# valid_ratio - float, 验证集比例
# reorg_cifar10_data(...) - 函数调用,执行数据集重组操作
# 该函数会将原始CIFAR-10数据集重组为训练集、验证集和测试集三部分
# 重组后的数据结构为:
# - train_valid_test/train/: 训练集,按类别组织
# - train_valid_test/valid/: 验证集,按类别组织
# - train_valid_test/test/unknown/: 测试集,所有测试图像
# - train_valid_test/train_valid/: 原始训练集(包含验证部分),按类别组织
reorg_cifar10_data(data_dir, valid_ratio)

执行完毕后,data_dir 目录下会生成一个新的 train_valid_test 文件夹,其结构如下:

kaggle_cifar10_tiny/
├── trainLabels.csv
├── train/ (原始训练图像)
├── test/ (原始测试图像)
└── train_valid_test/
    ├── train/
    │   ├── airplane/
    │   ├── automobile/
    │   └── ... (其他8个类别)
    ├── valid/
    │   ├── airplane/
    │   ├── automobile/
    │   └── ... (其他8个类别)
    ├── train_valid/ (原始训练图像,按类别组织)
    │   ├── airplane/
    │   ├── automobile/
    │   └── ... (其他8个类别)
    └── test/
        └── unknown/ (所有测试图像)

2. 图像增广

图像增广(Data Augmentation)是一种通过对训练图像进行一系列随机变换来生成新样本的技术。它可以有效增加训练数据的多样性,减少模型过拟合,提高模型的泛化能力。

2.1 训练集图像变换

对于训练集,我们应用以下变换:

  1. Resize (40x40): 将图像调整到 40x40 像素。
  2. RandomResizedCrop (32x32, scale=(0.64, 1.0), ratio=(1.0, 1.0)): 随机裁剪一个面积为原图 64% 到 100% 的正方形区域,并将其缩放到 32x32 像素。这有助于模型学习到图像的不同部分。
  3. RandomHorizontalFlip: 以 50% 的概率水平翻转图像。
  4. ToTensor: 将 PIL 图像转换为 PyTorch 张量,并将像素值从 [0, 255] 归一化到 [0.0, 1.0]。
  5. Normalize: 使用 CIFAR-10 数据集的均值和标准差对图像进行标准化。这有助于加速模型收敛。
import torch
import torchvision
import torchvision.transforms as transforms

# 定义训练数据的图像变换流水线
# transform_train - torchvision.transforms.Compose对象, 包含多个按顺序应用的图像变换操作
transform_train = torchvision.transforms.Compose([
    # 步骤1: 调整图像大小
    # 将输入图像(任意大小)调整为40×40像素的正方形
    # 输入: 任意大小的PIL图像
    # 输出: 40×40像素的PIL图像
    torchvision.transforms.Resize(40),
    
    # 步骤2: 随机裁剪并调整大小
    # 随机裁剪一个面积为原始图像面积0.64~1倍(约为原图的80%~100%)的正方形区域,
    # 然后将该区域调整为32×32像素的图像
    # scale参数: (0.64, 1.0) - tuple(float, float), 表示裁剪区域面积占原图面积的比例范围
    # ratio参数: (1.0, 1.0) - tuple(float, float), 表示裁剪区域的宽高比范围,此处固定为1.0表示裁剪正方形
    # 输入: 40×40像素的PIL图像
    # 输出: 32×32像素的PIL图像
    torchvision.transforms.RandomResizedCrop(32, scale=(0.64, 1.0),
                                             ratio=(1.0, 1.0)),
    
    # 步骤3: 随机水平翻转
    # 以0.5的概率水平翻转图像,增加数据多样性
    # 输入: 32×32像素的PIL图像
    # 输出: 32×32像素的PIL图像,可能已水平翻转
    torchvision.transforms.RandomHorizontalFlip(),
    
    # 步骤4: 转换为张量
    # 将PIL图像转换为形状为[C, H, W]的张量,并将像素值范围从[0, 255]缩放到[0.0, 1.0]
    # 输入: 32×32像素的PIL图像
    # 输出: torch.Tensor, 形状为[3, 32, 32],值范围为[0.0, 1.0]
    torchvision.transforms.ToTensor(),
    
    # 步骤5: 标准化
    # 对每个通道应用标准化处理:(x - mean) / std
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值