人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

VGG16のFine-tuningによる犬猫認識 (2)

VGG16のFine-tuningによる犬猫認識 (1) (2017/1/8)のつづき。

前回、予告したように下の3つのニューラルネットワークを動かして犬・猫の2クラス分類の精度を比較したい。

  1. 小さな畳み込みニューラルネットをスクラッチから学習する
  2. VGG16が抽出した特徴を使って多層パーセプトロンを学習する
  3. VGG16をFine-tuningする

リポジトリ:dogs_vs_cats

1. 小さな畳み込みニューラルネットをスクラッチから学習する

ベースラインとしてVGG16は使わずに小規模な畳み込みニューラルネットワークをスクラッチから学習する。学習データは、犬クラス1000枚、猫クラス1000枚と小規模なデータを使うのであまり大規模なネットワークは学習できない。そこで、畳込みが3層のLeNet相当の小さなモデルを構成した。

f:id:aidiary:20170110195058p:plain:h400

横の矢印はそのレイヤでの出力の4Dテンソルのサイズ (samples, rows, cols, channels) を表している。入力は、150x150ピクセルで3チャンネルのカラー画像とした。青で囲った部分が畳込み層(Convolution2D)+プーリング層(MaxPooling2D)で緑で囲った部分がフル結合層(FC)である。犬猫分類では、出力ユニット数は1であり、0が猫で1が犬とする。

(補足)犬クラスと猫クラスの割り当てはImageDataGeneratorが勝手に決める。どっちが0でどっちが1に割り当てられたかはclass_indicesでわかる。

> print(train_generator.class_indices)
{'dogs': 1, 'cats': 0}

今回は猫が0で犬が1なのでNNの出力は犬である確率(出力が1に近いほど犬で0に近いほど猫)と解釈できる。0.5を閾値として0.5未満なら猫、0.5以上なら犬と判定すればよい。どのディレクトリがどのクラスに割り当てられるかは表示してみないとわからないみたい。いつもアルファベット順ではなさそうだ。

さっそく、Kerasで実装してみよう。

スクリプト: smallcnn.py

    # モデルを構築
    model = Sequential()
    model.add(Convolution2D(32, 3, 3, input_shape=(150, 150, 3)))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))

    model.add(Convolution2D(32, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))

    model.add(Convolution2D(64, 3, 3))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2, 2)))

    model.add(Flatten())
    model.add(Dense(64))
    model.add(Activation('relu'))
    model.add(Dropout(0.5))
    model.add(Dense(1))
    model.add(Activation('sigmoid'))

    model.compile(loss='binary_crossentropy',
                  optimizer='adam',
                  metrics=['accuracy'])

    # 訓練データとバリデーションデータを生成するジェネレータを作成
    train_datagen = ImageDataGenerator(
        rescale=1.0 / 255,
        shear_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True)

    test_datagen = ImageDataGenerator(rescale=1.0 / 255)

    train_generator = train_datagen.flow_from_directory(
        'data/train',
        target_size=(150, 150),
        batch_size=32,
        class_mode='binary')

    validation_generator = test_datagen.flow_from_directory(
        'data/validation',
        target_size=(150, 150),
        batch_size=32,
        class_mode='binary')

    # 訓練
    history = model.fit_generator(
        train_generator,
        samples_per_epoch=2000,
        nb_epoch=nb_epoch,
        validation_data=validation_generator,
        nb_val_samples=800)

    # 結果を保存
    model.save_weights(os.path.join(result_dir, 'smallcnn.h5'))
    save_history(history, os.path.join(result_dir, 'history_smallcnn.txt'))

出力層は0から1なので出力層の活性化関数にはsigmoidを指定。また、2クラス分類なので損失関数にbinary_crossentropyを指定。最適化アルゴリズムはadamを使った。dataにある画像を直接ロードするためにImageDataGeneratorflow_from_directory()を使った。ここら辺は前回(2017/1/8)まとめた。学習履歴はあとでグラフ化したいのでsave_history()という自作関数でファイルに保存するようにした。

結果はあとで3つまとめて考察したいので次へいこう。

2. VGG16が抽出した特徴を使って多層パーセプトロンを学習する

f:id:aidiary:20170110200655p:plain:h500

2つめの方法はVGG16を特徴抽出器として使うアプローチである。VGG16は左の図のように畳み込み層のブロックが5つ続いたあとにフル結合層のブロックがくっつく構成になっている。VGG16のフル結合層はImageNetの1000クラスを分類するようになっているためそのままでは2クラスの犬・猫分類には使えない。

