ディープラーニングによるホットドッグ検出器のレシピ

研究開発部の画像解析担当のレシェックです。techlife を書くのは初めてです。よろしくお願いいたします。

最先端の機械学習を使うためには、常に自分のスキルアップが必要です。そのために、毎日論文を読んだり、新しいオープンソースのコードを試してみたり、クックパッドのデータで実験しています。これはちょっと料理の練習と似ています。新しいモデルを学習させるのは料理をオーブンに入れるのと同じ気持ちです。オーブンの温度は学習率と同じで、低すぎだとよく焼けず、高すぎだと焦げてしまいます。しかし、ちゃんと他のリサーチャーの論文やブログの中のレシピを見ながら自分のデータでモデルを学習させると、失敗せずに済むかもしれません。

このエントリでは、そういった機械学習のレシピの一例を紹介します。

f:id:lunardog:20180405185342j:plain

このブログで使っているテスト画像はPixabayから取得した、Creative Commonsのライセンスの写真です。

概要

クックパッドは料理/非料理のモデルを開発しています。ここでは、このモデルのミニチュア版のレシピを紹介します。カテゴリは「料理」と「非料理」の代わりに、「ホットドッグ」と「非ホットドッグ」にします。そして、パッチ化した画像に対する認識モデルを使って、画像の中でホットドッグがどこにあるかを検出します。

調理器具

  • python
  • Keras
  • numpy
  • pillow (PIL)
  • jupyter notebook(お好みでお使い下さい。)

KerasはTensorflow、CNTKやTheano上で動く高水準のライブラリーです。Keras は特に画像データに対して、単なる学習以外にも前処理などでも様々な機能があります。

材料

KaggleからHot Dog - Not Hot Dogのデーターセットをダウンロードしてください。なお、ダウンロードするには Kaggle の登録が必要です。

ダウンロードした後、seefood.zipunzipしてください。

アーカイブの中に、2つのディレクトリtraintestがあります。

seefood/train/not_hot_dog
seefood/train/hot_dog
seefood/test/not_hot_dog
seefood/test/hot_dog

hot_dogディレクトリの中にホットドッグの画像が入っており、not_hot_dogの中にそれ以外の画像が入っています。新しい機械学習のレシピを開発する時はテストデータを分けるべきです。しかし、今回は画像が少ないので、テストデータも学習に使いましょう。

