文章目录
前言
欢迎来到我们的深度学习实战系列!在本文中,我们将深入探讨一个经典的图像分类问题——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 训练集图像变换
对于训练集,我们应用以下变换:
- Resize (40x40): 将图像调整到 40x40 像素。
- RandomResizedCrop (32x32, scale=(0.64, 1.0), ratio=(1.0, 1.0)): 随机裁剪一个面积为原图 64% 到 100% 的正方形区域,并将其缩放到 32x32 像素。这有助于模型学习到图像的不同部分。
- RandomHorizontalFlip: 以 50% 的概率水平翻转图像。
- ToTensor: 将 PIL 图像转换为 PyTorch 张量,并将像素值从 [0, 255] 归一化到 [0.0, 1.0]。
- 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