そこで、この使えないフル結合層(FC)を捨ててしまって直前の畳み込みブロックまでを使うのがこのアプローチである。

f:id:aidiary:20170110201742j:plain:w250
Fine-tuningのためにいつも捨てられる全結合層のイラスト(from @yu4u)

直前の畳込みブロックの出力は、(None, 4, 4, 512) でフラット化すると4x4x512=8192次元ベクトルになる。つまり、入力画像 (None, 150, 150, 3) の150x150x3=67500次元ベクトルを8192次元ベクトルに圧縮した特徴量(ボトルネック特徴量)を抽出する。そして、この8192次元ベクトルのボトルネック特徴量を入力として2クラスを出力とする多層パーセプトロン(2016/11/3)を新たに学習する。

f:id:aidiary:20170110202247p:plain:w300

この程度の小さな多層パーセプトロンの学習なら小さなデータでも十分だろう。

以下の実装では、訓練データとバリデーションデータの画像からボトルネック特徴量を抽出するsave_bottleneck_features()と抽出したボトルネック特徴量を使って多層パーセプトロンを学習するtrain_top_model()に分けている。VGG16include_topオプションをFalseにするとFC層を除外したネットワークがロードできる。

スクリプト: extractor.py

def save_bottleneck_features():
    """VGG16にDog vs Catの訓練画像、バリデーション画像を入力し、
    ボトルネック特徴量(FC層の直前の出力)をファイルに保存する"""

    # VGG16モデルと学習済み重みをロード
    # Fully-connected層(FC)はいらないのでinclude_top=False)
    model = VGG16(include_top=False, weights='imagenet')
    model.summary()

    # ジェネレータの設定
    datagen = ImageDataGenerator(rescale=1.0 / 255)

    # Dog vs Catのトレーニングセットを生成するジェネレータを作成
    generator = datagen.flow_from_directory(
        train_data_dir,
        target_size=(img_width, img_height),
        batch_size=32,
        class_mode=None,
        shuffle=False)

    # ジェネレータから生成される画像を入力し、VGG16の出力をファイルに保存
    bottleneck_features_train = model.predict_generator(generator, nb_train_samples)
    np.save(os.path.join(result_dir, 'bottleneck_features_train.npy'),
            bottleneck_features_train)

    # Dog vs Catのバリデーションセットを生成するジェネレータを作成
    generator = datagen.flow_from_directory(
        validation_data_dir,
        target_size=(img_width, img_height),
        batch_size=32,
        class_mode=None,
        shuffle=False)

    # ジェネレータから生成される画像を入力し、VGG16の出力をファイルに保存
    bottleneck_features_validation = model.predict_generator(generator, nb_validation_samples)
    np.save(os.path.join(result_dir, 'bottleneck_features_validation.npy'),
            bottleneck_features_validation)


def train_top_model():
    """VGGのボトルネック特徴量を入力とし、Dog vs Catの正解を出力とするFCネットワークを訓練"""
    # 訓練データをロード
    # ジェネレータではshuffle=Falseなので最初の1000枚がcats、次の1000枚がdogs
    train_data = np.load(os.path.join(result_dir, 'bottleneck_features_train.npy'))
    train_labels = np.array([0] * int(nb_train_samples / 2) + [1] * int(nb_train_samples / 2))

    # (2000, 4, 4, 512)
    print(train_data.shape)

    # バリデーションデータをロード
    validation_data = np.load(os.path.join(result_dir, 'bottleneck_features_validation.npy'))
    validation_labels = np.array([0] * int(nb_validation_samples / 2) + [1] * int(nb_validation_samples / 2))

    # (800, 4, 4, 512)
    print(validation_data.shape)

    # FCネットワークを構築
    model = Sequential()
    model.add(Flatten(input_shape=train_data.shape[1:]))
    model.add(Dense(256, activation='relu'))
    model.add(Dropout(0.5))
    model.add(Dense(1, activation='sigmoid'))

    model.compile(loss='binary_crossentropy',
                  optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
                  metrics=['accuracy'])

    history = model.fit(train_data, train_labels,
                        nb_epoch=nb_epoch,
                        batch_size=32,
                        validation_data=(validation_data, validation_labels))

    model.save_weights(os.path.join(result_dir, 'bottleneck_fc_model.h5'))
    save_history(history, os.path.join(result_dir, 'history_extractor.txt'))