mkdir seefood/all
cp -r seefood/test/* seefood/train/* seefood/all

以降では、seefood/allのディレクトリを使います。

データ拡張

Keras のモバイルネットは(224px・224px)のフィックスサイズの画像しか認識できないので、これから学習や認識用にサイズを変換します。

IMG_SIZE=[224, 224]

テストデータを学習に使っても、このデータセットはまだ小さいので、データ拡張を使いましょう。

KerasのImageDataGeneratorは学習時に画像を一つずつ変換します。

import keras.preprocessing.image

image_generator = keras.preprocessing.image.ImageDataGenerator(
        rescale=1./255,
        shear_range=0.0,
        width_shift_range=0.1,
        height_shift_range=0.1,
        rotation_range=10,
        fill_mode="wrap",
        vertical_flip=True,
        horizontal_flip=True
)

上のimage_generator"seefood/all"のディレクトリで動かします。

train_generator = image_generator.flow_from_directory(
    "seefood/all",
    target_size=IMG_SIZE,
    batch_size=32,
    class_mode="categorical",
    classes=["not_hot_dog", "hot_dog"]
)

モデルの作り方

以下のレシピでは、3 個のモデルを 3 層のスポンジケーキのように積み重ねています。

  1. base_modelMobileNetです。転移学習のために使います。
  2. その上のpatch_modelは画像のパッチごとに分類できます。
  3. さらにその上のclassifierは「ホットドッグ」と「非ホットドッグ」の二値分類器です。

まずkerasimportします:

import keras

ベースとして、Googleで開発されたMobileNetというモデルを使います。

weights="imagenet"は、ILSVRCのコンペティションのデータセットで学習されたパラメタを使って、転移学習することを意味しています。

base_model = keras.applications.mobilenet.MobileNet(
    input_shape=IMG_SIZE + [3], 
    weights="imagenet",
    include_top=False
)

ベースモデルの一番上のフィーチャサイズは1024です。パッチレイヤが学習できるようにちょっと下げましょう。

drop1 = keras.layers.SpatialDropout2D(0.3)(base_model.output)
conv_filter = keras.layers.convolutional.Conv2D(
    4, (1,1),
    activation="relu",
    use_bias=True,
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop1)

パッチレイヤもConv2Dのタイプのレイヤです。この場合、softmaxを使えば、パッチごとに分類できるようになります。

drop2 = keras.layers.SpatialDropout2D(0.3)(conv_filter)
patch = keras.layers.convolutional.Conv2D(
    2, (3, 3),
    name="patch",
    activation="softmax",
    use_bias=True,
    padding="same",
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop2)

これでパッチモデルができました。

patch_model = keras.models.Model(
    inputs=base_model.input, 
    outputs=patch
)

パッチモデルをベースにして、最後の出力レイヤを追加して分類モデルを作ります。

pool = keras.layers.GlobalAveragePooling2D()(patch)
logits = keras.layers.Activation("softmax")(pool)


classifier = keras.models.Model(
    inputs=base_model.input, 
    outputs=logits
)

学習

ベースモデルは学習させません。

for layer in base_model.layers:
    layer.trainable = False

そして全体のモデルをcompileします。

classifier.compile(optimizer="rmsprop", loss="categorical_crossentropy", metrics=["accuracy"])

では、学習を始めましょう!

いくつか実験をした結果、以下のようにnot_hot_dogのクラスのclass_weightを高くするほうが良いことが分かりました。

%%time
classifier.fit_generator(
    train_generator, 
    class_weight={0: .75, 1: .25}, 
    epochs=10
)
Epoch 1/10
32/32 [==============================] - 148s 5s/step - loss: 0.3157 - acc: 0.5051
Epoch 2/10
32/32 [==============================] - 121s 4s/step - loss: 0.3017 - acc: 0.5051
Epoch 3/10
32/32 [==============================] - 122s 4s/step - loss: 0.2961 - acc: 0.5010
Epoch 4/10
32/32 [==============================] - 121s 4s/step - loss: 0.2791 - acc: 0.5862
Epoch 5/10
32/32 [==============================] - 122s 4s/step - loss: 0.2681 - acc: 0.6380
Epoch 6/10
32/32 [==============================] - 123s 4s/step - loss: 0.2615 - acc: 0.6876
Epoch 7/10
32/32 [==============================] - 121s 4s/step - loss: 0.2547 - acc: 0.6790
Epoch 8/10
32/32 [==============================] - 122s 4s/step - loss: 0.2522 - acc: 0.7052
Epoch 9/10
32/32 [==============================] - 123s 4s/step - loss: 0.2522 - acc: 0.7045
Epoch 10/10
32/32 [==============================] - 145s 5s/step - loss: 0.2486 - acc: 0.7164
CPU times: user 1h 4min 20s, sys: 2min 35s, total: 1h 6min 56s
Wall time: 21min 8s

このデータセットの場合、10エポックぐらいが良さそうです。パッチベースを使っているので、精度は100%にならないほうがいいです。70%ぐらいがちょうどいいです。

私の MacBook Pro では10エポックで20分ぐらいかかりました。

確認作業

画像とデータの変換のために、PILnumpyを使います。

import numpy as np
from PIL import Image

画像をインファレンスする前に、numpyのデータに変換します。

def patch_infer(img):
    data = np.array(img.resize(IMG_SIZE))/255.0
    patches = patch_model.predict(data[np.newaxis])
    return patches

そして、元の画像とインファレンス結果をビジュアライズします。

def overlay(img, patches, threshold=0.99):
    # transposeはパッチをクラスごとに分けます。
    patches = patches[0].transpose(2, 0, 1)
    # hot_dogパッチ - not_hot_dogパッチ
    patches = patches[1] - patches[0]
    # 微妙なパッチをなくして
    patches = np.clip(patches, threshold, 1.0)
    patches = 255.0 * (patches - threshold) / (1.0 - threshold)
    # 数字を画像にして
    patches = Image.fromarray(patches.astype(np.uint8)).resize(img.size, Image.BICUBIC)
    # もとの画像を白黒に
    grayscale = img.convert("L").convert("RGB").point(lambda p: p * 0.5)
    # パッチをマスクに使って、元の画像と白黒の画像をあわせて
    composite = Image.composite(img, grayscale, patches)
    return composite

まとめて、インファレンスとビジュアライズを一つのファンクションにすると、

def process_image(path, border=8):
    img = Image.open(path)
    patches = patch_infer(img)
    result = overlay(img, patches)
    # 元の画像と変換された画像をカンバスに並べます
    canvas = Image.new(
        mode="RGB", 
        size=(img.width * 2 + border, img.height), 
        color="white")
    canvas.paste(img, (0,0))
    canvas.paste(result, (img.width + border, 0))
    return canvas

では、結果を見てみましょう!

f:id:lunardog:20180405185418j:plain きれいですね!

f:id:lunardog:20180405185437j:plain ホットドッグの色はちょっと隣のコーヒーに移りましたが、ほとんど大丈夫です。

f:id:lunardog:20180405185457j:plain フォーカスが足りないところは認識にならなかったみたいです。なぜでしょう?学習データにフォーカスが当たらないホットドッグがなかったからです。

f:id:lunardog:20180405185342j:plain こちらも、左側のホットドッグはフォーカスが当たっておらず、モデルはホットドッグを認識できませんでした。

ホットドッグではない画像は? f:id:lunardog:20180405185526j:plain

f:id:lunardog:20180405185541j:plain

f:id:lunardog:20180405185558j:plain

f:id:lunardog:20180405185609j:plain

ホットドッグではない画像には、パッチはゼロやゼロに近い値になります。

まとめ

転移学習を使えば、データが少なくても、それなりの識別器が作れますね!

パッチごとの分類を使えば、画像の中の認識したいフィーチャーを可視化できます。

モバイルネット(MobileNet)のおかげで、CPU でもモデルを学習できます。

いかがでしたでしょうか。 クックパッドでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/