《动手学深度学习》
Table Of Contents
《动手学深度学习》
Table Of Contents

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

我们将在本节动手实战 Kaggle 比赛中的狗的品种识别问题。该比赛的网页地址是

在这个比赛中,我们将识别 120 类不同品种的狗。这个比赛的数据集实际上是著名的 ImageNet 的子集数据集。和上一节的 CIFAR-10 数据集中的图像不同,ImageNet 数据集中的图像更高更宽,且尺寸不一。

图 9.17 展示了该比赛的网页信息。为了便于提交结果,请先在 Kaggle 网站上注册账号。

狗的品种识别比赛的网页信息。比赛数据集可通过点击“Data”标签获取。(来源:www.kaggle.com/c/dog-breed-identification)

狗的品种识别比赛的网页信息。比赛数据集可通过点击“Data”标签获取。(来源:www.kaggle.com/c/dog-breed-identification)

首先,导入比赛所需的包或模块。

In [1]:
import collections
import gluonbook as gb
import math
from mxnet import autograd, gluon, init, nd
from mxnet.gluon import data as gdata, loss as gloss, model_zoo, nn
import os
import shutil
import time
import zipfile

获取和整理数据集

比赛数据分为训练集和测试集。训练集了包含 10,222 张图像,测试集了包含 10,357 张图像。两个数据集中的图像格式都是 JPEG。这些图像都含有 RGB 三个通道(彩色),高和宽的大小不一。训练集中狗的类别共有 120 种,例如拉布拉多、贵宾、腊肠、萨摩耶、哈士奇、吉娃娃和约克夏等。

下载数据集

登录 Kaggle 后,我们可以点击图 9.17 所示的狗的品种识别比赛网页上的“Data”标签,并分别下载训练数据集“train.zip”、测试数据集“test.zip”和训练数据集标签“label.csv.zip”。下载完成后,将它们分别存放在以下三个路径:

  • ../data/kaggle_dog/train.zip
  • ../data/kaggle_dog/test.zip
  • ../data/kaggle_dog/labels.csv.zip

为方便快速上手,我们提供了上述数据集的小规模采样“train_valid_test_tiny.zip”。如果你要使用上述 Kaggle 比赛的完整数据集,还需要把下面demo变量改为False

In [2]:
# 如果使用下载的 Kaggle 比赛的完整数据集,把下面改为 False。
demo = True
data_dir = '../data/kaggle_dog'
if demo:
    zipfiles = ['train_valid_test_tiny.zip']
else:
    zipfiles = ['train.zip', 'test.zip', 'labels.csv.zip']
for f in zipfiles:
    with zipfile.ZipFile(data_dir + '/' + f, 'r') as z:
        z.extractall(data_dir)

整理数据集

我们定义下面的reorg_train_valid函数来从 Kaggle 比赛的完整原始训练集中切分出验证集。该函数中的参数valid_ratio指验证集中每类狗的样本数与原始训练集中数量最少一类的狗的样本数(66)之比。经过整理后,同一类狗的图像将被放在同一个文件夹下,便于我们稍后读取。

In [3]:
def reorg_train_valid(data_dir, train_dir, input_dir, valid_ratio, idx_label):
    # 训练集中数量最少一类的狗的样本数。
    min_n_train_per_label = (
        collections.Counter(idx_label.values()).most_common()[:-2:-1][0][1])
    # 验证集中每类狗的样本数。
    n_valid_per_label = math.floor(min_n_train_per_label * valid_ratio)
    label_count = {}
    for train_file in os.listdir(os.path.join(data_dir, train_dir)):
        idx = train_file.split('.')[0]
        label = idx_label[idx]
        gb.mkdir_if_not_exist([data_dir, input_dir, 'train_valid', label])
        shutil.copy(os.path.join(data_dir, train_dir, train_file),
                    os.path.join(data_dir, input_dir, 'train_valid', label))
        if label not in label_count or label_count[label] < n_valid_per_label:
            gb.mkdir_if_not_exist([data_dir, input_dir, 'valid', label])
            shutil.copy(os.path.join(data_dir, train_dir, train_file),
                        os.path.join(data_dir, input_dir, 'valid', label))
            label_count[label] = label_count.get(label, 0) + 1
        else:
            gb.mkdir_if_not_exist([data_dir, input_dir, 'train', label])
            shutil.copy(os.path.join(data_dir, train_dir, train_file),
                        os.path.join(data_dir, input_dir, 'train', label))

下面的reorg_dog_data函数用来读取训练数据标签、切分验证集并整理测试集。

In [4]:
def reorg_dog_data(data_dir, label_file, train_dir, test_dir, input_dir,
                   valid_ratio):
    # 读取训练数据标签。
    with open(os.path.join(data_dir, label_file), 'r') as f:
        # 跳过文件头行(栏名称)。
        lines = f.readlines()[1:]
        tokens = [l.rstrip().split(',') for l in lines]
        idx_label = dict(((idx, label) for idx, label in tokens))
    reorg_train_valid(data_dir, train_dir, input_dir, valid_ratio, idx_label)
    # 整理测试集。
    gb.mkdir_if_not_exist([data_dir, input_dir, 'test', 'unknown'])
    for test_file in os.listdir(os.path.join(data_dir, test_dir)):
        shutil.copy(os.path.join(data_dir, test_dir, test_file),
                    os.path.join(data_dir, input_dir, 'test', 'unknown'))

