12.11. 实战 Kaggle 比赛:图像分类 (CIFAR-10)

之前几节中,我们一直在使用深度学习框架的高级API直接获取张量格式的图像数据集。 但是在实践中,图像数据集通常以图像文件的形式出现。 本节将从原始图像文件开始,然后逐步组织、读取并将它们转换为张量格式。

我们在 12.1节中对CIFAR-10数据集做了一个实验。CIFAR-10是计算机视觉领域中的一个重要的数据集。 本节将运用我们在前几节中学到的知识来参加CIFAR-10图像分类问题的Kaggle竞赛,比赛的网址是https://www.kaggle.com/c/cifar-10。

图12.11.1显示了竞赛网站页面上的信息。 为了能提交结果,首先需要注册一个Kaggle账户。

../_images/kaggle-cifar10.png

图12.11.1 CIFAR-10 图像分类竞赛页面上的信息。竞赛用的数据集可通过点击“Data”选项卡获取。

首先,导入竞赛所需的包和模块。

import collections
import glob
import math
import os
import shutil
from pathlib import Path
import albumentations as A
import mlx.core as mx
import mlx.data as dx
import mlx.optimizers as optim
import numpy as np
import pandas as pd
from mlx import nn
from d2l import mlx as d2l

12.11.1. 获取并组织数据集

比赛数据集分为训练集和测试集,其中训练集包含50000张、测试集包含300000张图像。 在测试集中,10000张图像将被用于评估,而剩下的290000张图像将不会被进行评估,包含它们只是为了防止手动标记测试集并提交标记结果。 两个数据集中的图像都是png格式,高度和宽度均为32像素并有三个颜色通道(RGB)。 这些图片共涵盖10个类别:飞机、汽车、鸟类、猫、鹿、狗、青蛙、马、船和卡车。 图12.11.1的左上角显示了数据集中飞机、汽车和鸟类的一些图像。

12.11.1.1. 下载数据集

登录Kaggle后,我们可以点击 图12.11.1中显示的CIFAR-10图像分类竞赛网页上的“Data”选项卡,然后单击“Download All”按钮下载数据集。 在../data中解压下载的文件并在其中解压缩train.7ztest.7z后,在以下路径中可以找到整个数据集:

  • ../data/cifar-10/train/[1-50000].png

  • ../data/cifar-10/test/[1-300000].png

  • ../data/cifar-10/trainLabels.csv

  • ../data/cifar-10/sampleSubmission.csv

traintest文件夹分别包含训练和测试图像,trainLabels.csv含有训练图像的标签, sample_submission.csv是提交文件的范例。

为了便于入门,我们提供包含前1000个训练图像和5个随机测试图像的数据集的小规模样本。 要使用Kaggle竞赛的完整数据集,需要将以下demo变量设置为False

#@save
d2l.DATA_HUB['cifar10_tiny'] = (d2l.DATA_URL + 'kaggle_cifar10_tiny.zip',
                                '2068874e4b9a9f0fb07ebe0ad2b29754449ccacd')

# 如果使用完整的Kaggle竞赛的数据集,设置demo为False
demo = True

if demo:
    data_dir = d2l.download_extract('cifar10_tiny')
else:
    data_dir = '../data/cifar-10/'

12.11.1.2. 整理数据集

我们需要整理数据集来训练和测试模型。 首先,我们用以下函数读取CSV文件中的标签,它返回一个字典,该字典将文件名中不带扩展名的部分映射到其标签。

#@save
def read_csv_labels(fname):
    """读取fname来给标签字典返回一个文件名"""
    with open(fname, 'r') as f:
        # 跳过文件头行(列名)
        lines = f.readlines()[1:]
    tokens = [l.rstrip().split(',') for l in lines]
    return dict(((name, label) for name, label in tokens))

labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
print('# 训练样本 :', len(labels))
print('# 类别 :', len(set(labels.values())))
# 训练样本 : 1000
# 类别 : 10

