kerasがんばる！～CNN・転移学習を利用して画像分類

# はじめに
趣味でMastodon上で稼働するbotを作ったりしています。そのうちの一つ、CNNと転移学習を用いた画像分類について解説します。

## 開発環境
ubuntu18.04/python3.6/cuda9.0/tensorflow1.9.0(GPU版)

## 前提条件
DeepLearningやkerasにある程度入門できている方を想定しています。

# 投稿された画像を分類する
投稿された画像を約70種類に分類して、それぞれに合わせたコメントを投稿する機能があります。画像分類器にはkerasのデータセットとして用意されている「Xception」を使って転移学習させています。

# データを集める
分類したいカテゴリごとにだいたい100枚ずつ集めました（合計7000枚くらい）。
0から学習するとなると全然足りませんが、転移学習ということでこのくらいで大丈夫なはずです。
画像の格納イメージは以下の通りです。サブディレクトリの名前が分類ラベルになります。

img_dir/
├ラーメン/ 001.png,002.jpg,……
├たこやき/ 001.png,002.jpg,……
├……

# モデル構築
今回は転移学習を用いて少ない画像で効果的に画像を分類することを目指します。
kerasにはVGG16やInceptionV3などの学習済みモデルが用意されています。今回はXceptionという比較的軽量で成績の良いモデルを選んでみました。

```python:model.py

def cnn_model(labels):
    # include_top=Falseによって、モデルから全結合層を削除
    input_tensor = Input(shape=(299, 299, 3))
    xception_model = Xception(include_top=False, pooling='avg', input_tensor=input_tensor)
    # 全結合層の構築
    top_model = Sequential()
    top_model.add(GaussianNoise(stddev=0.2,input_shape=xception_model.output_shape[1:]))
    top_model.add(Dropout(0.3))
    top_model.add(Dense(len(labels),input_shape=xception_model.output_shape[1:]))
    top_model.add(Activation("softmax"))

    # 全結合層を削除したモデルと上で自前で構築した全結合層を結合
    model = Model(inputs=xception_model.input, outputs=top_model(xception_model.output))

    # モデル重みを固定
    for layer in xception_model.layers[:-50]:
        layer.trainable = False
    return model

```
Xception層:学習済みレイヤーとして使用します。pooling='avg'を指定することでGlobalAveragePoolingを行ってくれます。Denseで全結合するよりモデルサイズが少なく済み、かつ分類性能は同等、らしいです。
GaussianNoise層、Dropout層:過学習防止のため入れました。
Dense層:全結合層。カテゴリ数に合わせて分類分けします。
Activation層:softmaxで合計1.0になるようにカテゴリごとの適合率を計算してくれます。

#学習
##データジェネレータ
画像枚数がそこそこあるので一度にメモリに展開できません。kerasに用意されている「ImageDataGenerator」を使ってイテレートの度にデータを返すデータジェネレータを作成します。また、パラメータ指定によりランダムに画像を回転・拡大縮小等を自動で行い、データの水増しも行ってくれます（画像データが少ない場合は特に有効）。

```python:cnn_train.py

    train_datagen = ImageDataGenerator(
                    rescale=1./255,
                    rotation_range=90, # 90°まで回転
                    width_shift_range=0.2, # 水平方向にランダムでシフト
                    height_shift_range=0.2, # 垂直方向にランダムでシフト
                    #channel_shift_range=50.0, # 色調をランダム変更
                    shear_range=0.2, # 斜め方向(pi/8まで)に引っ張る
                    horizontal_flip=True, # 垂直方向にランダムで反転
                    vertical_flip=True, # 水平方向にランダムで反転
                    zoom_range=0.2,
                    fill_mode='wrap'
                    )

    # 画像の拡張
    train_generator = train_datagen.flow_from_directory(
        img_dir,
        batch_size=batch_size,
        target_size=STANDARD_SIZE)
    validation_generator = train_datagen.flow_from_directory(
        img_dir,
        batch_size=batch_size,
        target_size=STANDARD_SIZE)

    print(train_generator.class_indices)
    with open('.cnn_labels','w') as fw:
        json.dump(train_generator.class_indices,fw,indent=4)

```
ImageDataGenerator:画像生成します。各パラメータはkerasドキュメントを参照ください。
flow_from_directory:ジェネレータで生成する画像の具体的な指定をする感じです。ここでは、Validation用データとtrain用データを同じディレクトリにしちゃってますが、本来は別にしてください。（水増し加工で同じ画像はできないだろうと思って手を抜いちゃいました）
class_indices:分類するクラス名（ディレクトリ名）を返してくれる便利な機能です。