由于我们在这里使用了小数据集,所以将批量大小设为 1。在实际训练和测试时,我们应使用 Kaggle 比赛的完整数据集并调用reorg_dog_data函数来整理数据集。相应地,我们也需要将批量大小batch_size设为一个较大的整数,例如 128。

In [5]:
if demo:
    # 注意:此处使用小数据集并将批量大小相应设小。使用 Kaggle 比赛的完整数据集时可设批量大
    # 小为较大整数。
    input_dir, batch_size = 'train_valid_test_tiny', 1
else:
    label_file, train_dir, test_dir = 'labels.csv', 'train', 'test'
    input_dir, batch_size, valid_ratio = 'train_valid_test', 128, 0.1
    reorg_dog_data(data_dir, label_file, train_dir, test_dir, input_dir,
                   valid_ratio)

图像增广

本节比赛的图像尺寸比上一节中的更大。这里列举了更多可能有用的图像增广操作。

In [6]:
transform_train = gdata.vision.transforms.Compose([
    # 随机对图像裁剪出面积为原图像面积 0.08 到 1 倍之间、且高和宽之比在 3/4 和 4/3 之间
    # 的图像,再放缩为高和宽均为 224 像素的新图像。
    gdata.vision.transforms.RandomResizedCrop(224, scale=(0.08, 1.0),
                                              ratio=(3.0/4.0, 4.0/3.0)),
    gdata.vision.transforms.RandomFlipLeftRight(),
    # 随机变化亮度、对比度和饱和度。
    gdata.vision.transforms.RandomColorJitter(brightness=0.4, contrast=0.4,
                                              saturation=0.4),
    # 随机加噪音。
    gdata.vision.transforms.RandomLighting(0.1),
    gdata.vision.transforms.ToTensor(),
    # 对图像的每个通道做标准化。
    gdata.vision.transforms.Normalize([0.485, 0.456, 0.406],
                                      [0.229, 0.224, 0.225])])

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

In [7]:
transform_test = gdata.vision.transforms.Compose([
    gdata.vision.transforms.Resize(256),
    # 将图像中央的高和宽均为 224 的正方形区域裁剪出来。
    gdata.vision.transforms.CenterCrop(224),
    gdata.vision.transforms.ToTensor(),
    gdata.vision.transforms.Normalize([0.485, 0.456, 0.406],
                                      [0.229, 0.224, 0.225])])

读取数据集

和上一节一样,我们创建ImageFolderDataset实例来读取整理后的含原始图像文件的数据集。

In [8]:
train_ds = gdata.vision.ImageFolderDataset(
    os.path.join(data_dir, input_dir, 'train'), flag=1)
valid_ds = gdata.vision.ImageFolderDataset(
    os.path.join(data_dir, input_dir, 'valid'), flag=1)
train_valid_ds = gdata.vision.ImageFolderDataset(
    os.path.join(data_dir, input_dir, 'train_valid'), flag=1)
test_ds = gdata.vision.ImageFolderDataset(
    os.path.join(data_dir, input_dir, 'test'), flag=1)

这里创建DataLoader实例的方法也与上一节中的相同。

In [9]:
train_data = gdata.DataLoader(train_ds.transform_first(transform_train),
                              batch_size, shuffle=True, last_batch='keep')
valid_data = gdata.DataLoader(valid_ds.transform_first(transform_test),
                              batch_size, shuffle=True, last_batch='keep')
train_valid_data = gdata.DataLoader(train_valid_ds.transform_first(
    transform_train), batch_size, shuffle=True, last_batch='keep')
test_data = gdata.DataLoader(test_ds.transform_first(transform_test),
                             batch_size, shuffle=False, last_batch='keep')

定义模型

这个比赛的数据属于 ImageNet 数据集的子集,因此我们可以使用“微调”一节中介绍的思路,选用在 ImageNet 完整数据集上预训练的模型来抽取图像特征,以作为自定义小规模输出网络的输入。Gluon 提供了丰富的预训练模型,我们在这里以预训练的 ResNet-34 模型为例。由于比赛数据集属于预训练数据集的子集,因此我们直接重用预训练模型在输出层的输入,即抽取的特征。然后,我们可以将原输出层替换成自定义的可训练的小规模输出网络,例如两个串联的全连接层。与“微调”一节中的实验不同,这里不再训练用于抽取特征的预训练模型:这样既节省了训练时间,又省去了存储其模型参数的梯度的空间。

需要注意的是,我们在图像增广中使用了 ImageNet 数据集上 RGB 三个通道的均值和标准差做标准化,这和预训练模型所做的标准化是一致的。