接下来,我们定义reorg_train_valid函数来将验证集从原始的训练集中拆分出来。 此函数中的参数valid_ratio是验证集中的样本数与原始训练集中的样本数之比。 更具体地说,令\(n\)等于样本最少的类别中的图像数量,而\(r\)是比率。 验证集将为每个类别拆分出\(\max(\lfloor nr\rfloor,1)\)张图像。 让我们以valid_ratio=0.1为例,由于原始的训练集有50000张图像,因此train_valid_test/train路径中将有45000张图像用于训练,而剩下5000张图像将作为路径train_valid_test/valid中的验证集。 组织数据集后,同类别的图像将被放置在同一文件夹下。

#@save
def copyfile(filename, target_dir):
    """将文件复制到目标目录"""
    os.makedirs(target_dir, exist_ok=True)
    shutil.copy(filename, target_dir)

#@save
def reorg_train_valid(data_dir, labels, valid_ratio):
    """将验证集从原始的训练集中拆分出来"""
    # 训练数据集中样本最少的类别中的样本数
    n = collections.Counter(labels.values()).most_common()[-1][1]
    # 验证集中每个类别的样本数
    n_valid_per_label = max(1, math.floor(n * valid_ratio))
    label_count = {}
    for train_file in os.listdir(os.path.join(data_dir, 'train')):
        label = labels[train_file.split('.')[0]]
        fname = os.path.join(data_dir, 'train', train_file)
        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:
            copyfile(fname, os.path.join(data_dir, 'train_valid_test',
                                         'valid', label))
            label_count[label] = label_count.get(label, 0) + 1
        else:
            copyfile(fname, os.path.join(data_dir, 'train_valid_test',
                                         'train', label))
    return n_valid_per_label

下面的reorg_test函数用来在预测期间整理测试集,以方便读取。

#@save
def reorg_test(data_dir):
    """在预测期间整理测试集,以方便读取"""
    for test_file in os.listdir(os.path.join(data_dir, 'test')):
        copyfile(os.path.join(data_dir, 'test', test_file),
                 os.path.join(data_dir, 'train_valid_test', 'test',
                              'unknown'))

最后,我们使用一个函数来调用前面定义的函数read_csv_labelsreorg_train_validreorg_test

def reorg_cifar10_data(data_dir, valid_ratio):
    labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
    reorg_train_valid(data_dir, labels, valid_ratio)
    reorg_test(data_dir)

在这里,我们只将样本数据集的批量大小设置为32。 在实际训练和测试中,应该使用Kaggle竞赛的完整数据集,并将batch_size设置为更大的整数,例如128。 我们将10%的训练样本作为调整超参数的验证集。

batch_size = 32 if demo else 128
valid_ratio = 0.1
reorg_cifar10_data(data_dir, valid_ratio)

12.11.2. 图像增广

我们使用图像增广来解决过拟合的问题。例如在训练中,我们可以随机水平翻转图像。 我们还可以对彩色图像的三个RGB通道执行标准化。 下面,我们列出了其中一些可以调整的操作。

transform_train = A.Compose([
    # 在高度和宽度上将图像放大到40像素的正方形
    A.Resize(40, 40),
    # 随机裁剪出一个高度和宽度均为40像素的正方形图像,
    # 生成一个面积为原始图像面积0.64~1倍的小正方形,
    # 然后将其缩放为高度和宽度均为32像素的正方形
    A.RandomResizedCrop((32, 32), scale=(0.64, 1.0),
                        ratio=(1.0, 1.0)),
    # 随机水平翻转
    A.HorizontalFlip(p=0.5),
    # 标准化图像的每个通道
    A.Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.2023, 0.1994, 0.2010)),
])

在测试期间,我们只对图像执行标准化,以消除评估结果中的随机性。

transform_test = A.Compose([
    # 标准化图像的每个通道
    A.Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.2023, 0.1994, 0.2010)),
])

12.11.3. 读取数据集

