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

:label:sec_kaggle_cifar10

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

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

:numref:fig_kaggle_cifar10显示了竞赛网站页面上的信息。 为了能提交结果,你需要首先注册Kaggle账户。

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

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

```{.python .input} import collections from d2l import mxnet as d2l import math from mxnet import gluon, init, npx from mxnet.gluon import nn import os import pandas as pd import shutil

npx.set_np()

  1. ```{.python .input}
  2. #@tab pytorch
  3. import collections
  4. from d2l import torch as d2l
  5. import math
  6. import torch
  7. import torchvision
  8. from torch import nn
  9. import os
  10. import pandas as pd
  11. import shutil

获取并组织数据集

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

下载数据集

登录Kaggle后,我们可以点击 :numref:fig_kaggle_cifar10中显示的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

```{.python .input}

@tab all

@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/‘

  1. ### [**整理数据集**]
  2. 我们需要整理数据集来训练和测试模型。
  3. 首先,我们用以下函数读取CSV文件中的标签,它返回一个字典,该字典将文件名中不带扩展名的部分映射到其标签。
  4. ```{.python .input}
  5. #@tab all
  6. #@save
  7. def read_csv_labels(fname):
  8. """读取fname来给标签字典返回一个文件名"""
  9. with open(fname, 'r') as f:
  10. # 跳过文件头行(列名)
  11. lines = f.readlines()[1:]
  12. tokens = [l.rstrip().split(',') for l in lines]
  13. return dict(((name, label) for name, label in tokens))
  14. labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
  15. print('# 训练样本 :', len(labels))
  16. print('# 类别 :', len(set(labels.values())))

接下来,我们定义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中的验证集。 组织数据集后,同类别的图像将被放置在同一文件夹下。

```{.python .input}

@tab all

@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): “””将验证集从原始的训练集中拆分出来”””

  1. # 训练数据集中样本最少的类别中的样本数
  2. n = collections.Counter(labels.values()).most_common()[-1][1]
  3. # 验证集中每个类别的样本数
  4. n_valid_per_label = max(1, math.floor(n * valid_ratio))
  5. label_count = {}
  6. for train_file in os.listdir(os.path.join(data_dir, 'train')):
  7. label = labels[train_file.split('.')[0]]
  8. fname = os.path.join(data_dir, 'train', train_file)
  9. copyfile(fname, os.path.join(data_dir, 'train_valid_test',
  10. 'train_valid', label))
  11. if label not in label_count or label_count[label] < n_valid_per_label:
  12. copyfile(fname, os.path.join(data_dir, 'train_valid_test',
  13. 'valid', label))
  14. label_count[label] = label_count.get(label, 0) + 1
  15. else:
  16. copyfile(fname, os.path.join(data_dir, 'train_valid_test',
  17. 'train', label))
  18. return n_valid_per_label
  1. 下面的`reorg_test`函数用来[**在预测期间整理测试集,以方便读取**]。
  2. ```{.python .input}
  3. #@tab all
  4. #@save
  5. def reorg_test(data_dir):
  6. """在预测期间整理测试集,以方便读取"""
  7. for test_file in os.listdir(os.path.join(data_dir, 'test')):
  8. copyfile(os.path.join(data_dir, 'test', test_file),
  9. os.path.join(data_dir, 'train_valid_test', 'test',
  10. 'unknown'))

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

```{.python .input}

@tab all

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)

  1. 在这里,我们只将样本数据集的批量大小设置为32
  2. 在实际训练和测试中,应该使用Kaggle竞赛的完整数据集,并将`batch_size`设置为更大的整数,例如128
  3. 我们将10%的训练样本作为调整超参数的验证集。
  4. ```{.python .input}
  5. #@tab all
  6. batch_size = 32 if demo else 128
  7. valid_ratio = 0.1
  8. reorg_cifar10_data(data_dir, valid_ratio)

[图像增广]

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