In [10]:
def get_net(ctx):
    finetune_net = model_zoo.vision.resnet34_v2(pretrained=True)
    # 定义新的输出网络。
    finetune_net.output_new = nn.HybridSequential(prefix='')
    finetune_net.output_new.add(nn.Dense(256, activation='relu'))
    # 120 是输出的类别数。
    finetune_net.output_new.add(nn.Dense(120))
    # 初始化输出网络。
    finetune_net.output_new.initialize(init.Xavier(), ctx=ctx)
    # 把模型参数分配到即将用于计算的 CPU 或 GPU 上。
    finetune_net.collect_params().reset_ctx(ctx)
    return finetune_net

在计算损失时,我们先通过成员变量features来获取预训练模型输出层的输入,即抽取的特征。然后,将该特征作为我们自定义的小规模输出网络的输入,并计算输出。

In [11]:
loss = gloss.SoftmaxCrossEntropyLoss()

def get_loss(data, net, ctx):
    l = 0.0
    for X, y in data:
        y = y.as_in_context(ctx)
        output_features = net.features(X.as_in_context(ctx))
        outputs = net.output_new(output_features)
        l += loss(outputs, y).mean().asscalar()
    return l / len(data)

定义训练函数

我们将依赖模型在验证集上的表现来选择模型并调节超参数。模型的训练函数train只训练自定义的小规模输出网络。

In [12]:
def train(net, train_data, valid_data, num_epochs, lr, wd, ctx, lr_period,
          lr_decay):
    # 只训练我们定义的小规模输出网络。
    trainer = gluon.Trainer(net.output_new.collect_params(), 'sgd',
                            {'learning_rate': lr, 'momentum': 0.9, 'wd': wd})
    for epoch in range(num_epochs):
        train_l, start = 0.0, time.time()
        if epoch > 0 and epoch % lr_period == 0:
            trainer.set_learning_rate(trainer.learning_rate * lr_decay)
        for X, y in train_data:
            y = y.astype('float32').as_in_context(ctx)
            output_features = net.features(X.as_in_context(ctx))
            with autograd.record():
                outputs = net.output_new(output_features)
                l = loss(outputs, y)
            l.backward()
            trainer.step(batch_size)
            train_l += l.mean().asscalar()
        time_s = "time %.2f sec" % (time.time() - start)
        if valid_data is not None:
            valid_loss = get_loss(valid_data, net, ctx)
            epoch_s = ("epoch %d, train loss %f, valid loss %f, "
                       % (epoch + 1, train_l / len(train_data), valid_loss))
        else:
            epoch_s = ("epoch %d, train loss %f, "
                       % (epoch + 1, train_l / len(train_data)))
        print(epoch_s + time_s + ', lr ' + str(trainer.learning_rate))

训练并验证模型

现在,我们可以训练并验证模型了。以下的超参数都是可以调节的,例如增加迭代周期等。由于lr_periodlr_decay分别设为 10 和 0.1,优化算法的学习率将在每 10 个迭代周期后自乘 0.1。

In [13]:
ctx, num_epochs, lr, wd = gb.try_gpu(), 1, 0.01, 1e-4
lr_period, lr_decay, net = 10, 0.1, get_net(ctx)
net.hybridize()
train(net, train_data, valid_data, num_epochs, lr, wd, ctx, lr_period,
      lr_decay)
epoch 1, train loss 5.218460, valid loss 4.779257, time 2.88 sec, lr 0.01

对测试集分类并在 Kaggle 提交结果

当得到一组满意的模型设计和超参数后,我们使用全部训练数据集(含验证集)重新训练模型,并对测试集分类。注意,我们要用刚训练好的输出网络做预测。

In [14]:
net = get_net(ctx)
net.hybridize()
train(net, train_valid_data, None, num_epochs, lr, wd, ctx, lr_period,
      lr_decay)

preds = []
for data, label in test_data:
    output_features = net.features(data.as_in_context(ctx))
    output = nd.softmax(net.output_new(output_features))
    preds.extend(output.asnumpy())
ids = sorted(os.listdir(os.path.join(data_dir, input_dir, 'test/unknown')))
with open('submission.csv', 'w') as f:
    f.write('id,' + ','.join(train_valid_ds.synsets) + '\n')
    for i, output in zip(ids, preds):
        f.write(i.split('.')[0] + ',' + ','.join(
            [str(num) for num in output]) + '\n')
epoch 1, train loss 5.055367, time 3.84 sec, lr 0.01

执行完上述代码后,会生成一个“submission.csv”文件。这个文件符合 Kaggle 比赛要求的提交格式。提交结果的方法与“实战 Kaggle 比赛:房价预测”一节中的类似。

小结

  • 我们可以使用在 ImageNet 数据集上预训练的模型抽取特征,并仅训练自定义的小规模输出网络,从而以较小的计算和存储开销对 ImageNet 的子集数据集做分类。

练习

  • 使用 Kaggle 完整数据集,把批量大小batch_size和迭代周期数num_epochs分别调大些,可以在 Kaggle 上拿到什么样的结果?
  • 使用更深的预训练模型,你能获得更好的结果吗?
  • 扫码直达讨论区,在社区交流方法和结果。你能发掘出其他更好的技巧吗?

扫码直达讨论区

image0

参考文献

[1] Kaggle ImageNet Dogs 比赛网址。https://www.kaggle.com/c/dog-breed-identification