if __name__ == '__main__':
    # 訓練データとバリデーションデータのボトルネック特徴量の抽出
    save_bottleneck_features()

    # ボトルネット特徴量でFCネットワークを学習
    train_top_model()

ここで、save_bottleneck_features()では一切学習を行っていない点に注意。VGG16の学習した重みをそのまま使ってpredict_generator()で出力を求めている(フィードフォワードのみ)。一方、train_top_model()ではランダムな初期値から多層パーセプトロンの重みをfit()で学習している。ここで、optimizeradamではなく、学習率を小さくしたSGDを使った。実際は、adamでもうまくいくのだが、次のFine-tuningと比較するためにSGDにしている。

この方法では、大規模データで学習したVGG16を特徴抽出器として使っている。ニューラルネットのこのような使い方は知ってしまえば当たり前だが初めて知ったときは衝撃を受けた。Deep Learningの強みは青色部分の多段の特徴抽出にあって分類を行うフル結合層はおまけみたいなものなのだ。実際は、多層パーセプトロンを使わずにSVM(Support Vector Machine)を使うケースもあるようだ。

3. VGG16をFine-tuningする

f:id:aidiary:20170110205228p:plain:h500

2番目の方法では、実質的に重みを学習したのはフル結合層の多層パーセプトロンの部分のみだった。これに対してFine-tuningではフル結合層だけでなく、その1つ前の畳み込み層の重みも学習する。畳込みニューラルネットでは浅い層ほどエッジやブロブなど汎用的な特徴が抽出されているのに対し、深い層ほど学習データに特化した特徴が抽出される傾向がある。そこで、浅い層の汎用的な特徴抽出器はそのまま固定(frozen)し、深い層の重みのみ今回の犬・猫分類に合うように再調整(fine-tuning)してしまう

KerasでFine-tuningをするには下のようなコードを書く。

スクリプト: finetuning.py

if __name__ == '__main__':
    # VGG16モデルと学習済み重みをロード
    # Fully-connected層(FC)はいらないのでinclude_top=False)
    # input_tensorを指定しておかないとoutput_shapeがNoneになってエラーになるので注意
    # https://keras.io/applications/#inceptionv3
    input_tensor = Input(shape=(150, 150, 3))
    vgg16_model = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)
    # vgg16_model.summary()

    # FC層を構築
    # Flattenへの入力指定はバッチ数を除く
    top_model = Sequential()
    top_model.add(Flatten(input_shape=vgg16_model.output_shape[1:]))
    top_model.add(Dense(256, activation='relu'))
    top_model.add(Dropout(0.5))
    top_model.add(Dense(1, activation='sigmoid'))

    # 学習済みのFC層の重みをロード
    top_model.load_weights(os.path.join(result_dir, 'bottleneck_fc_model.h5'))

    # vgg16_modelはkeras.engine.training.Model
    # top_modelはSequentialとなっている
    # ModelはSequentialでないためadd()がない
    # そのためFunctional APIで二つのモデルを結合する
    # https://github.com/fchollet/keras/issues/4040
    model = Model(input=vgg16_model.input, output=top_model(vgg16_model.output))
    print('vgg16_model:', vgg16_model)
    print('top_model:', top_model)
    print('model:', model)

    # Total params: 16,812,353
    # Trainable params: 16,812,353
    # Non-trainable params: 0
    model.summary()

    # layerを表示
    for i in range(len(model.layers)):
        print(i, model.layers[i])

    # 最後のconv層の直前までの層をfreeze
    for layer in model.layers[:15]:
        layer.trainable = False

    # Total params: 16,812,353
    # Trainable params: 9,177,089
    # Non-trainable params: 7,635,264
    model.summary()

    # ここでAdamを使うとうまくいかない
    # Fine-tuningのときは学習率を小さくしたSGDの方がよい?
    model.compile(loss='binary_crossentropy',
                  optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
                  metrics=['accuracy'])

(1) VGG16と多層パーセプトロンの接続

2つめの実装と異なり、今回はFC層を除いたVGG16とフル結合層をKerasのFunctional APIを使って結合している。vgg16_modelがフル結合層を除いたVGG16(図の青色と黄色部分)でtop_modelが多層パーセプトロン(図の緑色部分)である。この2つのネットワークを結合する必要がある。

vgg16_model.add(top_model)とできれば理想的なのだができなかった。よくよく調べるとtop_modelSequentialクラスであるのに対し、vgg16_modelkeras.engine.training.Modelという独自クラスでなぜかこのクラスにはadd()がない!そこで、少し面倒だがこの2つの入出力をつなげる下のような新しいモデルを定義する。