```{.python .input} transform_train = gluon.data.vision.transforms.Compose([

  1. # 在高度和宽度上将图像放大到40像素的正方形
  2. gluon.data.vision.transforms.Resize(40),
  3. # 随机裁剪出一个高度和宽度均为40像素的正方形图像,
  4. # 生成一个面积为原始图像面积0.64到1倍的小正方形,
  5. # 然后将其缩放为高度和宽度均为32像素的正方形
  6. gluon.data.vision.transforms.RandomResizedCrop(32, scale=(0.64, 1.0),
  7. ratio=(1.0, 1.0)),
  8. gluon.data.vision.transforms.RandomFlipLeftRight(),
  9. gluon.data.vision.transforms.ToTensor(),
  10. # 标准化图像的每个通道
  11. gluon.data.vision.transforms.Normalize([0.4914, 0.4822, 0.4465],
  12. [0.2023, 0.1994, 0.2010])])
  1. ```{.python .input}
  2. #@tab pytorch
  3. transform_train = torchvision.transforms.Compose([
  4. # 在高度和宽度上将图像放大到40像素的正方形
  5. torchvision.transforms.Resize(40),
  6. # 随机裁剪出一个高度和宽度均为40像素的正方形图像,
  7. # 生成一个面积为原始图像面积0.64到1倍的小正方形,
  8. # 然后将其缩放为高度和宽度均为32像素的正方形
  9. torchvision.transforms.RandomResizedCrop(32, scale=(0.64, 1.0),
  10. ratio=(1.0, 1.0)),
  11. torchvision.transforms.RandomHorizontalFlip(),
  12. torchvision.transforms.ToTensor(),
  13. # 标准化图像的每个通道
  14. torchvision.transforms.Normalize([0.4914, 0.4822, 0.4465],
  15. [0.2023, 0.1994, 0.2010])])

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

```{.python .input} transform_test = gluon.data.vision.transforms.Compose([ gluon.data.vision.transforms.ToTensor(), gluon.data.vision.transforms.Normalize([0.4914, 0.4822, 0.4465], [0.2023, 0.1994, 0.2010])])

  1. ```{.python .input}
  2. #@tab pytorch
  3. transform_test = torchvision.transforms.Compose([
  4. torchvision.transforms.ToTensor(),
  5. torchvision.transforms.Normalize([0.4914, 0.4822, 0.4465],
  6. [0.2023, 0.1994, 0.2010])])

读取数据集

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

```{.python .input} train_ds, valid_ds, train_valid_ds, test_ds = [ gluon.data.vision.ImageFolderDataset( os.path.join(data_dir, ‘train_valid_test’, folder)) for folder in [‘train’, ‘valid’, ‘train_valid’, ‘test’]]

  1. ```{.python .input}
  2. #@tab pytorch
  3. train_ds, train_valid_ds = [torchvision.datasets.ImageFolder(
  4. os.path.join(data_dir, 'train_valid_test', folder),
  5. transform=transform_train) for folder in ['train', 'train_valid']]
  6. valid_ds, test_ds = [torchvision.datasets.ImageFolder(
  7. os.path.join(data_dir, 'train_valid_test', folder),
  8. transform=transform_test) for folder in ['valid', 'test']]

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

```{.python .input} train_iter, train_valid_iter = [gluon.data.DataLoader( dataset.transform_first(transform_train), batch_size, shuffle=True, last_batch=’discard’) for dataset in (train_ds, train_valid_ds)]

valid_iter = gluon.data.DataLoader( valid_ds.transform_first(transform_test), batch_size, shuffle=False, last_batch=’discard’)

test_iter = gluon.data.DataLoader( test_ds.transform_first(transform_test), batch_size, shuffle=False, last_batch=’keep’)

  1. ```{.python .input}
  2. #@tab pytorch
  3. train_iter, train_valid_iter = [torch.utils.data.DataLoader(
  4. dataset, batch_size, shuffle=True, drop_last=True)
  5. for dataset in (train_ds, train_valid_ds)]
  6. valid_iter = torch.utils.data.DataLoader(valid_ds, batch_size, shuffle=False,
  7. drop_last=True)
  8. test_iter = torch.utils.data.DataLoader(test_ds, batch_size, shuffle=False,
  9. drop_last=False)