接下来,我们读取由原始图像组成的数据集,每个样本都包括一张图片和一个标签。

root = os.path.join(data_dir, 'train_valid_test', 'train')
# 列出root所有 *.png文件
files = [str(f) for f in glob.glob(os.path.join(root, "**/*.png"), recursive=True)]
print(files[0:5])  # 显示前5个文件名
classes = dict(
    map(reversed, enumerate(sorted(set(f.split("/")[-2] for f in files))))
)

idx_to_class = dict( enumerate(sorted(set(f.split("/")[-2] for f in files)))
)

def files_and_classes(root):
    files = [str(f) for f in glob.glob(os.path.join(root, "**/*.png"), recursive=True)]
    files = [f for f in files if "BACKGROUND" not in f]
    classes = dict(
        map(reversed, enumerate(sorted(set(f.split("/")[-2] for f in files))))
    )

    return [
        dict(image=f.encode("ascii"), label=classes[f.split("/")[-2]]) for f in files
    ]

#@save
def create_dataset(root, transform=None):
    """从文件夹中创建数据集"""
    dset = (
        dx.buffer_from_vector(files_and_classes(root))
        .load_image("image")  # load the file pointed to by the 'image' key as an image
    )
    def process_sample(samples: dict) -> dict:
        image = samples["image"]
        label = samples["label"]

        image = transform(image=image)["image"]
        image = image.astype("float32") / 255.0

        label = label.astype("int32")
        return {"X": image, "y": label}

    dataset = dset.sample_transform(process_sample)

    return dataset

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

valid_ds, test_ds = [create_dataset(
    os.path.join(data_dir, 'train_valid_test', folder),
    transform=transform_test) for folder in ['valid', 'test']]
['../data/kaggle_cifar10_tiny/train_valid_test/train/cat/75.png', '../data/kaggle_cifar10_tiny/train_valid_test/train/cat/160.png', '../data/kaggle_cifar10_tiny/train_valid_test/train/cat/170.png', '../data/kaggle_cifar10_tiny/train_valid_test/train/cat/789.png', '../data/kaggle_cifar10_tiny/train_valid_test/train/cat/549.png']

在训练期间,我们需要指定上面定义的所有图像增广操作。 当验证集在超参数调整过程中用于模型评估时,不应引入图像增广的随机性。 在最终预测之前,我们根据训练集和验证集组合而成的训练模型进行训练,以充分利用所有标记的数据。

#@save
def create_data_iter(dataset, batch_size, shuffle=True, drop_last=True):
    """创建CIFAR10检测数据集的迭代器"""
    len_dataset = len(dataset)
    # drop_last的处理,没有严格处理 dataset的长度 ?????
    if drop_last:
        num_batches = len_dataset // batch_size
    else:
        num_batches = math.ceil(len_dataset / batch_size)

    data_iter = (
        dataset
        .shuffle_if(shuffle)
        .to_stream()
        .batch(batch_size)
    )
    return data_iter, num_batches

(train_iter, num_train_batches), (train_valid_iter, num_train_valid_batches) = [
    create_data_iter(
        dataset, batch_size, shuffle=True, drop_last=True
    )
    for dataset in (train_ds, train_valid_ds)
]
valid_iter, num_valid_batches = create_data_iter(
    valid_ds, batch_size, shuffle=False, drop_last=True
)
test_iter, num_test_batches = create_data_iter(
    test_ds, batch_size, shuffle=False, drop_last=False
)

12.11.4. 定义模型

我们定义了 8.3节中描述的Resnet-18模型。

def get_net():
    num_classes = 10
    net = d2l.resnet18(num_classes, 3)
    return net

loss_fn = nn.losses.cross_entropy

12.11.5. 定义训练函数

我们将根据模型在验证集上的表现来选择模型并调整超参数。 下面我们定义了模型训练函数train