##学習の実施

```python:cnn_train.py
def get_args():
    parser = argparse.ArgumentParser()
    parser.add_argument("--model_path", type=str)
    parser.add_argument("--gpu", type=str, default='1')
    parser.add_argument("--idx", type=int, default=0)
    parser.add_argument("--batch_size", type=int, default=96)
    args = parser.parse_args()
    return args

if __name__ == '__main__':
    #パラメータ取得
    args = get_args()
    #GPU設定
    config = tf.ConfigProto(gpu_options=tf.GPUOptions(allow_growth=False,
                                                      visible_device_list=args.gpu
                                                      ))
    session = tf.Session(config=config)
    backend.set_session(session)

    GPUs = len(args.gpu.split(','))
    start_idx = args.idx
    batch_size = args.batch_size
    model_path = args.model_path

    # 同時実行プロセス数
    process_count = multiprocessing.cpu_count()

    STANDARD_SIZE = (299, 299)
    epochs = 100000
    path_list = []
    image_list = []
    label_list = []

    # モデルを読み込む
    model = cnn_model(train_generator.class_indices)
    model.summary()
    if os.path.exists(model_path):
        model.load_weights(model_path + 'w', by_name=False)

    def on_epoch_end(epoch, logs):
        model.save_weights(model_path + 'w')
        model.save(model_path)


    print_callback = LambdaCallback(on_epoch_end=on_epoch_end)
    ES = EarlyStopping(monitor='loss', min_delta=0.002, patience=2, verbose=0, mode='auto')

    model.compile(loss='categorical_crossentropy',
                  # optimizer=SGD(lr=1e-3, decay=1e-6, momentum=0.9, nesterov=True),
                  # optimizer=SGD(),
                  optimizer=Adam(),
                  # optimizer=Adam(lr=1e-5, beta_1=0.5),
                  metrics=['accuracy'])

    m = model
    if GPUs > 1:
        p_model = multi_gpu_model(model, gpus=GPUs)
        p_model.compile(loss='categorical_crossentropy',
                      optimizer=Adam(),
                      metrics=['accuracy'])
        m = p_model

    model.summary()
    m.fit_generator(
            train_generator,
            callbacks=[print_callback,ES],
            # steps_per_epoch=512,
            epochs=epochs,
            validation_data=validation_generator,
            validation_steps=15,
            initial_epoch=start_idx,
            max_queue_size=process_count,
            workers=3,
            use_multiprocessing=True)


```

on_epoch_end:１エポック終了後に自動的に実行される処理です。ここでは学習済みモデルの保存を行います。
fit_generator:先ほど作成したデータジェネレータを使って学習する機能です。これだけで自動でデータを順次生成しながら学習を進めてくれます。
multi_gpu_model:複数GPU使用する際に使用します。注意として、重み保存をする場合は元のモデル、学習する際はこの機能が返却するモデルを使用します。この機能が返却するモデルで重みの保存はできないようです。


#参考
以下にソースコード全体を載せておきます。

```python:cnn_model.py
import os,glob,sys
from tensorflow.python.keras.applications.xception import Xception
from tensorflow.python.keras.models import Sequential,Model
from tensorflow.python.keras.layers import Input, Dense, Dropout, Activation, Flatten
from tensorflow.python.keras.layers import GaussianNoise
from tensorflow.python.keras import backend as tensorflow_backend
from tensorflow.python.keras.layers import BatchNormalization

def cnn_model(labels):
    # include_top=Falseによって、モデルから全結合層を削除
    input_tensor = Input(shape=(299, 299, 3))
    xception_model = Xception(include_top=False, pooling='avg', input_tensor=input_tensor)
    # 全結合層の構築
    top_model = Sequential()
    top_model.add(GaussianNoise(stddev=0.2,input_shape=xception_model.output_shape[1:]))
    top_model.add(Dropout(0.3))
    top_model.add(Dense(len(labels),input_shape=xception_model.output_shape[1:]))
    top_model.add(Activation("softmax"))

    # 全結合層を削除したモデルと上で自前で構築した全結合層を結合
    model = Model(inputs=xception_model.input, outputs=top_model(xception_model.output))

    for layer in xception_model.layers[:-50]:
        layer.trainable = False
    return model
```