定义[模型]

:begin_tab:mxnet 在这里,我们基于HybridBlock类构建剩余块,这与 :numref:sec_resnet中描述的实现方法略有不同,是为了提高计算效率。 :end_tab:

```{.python .input} class Residual(nn.HybridBlock): def init(self, numchannels, use1x1conv=False, strides=1, **kwargs): super(Residual, self).__init(**kwargs) self.conv1 = nn.Conv2D(num_channels, kernel_size=3, padding=1, strides=strides) self.conv2 = nn.Conv2D(num_channels, kernel_size=3, padding=1) if use_1x1conv: self.conv3 = nn.Conv2D(num_channels, kernel_size=1, strides=strides) else: self.conv3 = None self.bn1 = nn.BatchNorm() self.bn2 = nn.BatchNorm()

  1. def hybrid_forward(self, F, X):
  2. Y = F.npx.relu(self.bn1(self.conv1(X)))
  3. Y = self.bn2(self.conv2(Y))
  4. if self.conv3:
  5. X = self.conv3(X)
  6. return F.npx.relu(Y + X)
  1. :begin_tab:`mxnet`
  2. 接下来,我们定义Resnet-18模型。
  3. :end_tab:
  4. ```{.python .input}
  5. def resnet18(num_classes):
  6. net = nn.HybridSequential()
  7. net.add(nn.Conv2D(64, kernel_size=3, strides=1, padding=1),
  8. nn.BatchNorm(), nn.Activation('relu'))
  9. def resnet_block(num_channels, num_residuals, first_block=False):
  10. blk = nn.HybridSequential()
  11. for i in range(num_residuals):
  12. if i == 0 and not first_block:
  13. blk.add(Residual(num_channels, use_1x1conv=True, strides=2))
  14. else:
  15. blk.add(Residual(num_channels))
  16. return blk
  17. net.add(resnet_block(64, 2, first_block=True),
  18. resnet_block(128, 2),
  19. resnet_block(256, 2),
  20. resnet_block(512, 2))
  21. net.add(nn.GlobalAvgPool2D(), nn.Dense(num_classes))
  22. return net

:begin_tab:mxnet 在训练开始之前,我们使用 :numref:subsec_xavier中描述的Xavier初始化。 :end_tab:

:begin_tab:pytorch 我们定义了 :numref:sec_resnet中描述的Resnet-18模型。 :end_tab:

```{.python .input} def get_net(devices): num_classes = 10 net = resnet18(num_classes) net.initialize(ctx=devices, init=init.Xavier()) return net

loss = gluon.loss.SoftmaxCrossEntropyLoss()

  1. ```{.python .input}
  2. #@tab pytorch
  3. def get_net():
  4. num_classes = 10
  5. net = d2l.resnet18(num_classes, 3)
  6. return net
  7. loss = nn.CrossEntropyLoss(reduction="none")

定义[训练函数]

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