model = Model(input=vgg16_model.input, output=top_model(vgg16_model.output))

この式は、新しいモデルへの入力(input)はvgg16_modelの入力と同じで、出力(output)はtop_modelvgg16_modelの出力を入力した値という意味。なんというややこしさ(^^; ここら辺はベースのTheanoやTensorFlowの書き方に少し近いと思った。

(2) フル結合層の初期値

今回は、フル結合層(top_model)の重みとして2番目の方法で学習した重みを初期値として用いた。実際はあとで結果に示すようにランダムな初期値から初めても最終的に同じ精度に収束するのでこの行は省いてもよさそう。

    # 学習済みのFC層の重みをロード
    top_model.load_weights(os.path.join(result_dir, 'bottleneck_fc_model.h5'))

同じように黄色部分の畳込み層の重みの初期値はVGG16のものだけどこれをランダム初期値から学習するとどうなるか気になった。結局、時間がかかっても同じ精度まで収束するのかな?ニューラルネットは初期値がかなり重要なファクターだからなあ。

(3) 重みの固定

VGG16の図の青色の部分は重みを固定(frozen)したい。Kerasではlayer.trainableFalseにすると重みの学習が行われなくなる。layersInputLayerlayers[0]でパラメータがないMaxPooling2Dlayersに含むのでlayers[:15]はちょうど図の青色の部分にあたる。

    # 最後のconv層の直前までの層をfreeze
    for layer in model.layers[:15]:
        layer.trainable = False

(4) 最適化には学習率の小さいSGDを使う

最初、最適化にAdamを使っていたのだがうまく学習できなかった。Fine-tuningはあくまで重みの再調整なので学習率を小さくしたSGDを使う方がよさそうだ。

実験結果

以上の3つの学習履歴をプロットすると下のようになった。

f:id:aidiary:20170110212330p:plain f:id:aidiary:20170110212333p:plain

ほぼ予想通りの結果で1つめの小さな畳み込みニューラルネット(smallcnn)では75%くらいの精度しか出ていないが、VGG16をFine-tuning(finetuning)すると94%くらいまで精度が跳ね上がる。VGG16を特徴抽出器(extractor)として用いた場合はその中間くらい。

先の(2)で述べたようにFine-tuningでフル結合層をランダムな初期値から始めても(finetuning random)最終的には学習済み初期値を使った場合と同様に94%近くまで上がることもわかった。

Kaggleのスコアを見ると精度94%はトップ100位以内には入っているのでけっこうよい順位だろう。しかも、今回は訓練に25000枚のうちたった2000枚しか使っていない。VGG16の学習済みモデルがいかに強力かわかる。

あとでテストデータで評価してKaggleにsubmitしてみよう!

犬と猫の分類

訓練データに含まれないテスト画像をいくつか入力して本当に分類できているか確認しておいた。train.zipの画像から適当に選んだけど各クラス最初の1000枚しか学習に使っていないので以下の画像は訓練外データである。出力が0.5未満だと猫、0.5以上だと犬と判定すればよい。

スクリプト: predict.py

f:id:aidiary:20170110215210j:plain:w300

input: train/cat.6341.jpg
[ 0.00011273] => 猫!

f:id:aidiary:20170110215214j:plain:w300

input: train/cat.6342.jpg
[ 0.01214203] => 猫!

f:id:aidiary:20170110215220j:plain:w300

input: train/dog.3877.jpg
[ 1.] => 犬!

f:id:aidiary:20170110215234j:plain:w300

input: train/dog.4143.jpg
[ 0.99994457] => 犬!

うちの愛犬を忘れるところだった(笑)

f:id:aidiary:20170104210733j:plain:w300

input: IMG_1550.jpg
[ 0.99496752] => 犬!

f:id:aidiary:20170104210738j:plain:w300

input: IMG_0041.JPG
[ 0.98027724] => 犬!

犬だってよ。よかったね。コロちゃん、くうちゃん(^^)v

実際は、Fine-tuningも限界がある。実は、VGG16を学習したImageNetに犬や猫の画像をたくさんあり、犬猫を分類するのに適した特徴量が抽出できていたため今回の犬猫分類がうまくいったのだ。つまり、ImageNetの画像とまったく異なる種類の画像ではうまく特徴抽出ができずFine-tuningもうまくいかなかったと予測される。

次回はVGG16のFine-tuningの別の例として花の品種の分類を試してみたい。

参考