|
| 1 | +# 猫狗大战 |
| 2 | + |
| 3 | +数据集来自 kaggle 上的一个竞赛:[Dogs vs. Cats](https://www.kaggle.com/c/dogs-vs-cats-redux-kernels-edition),训练集有25000张,猫狗各占一半。测试集12500张,没有标定是猫还是狗。 |
| 4 | + |
| 5 | +``` |
| 6 | +➜ 猫狗大战 ls train | head |
| 7 | +cat.0.jpg |
| 8 | +cat.1.jpg |
| 9 | +cat.10.jpg |
| 10 | +cat.100.jpg |
| 11 | +cat.1000.jpg |
| 12 | +cat.10000.jpg |
| 13 | +cat.10001.jpg |
| 14 | +cat.10002.jpg |
| 15 | +cat.10003.jpg |
| 16 | +cat.10004.jpg |
| 17 | +➜ 猫狗大战 ls test | head |
| 18 | +1.jpg |
| 19 | +10.jpg |
| 20 | +100.jpg |
| 21 | +1000.jpg |
| 22 | +10000.jpg |
| 23 | +10001.jpg |
| 24 | +10002.jpg |
| 25 | +10003.jpg |
| 26 | +10004.jpg |
| 27 | +10005.jpg |
| 28 | +``` |
| 29 | + |
| 30 | +下面是训练集的一部分例子: |
| 31 | + |
| 32 | + |
| 33 | + |
| 34 | +# 数据预处理 |
| 35 | + |
| 36 | +由于我们的数据集的文件名是以`type.num.jpg`这样的方式命名的,比如`cat.0.jpg`,但是使用 Keras 的 ImageDataGenerator 需要将不同种类的图片分在不同的文件夹中,因此我们需要对数据集进行预处理。这里我们采取的思路是创建符号链接(symbol link),这样的好处是不用复制一遍图片,占用不必要的空间。 |
| 37 | + |
| 38 | +```py |
| 39 | +import os |
| 40 | +import shutil |
| 41 | + |
| 42 | +train_filenames = os.listdir('train') |
| 43 | +train_cat = filter(lambda x:x[:3] == 'cat', train_filenames) |
| 44 | +train_dog = filter(lambda x:x[:3] == 'dog', train_filenames) |
| 45 | + |
| 46 | +def rmrf_mkdir(dirname): |
| 47 | + if os.path.exists(dirname): |
| 48 | + shutil.rmtree(dirname) |
| 49 | + os.mkdir(dirname) |
| 50 | + |
| 51 | +rmrf_mkdir('train2') |
| 52 | +os.mkdir('train2/cat') |
| 53 | +os.mkdir('train2/dog') |
| 54 | + |
| 55 | +rmrf_mkdir('test2') |
| 56 | +os.symlink('../test/', 'test2/test') |
| 57 | + |
| 58 | +for filename in train_cat: |
| 59 | + os.symlink('../../train/'+filename, 'train2/cat/'+filename) |
| 60 | + |
| 61 | +for filename in train_dog: |
| 62 | + os.symlink('../../train/'+filename, 'train2/dog/'+filename) |
| 63 | +``` |
| 64 | + |
| 65 | +我们可以从下面看到文件夹的结构,train2里面有两个文件夹,分别是猫和狗,每个文件夹里是12500张图。 |
| 66 | + |
| 67 | +``` |
| 68 | +├── test [12500 images] |
| 69 | +├── test.zip |
| 70 | +├── test2 |
| 71 | +│ └── test -> ../test/ |
| 72 | +├── train [25000 images] |
| 73 | +├── train.zip |
| 74 | +└── train2 |
| 75 | + ├── cat [12500 images] |
| 76 | + └── dog [12500 images] |
| 77 | +``` |
| 78 | + |
| 79 | +# 导出特征向量 |
| 80 | + |
| 81 | +对于这个题目来说,使用预训练的网络是最好不过的了,经过前期的测试,我们测试了 ResNet50 等不同的网络,但是排名都不高,现在看来只有一两百名的样子,所以我们需要提高我们的模型表现。那么一种有效的方法是综合各个不同的模型,从而得到不错的效果,兼听则明。如果是直接在一个巨大的网络后面加我们的全连接,那么训练10代就需要跑十次巨大的网络,而且我们的卷积层都是不可训练的,那么这个计算就是浪费的。所以我们可以将多个不同的网络输出的特征向量先保存下来,以便后续的训练,这样做的好处是我们一旦保存了特征向量,即使是在普通笔记本上也能轻松训练。 |
| 82 | + |
| 83 | +```py |
| 84 | +from keras.models import * |
| 85 | +from keras.layers import * |
| 86 | +from keras.applications import * |
| 87 | +from keras.preprocessing.image import * |
| 88 | + |
| 89 | +import h5py |
| 90 | + |
| 91 | +def write_gap(MODEL, image_size, lambda_func=None): |
| 92 | + width = image_size[0] |
| 93 | + height = image_size[1] |
| 94 | + input_tensor = Input((height, width, 3)) |
| 95 | + x = input_tensor |
| 96 | + if lambda_func: |
| 97 | + x = Lambda(lambda_func)(x) |
| 98 | + |
| 99 | + base_model = MODEL(input_tensor=x, weights='imagenet', include_top=False) |
| 100 | + model = Model(base_model.input, GlobalAveragePooling2D()(base_model.output)) |
| 101 | + |
| 102 | + gen = ImageDataGenerator() |
| 103 | + train_generator = gen.flow_from_directory("train2", image_size, shuffle=False, |
| 104 | + batch_size=16) |
| 105 | + test_generator = gen.flow_from_directory("test2", image_size, shuffle=False, |
| 106 | + batch_size=16, class_mode=None) |
| 107 | + |
| 108 | + train = model.predict_generator(train_generator, train_generator.nb_sample) |
| 109 | + test = model.predict_generator(test_generator, test_generator.nb_sample) |
| 110 | + with h5py.File("gap_%s.h5"%MODEL.func_name) as h: |
| 111 | + h.create_dataset("train", data=train) |
| 112 | + h.create_dataset("test", data=test) |
| 113 | + h.create_dataset("label", data=train_generator.classes) |
| 114 | + |
| 115 | +write_gap(ResNet50, (224, 224)) |
| 116 | +write_gap(InceptionV3, (299, 299), inception_v3.preprocess_input) |
| 117 | +write_gap(Xception, (299, 299), xception.preprocess_input) |
| 118 | + |
| 119 | +``` |
| 120 | + |
| 121 | +为了复用代码,我觉得写一个函数是非常有必要的,那么我们的函数就需要输入模型,输入图片的大小,以及[预处理函数](https://github.com/fchollet/keras/blob/master/keras/applications/inception_v3.py#L389-L393),因为 Xception 和 Inception V3 都需要将数据限定在 `(-1, 1)` 的范围内,然后我们利用 `GlobalAveragePooling2D` 将卷积层输出的每个激活图直接求平均值,不然输出的文件会非常大,且容易过拟合。然后我们定义了两个 generator,利用 `model.predict_generator` 函数来导出特征向量,最后我们选择了 ResNet50, Xception, Inception V3 这三个模型(如果有兴趣也可以导出 VGG 的特征向量)。每个模型导出的时间都挺长的,在 aws p2.xlarge 上大概需要用**十分钟到二十分钟**。 这三个模型都是在 [ImageNet](http://www.image-net.org/) 上面预训练过的,所以每一个模型都可以说是身经百战,通过这三个老司机导出的特征向量,可以高度概括一张图片有哪些内容。 |
| 122 | + |
| 123 | +最后导出的 h5 文件包括三个 numpy 数组: |
| 124 | + |
| 125 | +* train (25000, 2048) |
| 126 | +* test (12500, 2048) |
| 127 | +* label (25000,) |
| 128 | + |
| 129 | +参考资料: |
| 130 | + |
| 131 | +* [ResNet](https://arxiv.org/abs/1512.03385) 15.12 |
| 132 | +* [Inception v3](https://arxiv.org/abs/1512.00567) 15.12 |
| 133 | +* [Xception](https://arxiv.org/abs/1610.02357) 16.10 |
| 134 | + |
| 135 | +# 载入特征向量 |
| 136 | + |
| 137 | +经过上面的代码以后,我们获得了三个特征向量文件,分别是: |
| 138 | + |
| 139 | +* gap_ResNet50.h5 |
| 140 | +* gap_InceptionV3.h5 |
| 141 | +* gap_Xception.h5 |
| 142 | + |
| 143 | +我们需要载入这些特征向量,并且将它们合成一条特征向量,然后记得把 X 和 y 打乱,不然之后我们设置`validation_split`的时候会出问题。这里设置了 numpy 的随机数种子为2017,这样可以确保每个人跑这个代码,输出都能是一样的结果。 |
| 144 | + |
| 145 | +```py |
| 146 | +import h5py |
| 147 | +import numpy as np |
| 148 | +from sklearn.utils import shuffle |
| 149 | +np.random.seed(2017) |
| 150 | + |
| 151 | +X_train = [] |
| 152 | +X_test = [] |
| 153 | + |
| 154 | +for filename in ["gap_ResNet50.h5", "gap_Xception.h5", "gap_InceptionV3.h5"]: |
| 155 | + with h5py.File(filename, 'r') as h: |
| 156 | + X_train.append(np.array(h['train'])) |
| 157 | + X_test.append(np.array(h['test'])) |
| 158 | + y_train = np.array(h['label']) |
| 159 | + |
| 160 | +X_train = np.concatenate(X_train, axis=1) |
| 161 | +X_test = np.concatenate(X_test, axis=1) |
| 162 | + |
| 163 | +X_train, y_train = shuffle(X_train, y_train) |
| 164 | +``` |
| 165 | + |
| 166 | +# 构建模型 |
| 167 | + |
| 168 | +模型的构建很简单,直接 dropout 然后分类就好了。 |
| 169 | + |
| 170 | +```py |
| 171 | +from keras.models import * |
| 172 | +from keras.layers import * |
| 173 | + |
| 174 | +np.random.seed(2017) |
| 175 | + |
| 176 | +input_tensor = Input(X_train.shape[1:]) |
| 177 | +x = Dropout(0.5)(input_tensor) |
| 178 | +x = Dense(1, activation='sigmoid')(x) |
| 179 | +model = Model(input_tensor, x) |
| 180 | + |
| 181 | +model.compile(optimizer='adadelta', |
| 182 | + loss='binary_crossentropy', |
| 183 | + metrics=['accuracy']) |
| 184 | +``` |
| 185 | + |
| 186 | +我们还可以对模型进行可视化: |
| 187 | + |
| 188 | +```dot |
| 189 | +digraph G{ |
| 190 | + node [shape=record] |
| 191 | + a[label="ResNet50|{input:|output:}|{(224, 224, 3)|(2048)}"] |
| 192 | + b[label="InceptionV3|{input:|output:}|{(299, 299, 3)|(2048)}"] |
| 193 | + c[label="Xception|{input:|output:}|{(299, 299, 3)|(2048)}"] |
| 194 | + Merge[label="Merge|{input:|output:}|{(3, 2048)|(6144)}"] |
| 195 | + Dropout[label="Dropout|Rate:|0.5"] |
| 196 | + Output[label="Output|{input:|output:}|{(6144)|(1)}"] |
| 197 | + Image -> a -> Merge |
| 198 | + Image -> b -> Merge |
| 199 | + Image -> c -> Merge |
| 200 | + Merge -> Dropout -> Output |
| 201 | +} |
| 202 | +``` |
| 203 | + |
| 204 | + |
| 205 | + |
| 206 | +# 训练模型 |
| 207 | + |
| 208 | +模型构件好了以后,我们就可以进行训练了,这里我们设置验证集大小为 20% ,也就是说训练集是20000张图,验证集是5000张图。 |
| 209 | + |
| 210 | +```py |
| 211 | +model.fit(X_train, y_train, batch_size=128, nb_epoch=8, validation_split=0.2) |
| 212 | +``` |
| 213 | + |
| 214 | +``` |
| 215 | +Train on 20000 samples, validate on 5000 samples |
| 216 | +Epoch 1/8 |
| 217 | +20000/20000 [==============================] - 1s - loss: 0.1193 - acc: 0.9591 - val_loss: 0.0283 - val_acc: 0.9936 |
| 218 | +Epoch 2/8 |
| 219 | +20000/20000 [==============================] - 0s - loss: 0.0319 - acc: 0.9898 - val_loss: 0.0181 - val_acc: 0.9952 |
| 220 | +Epoch 3/8 |
| 221 | +20000/20000 [==============================] - 0s - loss: 0.0252 - acc: 0.9916 - val_loss: 0.0172 - val_acc: 0.9934 |
| 222 | +Epoch 4/8 |
| 223 | +20000/20000 [==============================] - 0s - loss: 0.0214 - acc: 0.9936 - val_loss: 0.0140 - val_acc: 0.9956 |
| 224 | +Epoch 5/8 |
| 225 | +20000/20000 [==============================] - 0s - loss: 0.0200 - acc: 0.9926 - val_loss: 0.0139 - val_acc: 0.9954 |
| 226 | +Epoch 6/8 |
| 227 | +20000/20000 [==============================] - 0s - loss: 0.0189 - acc: 0.9933 - val_loss: 0.0129 - val_acc: 0.9956 |
| 228 | +Epoch 7/8 |
| 229 | +20000/20000 [==============================] - 0s - loss: 0.0170 - acc: 0.9946 - val_loss: 0.0123 - val_acc: 0.9960 |
| 230 | +Epoch 8/8 |
| 231 | +20000/20000 [==============================] - 0s - loss: 0.0163 - acc: 0.9945 - val_loss: 0.0119 - val_acc: 0.9958 |
| 232 | +Out[4]: |
| 233 | +
|
| 234 | +``` |
| 235 | + |
| 236 | +我们可以看到,训练的过程很快,十秒以内就能训练完,准确率也很高,在验证集上最高达到了99.6%的准确率,这相当于一千张图只错了4张,可以说比我还厉害。 |
| 237 | + |
| 238 | +# 预测测试集 |
| 239 | + |
| 240 | +模型训练好以后,我们就可以对测试集进行预测,然后提交到 kaggle 上看看最终成绩了。 |
| 241 | + |
| 242 | +```py |
| 243 | +y_pred = model.predict(X_test, verbose=1) |
| 244 | +y_pred = y_pred.clip(min=0.005, max=0.995) |
| 245 | + |
| 246 | +import pandas as pd |
| 247 | +from keras.preprocessing.image import * |
| 248 | + |
| 249 | +df = pd.read_csv("sample_submission.csv") |
| 250 | + |
| 251 | +gen = ImageDataGenerator() |
| 252 | +test_generator = gen.flow_from_directory("test2", (224, 224), shuffle=False, |
| 253 | + batch_size=16, class_mode=None) |
| 254 | + |
| 255 | +for i, fname in enumerate(test_generator.filenames): |
| 256 | + index = int(fname[fname.rfind('/')+1:fname.rfind('.')]) |
| 257 | + df.set_value(index-1, 'label', y_pred[i]) |
| 258 | + |
| 259 | +df.to_csv('pred.csv', index=None) |
| 260 | +df.head(10) |
| 261 | +``` |
| 262 | + |
| 263 | +预测这里我们用到了一个小技巧,我们将每个预测值限制到了 [0.005, 0.995] 个区间内,这个原因很简单,kaggle 官方的评估标准是 [LogLoss](https://www.kaggle.com/c/dogs-vs-cats-redux-kernels-edition/details/evaluation),对于预测正确的样本,0.995 和 1 相差无几,但是对于预测错误的样本,0 和 0.005 的差距非常大,是 15 和 2 的差别。参考 [LogLoss 如何处理无穷大问题](https://www.kaggle.com/wiki/LogLoss),下面的表达式就是二分类问题的 LogLoss 定义。 |
| 264 | + |
| 265 | +$$\textrm{LogLoss} = - \frac{1}{n} \sum_{i=1}^n \left[ y_i \log(\hat{y}_i) + (1 - y_i) \log(1 - \hat{y}_i)\right]$$ |
| 266 | + |
| 267 | + |
| 268 | + |
| 269 | +还有一个值得一提的地方就是测试集的文件名不是按 1, 2, 3 这样排的,而是按下面的顺序排列的: |
| 270 | + |
| 271 | +``` |
| 272 | +['test/1.jpg', |
| 273 | + 'test/10.jpg', |
| 274 | + 'test/100.jpg', |
| 275 | + 'test/1000.jpg', |
| 276 | + 'test/10000.jpg', |
| 277 | + 'test/10001.jpg', |
| 278 | + 'test/10002.jpg', |
| 279 | + 'test/10003.jpg', |
| 280 | + ...... |
| 281 | +``` |
| 282 | + |
| 283 | +因此我们需要对每个文件名进行处理,然后赋值到 df 里,最后导出为 csv 文件。 |
| 284 | + |
| 285 | +``` |
| 286 | + id label |
| 287 | +0 1 0.995 |
| 288 | +1 2 0.995 |
| 289 | +2 3 0.995 |
| 290 | +3 4 0.995 |
| 291 | +4 5 0.005 |
| 292 | +5 6 0.005 |
| 293 | +6 7 0.005 |
| 294 | +7 8 0.005 |
| 295 | +8 9 0.005 |
| 296 | +9 10 0.005 |
| 297 | +``` |
| 298 | + |
| 299 | +# 总结 |
| 300 | + |
| 301 | +我们可以从上图中看到,模型对于前十个样本都给出了很肯定的预测,提交到 kaggle 以后,得分也是很棒,0.04141,在全球排名中可以排到20/1314。我们如果要继续优化模型表现,可以使用更棒的预训练模型来导出特征向量,或者对预训练模型进行微调(fine-tune),或者进行数据增强(data augmentation)等。 |
| 302 | + |
| 303 | +参考链接:[面向小数据集构建图像分类模型](http://keras-cn.readthedocs.io/en/latest/blog/image_classification_using_very_little_data/) |
0 commit comments