```{.python .input} def train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period, lr_decay): trainer = gluon.Trainer(net.collect_params(), ‘sgd’, {‘learning_rate’: lr, ‘momentum’: 0.9, ‘wd’: wd}) num_batches, timer = len(train_iter), 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) for epoch in range(num_epochs): metric = d2l.Accumulator(3) if epoch > 0 and epoch % lr_period == 0: trainer.set_learning_rate(trainer.learning_rate lr_decay) for i, (features, labels) in enumerate(train_iter): timer.start() l, acc = d2l.train_batch_ch13( net, features, labels.astype(‘float32’), loss, trainer, devices, d2l.split_batch) 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)) if valid_iter is not None: valid_acc = d2l.evaluate_accuracy_gpus(net, valid_iter, d2l.split_batch) 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 on {str(devices)}’)

  1. ```{.python .input}
  2. #@tab pytorch
  3. def train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period,
  4. lr_decay):
  5. trainer = torch.optim.SGD(net.parameters(), lr=lr, momentum=0.9,
  6. weight_decay=wd)
  7. scheduler = torch.optim.lr_scheduler.StepLR(trainer, lr_period, lr_decay)
  8. num_batches, timer = len(train_iter), d2l.Timer()
  9. legend = ['train loss', 'train acc']
  10. if valid_iter is not None:
  11. legend.append('valid acc')
  12. animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
  13. legend=legend)
  14. net = nn.DataParallel(net, device_ids=devices).to(devices[0])
  15. for epoch in range(num_epochs):
  16. net.train()
  17. metric = d2l.Accumulator(3)
  18. for i, (features, labels) in enumerate(train_iter):
  19. timer.start()
  20. l, acc = d2l.train_batch_ch13(net, features, labels,
  21. loss, trainer, devices)
  22. metric.add(l, acc, labels.shape[0])
  23. timer.stop()
  24. if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
  25. animator.add(epoch + (i + 1) / num_batches,
  26. (metric[0] / metric[2], metric[1] / metric[2],
  27. None))
  28. if valid_iter is not None:
  29. valid_acc = d2l.evaluate_accuracy_gpu(net, valid_iter)
  30. animator.add(epoch + 1, (None, None, valid_acc))
  31. scheduler.step()
  32. measures = (f'train loss {metric[0] / metric[2]:.3f}, '
  33. f'train acc {metric[1] / metric[2]:.3f}')
  34. if valid_iter is not None:
  35. measures += f', valid acc {valid_acc:.3f}'
  36. print(measures + f'\n{metric[2] * num_epochs / timer.sum():.1f}'
  37. f' examples/sec on {str(devices)}')

[训练和验证模型]

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

```{.python .input} devices, num_epochs, lr, wd = d2l.try_all_gpus(), 20, 0.02, 5e-4 lr_period, lr_decay, net = 4, 0.9, get_net(devices) net.hybridize() train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period, lr_decay)

  1. ```{.python .input}
  2. #@tab pytorch
  3. devices, num_epochs, lr, wd = d2l.try_all_gpus(), 20, 2e-4, 5e-4
  4. lr_period, lr_decay, net = 4, 0.9, get_net()
  5. train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period,
  6. lr_decay)

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

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

```{.python .input} net, preds = get_net(devices), [] net.hybridize() train(net, train_valid_iter, None, num_epochs, lr, wd, devices, lr_period, lr_decay)

for X, _ in test_iter: y_hat = net(X.as_in_ctx(devices[0])) preds.extend(y_hat.argmax(axis=1).astype(int).asnumpy()) 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: train_valid_ds.synsets[x]) df.to_csv(‘submission.csv’, index=False)

  1. ```{.python .input}
  2. #@tab pytorch
  3. net, preds = get_net(), []
  4. train(net, train_valid_iter, None, num_epochs, lr, wd, devices, lr_period,
  5. lr_decay)
  6. for X, _ in test_iter:
  7. y_hat = net(X.to(devices[0]))
  8. preds.extend(y_hat.argmax(dim=1).type(torch.int32).cpu().numpy())
  9. sorted_ids = list(range(1, len(test_ds) + 1))
  10. sorted_ids.sort(key=lambda x: str(x))
  11. df = pd.DataFrame({'id': sorted_ids, 'label': preds})
  12. df['label'] = df['label'].apply(lambda x: train_valid_ds.classes[x])
  13. df.to_csv('submission.csv', index=False)

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

小结

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

:begin_tab:mxnet

  • 我们可以在图像分类竞赛中使用卷积神经网络、图像增广和混合编程。 :end_tab:

:begin_tab:pytorch

  • 我们可以在图像分类竞赛中使用卷积神经网络和图像增广。 :end_tab:

练习

  1. 在这场Kaggle竞赛中使用完整的CIFAR-10数据集。将超参数设为batch_size = 128num_epochs = 100lr = 0.1lr_period = 50lr_decay = 0.1。看看你在这场比赛中能达到什么准确度和排名。或者你能进一步改进吗?
  2. 不使用图像增广时,你能获得怎样的准确度?

:begin_tab:mxnet Discussions :end_tab:

:begin_tab:pytorch Discussions :end_tab: