kaggle-gluon-dog.md 15.7 KB
Newer Older
A
Aston Zhang 已提交
1
# 实战Kaggle比赛:狗的品种识别 (ImageNet Dogs)
2 3


A
Aston Zhang 已提交
4
我们将在本节动手实战Kaggle比赛中的狗的品种识别问题。该比赛的网页地址是
5

A
Aston Zhang 已提交
6
> https://www.kaggle.com/c/dog-breed-identification
7

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

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

A
Aston Zhang 已提交
12
![狗的品种识别比赛的网页信息。比赛数据集可通过点击“Data”标签获取](../img/kaggle-dog.png)
13 14


A
Aston Zhang 已提交
15
首先,导入实验所需的包或模块。
A
Aston Zhang 已提交
16 17

```{.python .input}
18 19 20
import sys
sys.path.insert(0, '..')

A
Aston Zhang 已提交
21 22 23 24 25 26 27 28 29 30 31
import collections
import datetime
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 zipfile
```

32
## 获取数据集
33

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

### 下载数据集

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

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

44 45

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

```{.python .input  n=1}
48
# 如果使用下载的 Kaggle 比赛的完整数据集,把下面改为 False。
49 50 51 52
demo = True
data_dir = '../data/kaggle_dog'

if demo:
A
Aston Zhang 已提交
53
    zipfiles = ['train_valid_test_tiny.zip']
54
else:
A
Aston Zhang 已提交
55
    zipfiles = ['train.zip', 'test.zip', 'labels.csv.zip']
56

A
Aston Zhang 已提交
57 58 59
for f in zipfiles:
    with zipfile.ZipFile(data_dir + '/' + f, 'r') as z:
        z.extractall(data_dir)
60 61 62 63
```

### 整理数据集

A
Aston Zhang 已提交
64
我们定义下面的`reorg_dog_data`函数来整理Kaggle比赛的完整数据集。整理后,同一类狗的图像将被放在同一个文件夹下,便于我们稍后读取。
65
该函数中的参数`valid_ratio`是验证集中每类狗的样本数与原始训练集中数量最少一类的狗的样本数(66)之比。
66 67 68 69 70 71 72 73 74 75 76

```{.python .input  n=2}
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))

77
    # 训练集中数量最少一类的狗的样本数。
A
Aston Zhang 已提交
78
    min_n_train_per_label = (
A
Aston Zhang 已提交
79
        collections.Counter(idx_label.values()).most_common()[:-2:-1][0][1])
80
    # 验证集中每类狗的样本数。
A
Aston Zhang 已提交
81
    n_valid_per_label = math.floor(min_n_train_per_label * valid_ratio)
A
kaggle  
Aston Zhang 已提交
82
    label_count = {}
83 84 85 86 87 88 89 90 91 92 93 94

    def mkdir_if_not_exist(path):
        if not os.path.exists(os.path.join(*path)):
            os.makedirs(os.path.join(*path))

    # 整理训练和验证集。
    for train_file in os.listdir(os.path.join(data_dir, train_dir)):
        idx = train_file.split('.')[0]
        label = idx_label[idx]
        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))
A
Aston Zhang 已提交
95
        if label not in label_count or label_count[label] < n_valid_per_label:
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
            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:
            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))

    # 整理测试集。
    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'))
```

A
Aston Zhang 已提交
112
由于我们在这里仅仅使用小数据集,于是将批量大小设为1。在实际训练和测试时,我们应使用Kaggle比赛的完整数据集并调用`reorg_dog_data`函数整理数据集。相应地,我们也需要将批量大小`batch_size`设为一个较大的整数,例如128。
113 114 115