def train(net, train_iter, num_batches, valid_iter, loss_fn, num_epochs, lr, wd, lr_period,lr_decay):
    scheduler = optim.step_decay(lr, lr_decay, lr_period)
    trainer = optim.SGD(learning_rate=scheduler, momentum=0.9, weight_decay=wd)
    timer = d2l.Timer()
    legend = ['train loss', 'train acc']
    if valid_iter is not None:
        legend.append('valid acc')
    animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
                            legend=legend)
    def train_step(net, X, y):
        y_hat = net(X)
        los = loss_fn(y_hat, y, reduction='sum')
        acc = mx.sum(mx.argmax(y_hat, axis=-1) == y)
        return los, acc
    for epoch in range(num_epochs):
        net.train()
        metric = d2l.Accumulator(3)
        for i, samples in enumerate(train_iter):
            features, labels = mx.array(samples["X"]), mx.array(samples["y"])
            timer.start()
            l, acc = d2l.train_batch_ch13(net, features, labels,
                                          train_step, trainer)
            metric.add(l, acc, 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[2], metric[1] / metric[2],
                              None))
        train_iter.reset()
        if valid_iter is not None:
            valid_acc = d2l.evaluate_accuracy_gpu(net, valid_iter)
            animator.add(epoch + 1, (None, None, valid_acc))
    measures = (f'train loss {metric[0] / metric[2]:.3f}, '
                f'train acc {metric[1] / metric[2]:.3f}')
    if valid_iter is not None:
        measures += f', valid acc {valid_acc:.3f}'
    print(measures + f'\n{metric[2] * num_epochs / timer.sum():.1f}'
          f' examples/sec')

12.11.6. 训练和验证模型

现在,我们可以训练和验证模型了,而以下所有超参数都可以调整。 例如,我们可以增加周期的数量。当lr_periodlr_decay分别设置为4和0.9时,优化算法的学习速率将在每4个周期乘以0.9。 为便于演示,我们在这里只训练20个周期。

num_epochs, lr, wd = 20, 2e-4, 5e-4
lr_period, lr_decay, net = 10, 0.95, get_net()
train(net, train_iter, num_train_batches, valid_iter, loss_fn, num_epochs, lr, wd, lr_period,
      lr_decay)
train loss 0.404, train acc 0.878, valid acc 0.438
1715.0 examples/sec
../_images/output_kaggle-cifar10_c0cabc_28_1.svg

12.11.7. 在 Kaggle 上对测试集进行分类并提交结果

在获得具有超参数的满意的模型后,我们使用所有标记的数据(包括验证集)来重新训练模型并对测试集进行分类。

net, preds = get_net(), []
train(net, train_valid_iter, num_train_valid_batches,None, loss_fn, num_epochs, lr, wd,  lr_period,
      lr_decay)

test_iter.reset()
for samples in test_iter:
    X = mx.array(samples["X"])
    y_hat = net(X)
    preds.extend(np.array(y_hat.argmax(axis=1)).astype(np.int32))
sorted_ids = list(range(1, len(test_ds) + 1))
sorted_ids.sort(key=lambda x: str(x))

df = pd.DataFrame({'id': sorted_ids, 'label': preds})
df['label'] = df['label'].apply(lambda x: idx_to_class[x])
df.to_csv('submission.csv', index=False)
train loss 0.430, train acc 0.884
1634.2 examples/sec
../_images/output_kaggle-cifar10_c0cabc_30_1.svg

向Kaggle提交结果的方法与 5.10节中的方法类似,上面的代码将生成一个 submission.csv文件,其格式符合Kaggle竞赛的要求。

12.11.8. 小结

  • 将包含原始图像文件的数据集组织为所需格式后,我们可以读取它们。

12.11.9. 练习

  1. 在这场Kaggle竞赛中使用完整的CIFAR-10数据集。将超参数设为batch_size = 128num_epochs = 100lr = 0.1lr_period = 50lr_decay = 0.1。看看在这场比赛中能达到什么准确度和排名。能进一步改进吗?

  2. 不使用图像增广时,能获得怎样的准确度?