```python:cnn_train.py
from tensorflow.python.keras.preprocessing.image import ImageDataGenerator
from tensorflow.python.keras.optimizers import Adam
from tensorflow.python.keras.callbacks import LambdaCallback,EarlyStopping
from tensorflow.python.keras.utils import multi_gpu_model
import multiprocessing
import os,glob,sys,json
from cnn_model import cnn_model
import numpy as np
from PIL import Image
import argparse

import tensorflow as tf
from tensorflow.python.keras import backend

def get_args():
    parser = argparse.ArgumentParser()
    parser.add_argument("--model_path", type=str)
    parser.add_argument("--gpu", type=str, default='1')
    parser.add_argument("--idx", type=int, default=0)
    parser.add_argument("--batch_size", type=int, default=96)
    args = parser.parse_args()
    return args


if __name__ == '__main__':
    #パラメータ取得
    args = get_args()
    #GPU設定
    config = tf.ConfigProto(gpu_options=tf.GPUOptions(allow_growth=False,
                                                      visible_device_list=args.gpu
                                                      ))
    session = tf.Session(config=config)
    backend.set_session(session)

    GPUs = len(args.gpu.split(','))
    start_idx = args.idx
    batch_size = args.batch_size
    model_path = args.model_path

    # 同時実行プロセス数
    process_count = multiprocessing.cpu_count()

    STANDARD_SIZE = (299, 299)
    epochs = 100000
    path_list = []
    image_list = []
    label_list = []
    img_dir = 'images/'
    test_dir = 'test_images/'

    train_datagen = ImageDataGenerator(
                    rescale=1./255,
                    rotation_range=90, # 90°まで回転
                    width_shift_range=0.2, # 水平方向にランダムでシフト
                    height_shift_range=0.2, # 垂直方向にランダムでシフト
                    #channel_shift_range=50.0, # 色調をランダム変更
                    shear_range=0.2, # 斜め方向(pi/8まで)に引っ張る
                    horizontal_flip=True, # 垂直方向にランダムで反転
                    vertical_flip=True, # 水平方向にランダムで反転
                    zoom_range=0.2,
                    fill_mode='wrap'
                    )

    # 画像の拡張
    train_generator = train_datagen.flow_from_directory(
        img_dir,
        batch_size=batch_size,
        target_size=STANDARD_SIZE)
    validation_generator = train_datagen.flow_from_directory(
        img_dir,
        batch_size=batch_size,
        target_size=STANDARD_SIZE)

    print(train_generator.class_indices)
    with open('.cnn_labels','w') as fw:
        json.dump(train_generator.class_indices,fw,indent=4)

    # モデルを読み込む
    model = cnn_model(train_generator.class_indices)
    model.summary()
    if os.path.exists(model_path):
        model.load_weights(model_path + 'w', by_name=False)

    def on_epoch_end(epoch, logs):
        model.save_weights(model_path + 'w')
        model.save(model_path)


    print_callback = LambdaCallback(on_epoch_end=on_epoch_end)
    ES = EarlyStopping(monitor='loss', min_delta=0.002, patience=2, verbose=0, mode='auto')

    model.compile(loss='categorical_crossentropy',
                  # optimizer=SGD(lr=1e-3, decay=1e-6, momentum=0.9, nesterov=True),
                  # optimizer=SGD(),
                  optimizer=Adam(),
                  # optimizer=Adam(lr=1e-5, beta_1=0.5),
                  metrics=['accuracy'])

    m = model
    if GPUs > 1:
        p_model = multi_gpu_model(model, gpus=GPUs)
        p_model.compile(loss='categorical_crossentropy',
                      optimizer=Adam(),
                      metrics=['accuracy'])
        m = p_model


    model.summary()
    m.fit_generator(
            train_generator,
            callbacks=[print_callback,ES],
            # steps_per_epoch=512,
            epochs=epochs,
            validation_data=validation_generator,
            validation_steps=15,
            initial_epoch=start_idx,
            max_queue_size=process_count,
            workers=3,
            use_multiprocessing=True)

```