```{.python .input  n=3}
if demo:
A
Aston Zhang 已提交
116 117 118
    # 注意:此处使用小数据集并将批量大小相应设小。使用 Kaggle 比赛的完整数据集时可设批量大
    # 小为较大整数。
    input_dir, batch_size = 'train_valid_test_tiny', 1
119
else:
A
Aston Zhang 已提交
120 121
    label_file, train_dir, test_dir = 'labels.csv', 'train', 'test'
    input_dir, batch_size, valid_ratio = 'train_valid_test', 128, 0.1
122 123 124 125
    reorg_dog_data(data_dir, label_file, train_dir, test_dir, input_dir, 
                   valid_ratio)
```

A
Aston Zhang 已提交
126
## 图像增广
127

A
Aston Zhang 已提交
128
为应对过拟合,我们在这里使用`transforms`来增广数据集。例如,加入`transforms.RandomFlipLeftRight()`即可随机对图像做镜面反转。我们也通过`transforms.Normalize()`对彩色图像RGB三个通道分别做标准化。以下列举了部分操作。这些操作可以根据需求来决定是否使用或修改。
129 130

```{.python .input  n=4}
A
Aston Zhang 已提交
131
transform_train = gdata.vision.transforms.Compose([
A
Aston Zhang 已提交
132 133
    # 随机对图像裁剪出面积为原图像面积 0.08 到 1 倍之间、且高和宽之比在 3/4 和 4/3 之间
    # 的图像,再放缩为高和宽均为 224 像素的新图像。
A
Aston Zhang 已提交
134 135
    gdata.vision.transforms.RandomResizedCrop(224, scale=(0.08, 1.0),
                                              ratio=(3.0/4.0, 4.0/3.0)),
A
Aston Zhang 已提交
136
    # 随机左右翻转图像。
A
Aston Zhang 已提交
137
    gdata.vision.transforms.RandomFlipLeftRight(),
138
    # 随机抖动亮度、对比度和饱和度。
A
Aston Zhang 已提交
139 140
    gdata.vision.transforms.RandomColorJitter(brightness=0.4, contrast=0.4,
                                              saturation=0.4),
141
    # 随机加噪音。
A
Aston Zhang 已提交
142
    gdata.vision.transforms.RandomLighting(0.1),
143
    
A
Aston Zhang 已提交
144
    # 将图像像素值按比例缩小到 0 和 1 之间,并将数据格式从“高 * 宽 * 通道”改为
A
Aston Zhang 已提交
145
    # “通道 * 高 * 宽”。
A
Aston Zhang 已提交
146
    gdata.vision.transforms.ToTensor(),
A
Aston Zhang 已提交
147
    # 对图像的每个通道做标准化。
A
Aston Zhang 已提交
148
    gdata.vision.transforms.Normalize([0.485, 0.456, 0.406],
A
Aston Zhang 已提交
149
                                      [0.229, 0.224, 0.225])])
150

151
# 测试时,只使用确定性的图像预处理操作。
A
Aston Zhang 已提交
152 153
transform_test = gdata.vision.transforms.Compose([
    gdata.vision.transforms.Resize(256),
A
Aston Zhang 已提交
154
    # 将图像中央的高和宽均为 224 的正方形区域裁剪出来。
A
Aston Zhang 已提交
155 156 157
    gdata.vision.transforms.CenterCrop(224),
    gdata.vision.transforms.ToTensor(),
    gdata.vision.transforms.Normalize([0.485, 0.456, 0.406],
A
Aston Zhang 已提交
158
                                      [0.229, 0.224, 0.225])])
159 160
```

A
Aston Zhang 已提交
161
接下来,我们可以使用`ImageFolderDataset`类来读取整理后的数据集,其中每个数据样本包括图像和标签。需要注意的是,我们要在`DataLoader`中调用刚刚定义好的图像增广函数。其中`transform_first`函数指明对每个数据样本中的图像做数据增广。
162 163

```{.python .input  n=5}
A
Aston Zhang 已提交
164 165 166 167 168 169 170 171 172
# 读取原始图像文件。flag=1 说明输入图像有三个通道(彩色)。
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)
A
Aston Zhang 已提交
173 174 175 176 177 178 179 180 181

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')
182 183
```

184
## 定义模型并使用微调
185

186
这个比赛的数据属于ImageNet数据集的子集,因此我们可以应用[“微调”](fine-tuning.md)一节中介绍的思路,选用在ImageNet完整数据集上预训练过的模型,并通过微调在比赛数据集上进行训练。Gluon提供了丰富的预训练模型,我们在这里以预训练过的ResNet-34模型为例。由于比赛数据集属于预训练数据集的子集,我们可以重用预训练模型在输出层的输入(即特征),并将原输出层替换成新的可以训练的小规模输出网络,例如两个串联的全连接层。由于预训练模型的参数在训练中是固定的,我们既节约了它们的训练时间,又节省了存储它们梯度所需的空间。
187

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

```{.python .input  n=6}
def get_net(ctx):
192
    # 设 pretrained=True 就能获取预训练模型的参数。第一次使用时需要联网下载。
A
Aston Zhang 已提交
193
    finetune_net = model_zoo.vision.resnet34_v2(pretrained=True)
A
Aston Zhang 已提交
194
    # 定义新的输出网络。
195 196
    finetune_net.output_new = nn.HybridSequential(prefix='')
    finetune_net.output_new.add(nn.Dense(256, activation='relu'))
197
    # 120是输出的类别数。
198
    finetune_net.output_new.add(nn.Dense(120))
199
    # 初始化输出网络。
200
    finetune_net.output_new.initialize(init.Xavier(), ctx=ctx)
201
    # 把模型参数分配到即将用于计算的 CPU 或 GPU 上。
202 203
    finetune_net.collect_params().reset_ctx(ctx)
    return finetune_net
204 205
```

206
## 定义训练函数
207

208
我们将依赖模型在验证集上的表现来选择模型并调节超参数。模型的训练函数`train`只会训练我们定义的输出网络。我们记录了每个迭代周期的训练时间。这有助于比较不同模型的时间开销。
209

210
```{.python .input  n=7}
A
Aston Zhang 已提交
211 212
loss = gloss.SoftmaxCrossEntropyLoss()

213
def get_loss(data, net, ctx):
A
Aston Zhang 已提交
214 215 216
    l = 0.0
    for X, y in data:
        y = y.as_in_context(ctx)
217
        # 计算预训练模型输出层的输入,即特征。
A
Aston Zhang 已提交
218
        output_features = net.features(X.as_in_context(ctx))
219
        # 将特征作为我们定义的输出网络的输入,计算输出。
A
Aston Zhang 已提交
220 221 222
        outputs = net.output_new(output_features)
        l += loss(outputs, y).mean().asscalar()
    return l / len(data)
223

224
def train(net, train_data, valid_data, num_epochs, lr, wd, ctx, lr_period,
225
          lr_decay):
226
    # 只训练我们定义的输出网络。
A
Aston Zhang 已提交
227 228
    trainer = gluon.Trainer(net.output_new.collect_params(), 'sgd',
                            {'learning_rate': lr, 'momentum': 0.9, 'wd': wd})
229 230
    prev_time = datetime.datetime.now()
    for epoch in range(num_epochs):
A
Aston Zhang 已提交
231
        train_l = 0.0
232 233
        if epoch > 0 and epoch % lr_period == 0:
            trainer.set_learning_rate(trainer.learning_rate * lr_decay)
A
Aston Zhang 已提交
234 235
        for X, y in train_data:
            y = y.astype('float32').as_in_context(ctx)
236
            # 计算预训练模型输出层的输入,即特征。
A
Aston Zhang 已提交
237
            output_features = net.features(X.as_in_context(ctx))
238
            with autograd.record():
239
                # 将特征作为我们定义的输出网络的输入,计算输出。
A
Aston Zhang 已提交
240 241
                outputs = net.output_new(output_features)
                l = loss(outputs, y)
242
            # 反向传播只发生在我们定义的输出网络上。
A
Aston Zhang 已提交
243
            l.backward()
244
            trainer.step(batch_size)
A
Aston Zhang 已提交
245
            train_l += l.mean().asscalar()
246 247 248
        cur_time = datetime.datetime.now()
        h, remainder = divmod((cur_time - prev_time).seconds, 3600)
        m, s = divmod(remainder, 60)
A
Aston Zhang 已提交
249
        time_s = "time %02d:%02d:%02d" % (h, m, s)
250
        if valid_data is not None:
251
            valid_loss = get_loss(valid_data, net, ctx)
A
Aston Zhang 已提交
252
            epoch_s = ("epoch %d, train loss %f, valid loss %f, "
A
Aston Zhang 已提交
253
                       % (epoch + 1, train_l / len(train_data), valid_loss))
254
        else:
A
Aston Zhang 已提交
255
            epoch_s = ("epoch %d, train loss %f, "
A
Aston Zhang 已提交
256
                       % (epoch + 1, train_l / len(train_data)))
257
        prev_time = cur_time
A
Aston Zhang 已提交
258
        print(epoch_s + time_s + ', lr ' + str(trainer.learning_rate))
259 260
```

261
## 训练并验证模型
262

A
Aston Zhang 已提交
263
现在,我们可以训练并验证模型了。以下的超参数都是可以调节的,例如增加迭代周期。由于`lr_period``lr_decay`分别设10和0.1,优化算法的学习率将在每10个迭代周期时自乘0.1。
264 265

```{.python .input  n=9}
A
Aston Zhang 已提交
266 267
ctx, num_epochs, lr, wd = gb.try_gpu(), 1, 0.01, 1e-4
lr_period, lr_decay, net = 10, 0.1, get_net(ctx)
268
net.hybridize()
A
Aston Zhang 已提交
269 270
train(net, train_data, valid_data, num_epochs, lr, wd, ctx, lr_period,
      lr_decay)
271 272
```

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

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

```{.python .input  n=8}
net = get_net(ctx)
net.hybridize()
A
Aston Zhang 已提交
280 281
train(net, train_valid_data, None, num_epochs, lr, wd, ctx, lr_period,
      lr_decay)
282

A
Aston Zhang 已提交
283
preds = []
284
for data, label in test_data:
285
    # 计算预训练模型输出层的输入,即特征。
286
    output_features = net.features(data.as_in_context(ctx))
287
    # 将特征作为我们定义的输出网络的输入,计算输出。
288
    output = nd.softmax(net.output_new(output_features))
A
Aston Zhang 已提交
289
    preds.extend(output.asnumpy())
290 291 292
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')
A
Aston Zhang 已提交
293
    for i, output in zip(ids, preds):
294 295 296 297
        f.write(i.split('.')[0] + ',' + ','.join(
            [str(num) for num in output]) + '\n')
```

298
执行完上述代码后,会生成一个“submission.csv”文件。这个文件符合Kaggle比赛要求的提交格式。这时我们可以在Kaggle上把对测试集分类的结果提交并查看分类准确率。你需要登录Kaggle网站,访问ImageNet Dogs比赛网页,并点击右侧“Submit Predictions”或“Late Submission”按钮。然后,点击页面下方“Upload Submission File”选择需要提交的分类结果文件。最后,点击页面最下方的“Make Submission”按钮就可以查看结果了。
299 300


A
kaggle  
Aston Zhang 已提交
301
## 小结
302

303
* 我们可以使用在ImageNet数据集上预训练的模型并微调,从而以较小的计算开销对ImageNet的子集数据集做分类。
304 305


A
kaggle  
Aston Zhang 已提交
306
## 练习
307

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

A
Aston Zhang 已提交
312 313 314
## 扫码直达[讨论区](https://discuss.gluon.ai/t/topic/2399)

![](../img/qr_kaggle-gluon-dog.svg)
A
kaggle  
Aston Zhang 已提交
315 316 317 318

## 参考文献

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