続・深層学習でアニメ顔を分類する with Keras

# はじめに
だいたい1年くらい前に[Chainerで書いた](http://qiita.com/hogefugabar/items/312707a09d29632e7288)アニメ顔を分類するプログラムですが，今回はKerasで書きました．プログラムは[GitHub](https://github.com/hogefugabar/cnn-animeface-keras)にあげました．

# データセット
データセットは[animeface-character-dataset](http://www.nurs.or.jp/%7Enagadomi/animeface-character-dataset/)から入手することができます．
[参考:DenoisingAutoEncoderでアニメ顔の特徴を抽出してみた](http://nonbiri-tereka.hatenablog.com/entry/2015/06/30/222616)

## データセット前処理
前より少しだけ改良しました．32×32のRGBのデータ(shape=(3, 32, 32))にリサイズします．前回と違うのは，データの入っていない空のフォルダを削除しなくても多分ちゃんと動くところです．
Requirementsは

- six
- numpy
- opencv
- progressbar2

となっています．


```py
#! -*- coding: utf-8 -*-

import os
import six.moves.cPickle as pickle
import numpy as np
try:
	import cv2 as cv
except:
	pass
from progressbar import ProgressBar

class AnimeFaceDataset:
	def __init__(self):
		self.data_dir_path = u"./animeface-character-dataset/thumb/"
		self.data = None
		self.target = None
		self.n_types_target = -1
		self.dump_name = u'animedata'
		self.image_size = 32

	def get_dir_list(self):
		tmp = os.listdir(self.data_dir_path)
		if tmp is None:
			return None
		ret = []
		for x in tmp:
			if os.path.isdir(self.data_dir_path+x):
				if len(os.listdir(self.data_dir_path+x)) >= 2:
					ret.append(x)
		return sorted(ret)

	def get_class_id(self, fname):
		dir_list = self.get_dir_list()
		dir_name = filter(lambda x: x in fname, dir_list)
		return dir_list.index(dir_name[0])

	def get_class_name(self, id):
		dir_list = self.get_dir_list()
		return dir_list[id]

	def load_data_target(self):
		if os.path.exists(self.dump_name+".pkl"):
			print "load from pickle"
			self.load_dataset()
			print "done"
		else:
			dir_list = self.get_dir_list()
			ret = {}
			self.target = []
			self.data = []
			print("now loading...")
			pb = ProgressBar(min_value=0, max_value=len(dir_list)).start()
			for i, dir_name in enumerate(dir_list):
				pb.update(i)
				file_list = os.listdir(self.data_dir_path+dir_name)
				for file_name in file_list:
					root, ext = os.path.splitext(file_name)
					if ext == u'.png':
						abs_name = self.data_dir_path+dir_name+'/'+file_name
						# read class id i.e., target
						class_id = self.get_class_id(abs_name)
						self.target.append(class_id)
						# read image i.e., data
						image = cv.imread(abs_name)
						image = cv.resize(image, (self.image_size, self.image_size))
						image = image.transpose(2,0,1)
						image = image/255.
						self.data.append(image)
			pb.finish()
			print("done.")
			self.data = np.array(self.data, np.float32)
			self.target = np.array(self.target, np.int32)

			self.dump_dataset()

	def dump_dataset(self):
		pickle.dump((self.data,self.target), open(self.dump_name+".pkl", 'wb'), -1)

	def load_dataset(self):
		self.data, self.target = pickle.load(open(self.dump_name+".pkl", 'rb'))


if __name__ == '__main__':
	dataset = AnimeFaceDataset()
	dataset.load_data_target()
```

実際に読み込んでみると

```py
In [1]: from animeface import AnimeFaceDataset

In [2]: dataset = AnimeFaceDataset()

In [3]: dataset.load_data_target()
load from pickle
done

In [4]: x = dataset.data

In [5]: y = dataset.target

In [6]: print x.shape, y.shape
(14490, 3, 32, 32) (14490,)
```

となり，データ数14490，クラス数(キャラクター数)176の分類問題となります．(ここまではほとんど前回と同じです．)


# KerasによるConvolutional Neural Networksの実装
## モデルの構築

```py
from keras.layers.convolutional import Convolution2D
from keras.layers.convolutional import MaxPooling2D
from keras.layers.core import Activation
from keras.layers.core import Dense
from keras.layers.core import Dropout
from keras.layers.core import Flatten
from keras.models import Sequential

def build_deep_cnn(num_classes=3):
	model = Sequential()

	model.add(Convolution2D(96, 3, 3, border_mode='same', input_shape=(3, 32, 32)))
	model.add(Activation('relu'))

	model.add(Convolution2D(128, 3, 3))
	model.add(Activation('relu'))
	model.add(MaxPooling2D(pool_size=(2, 2)))
	model.add(Dropout(0.5))

	model.add(Convolution2D(256, 3, 3, border_mode='same'))
	model.add(Activation('relu'))

	model.add(Convolution2D(256, 3, 3))
	model.add(Activation('relu'))
	model.add(MaxPooling2D(pool_size=(2, 2)))
	model.add(Dropout(0.5))

	model.add(Flatten())
	model.add(Dense(1024))
	model.add(Activation('relu'))
	model.add(Dropout(0.5))

	model.add(Dense(num_classes))
	model.add(Activation('softmax'))
	
	return model
```

最初に`model = Sequential()`でシーケンシャルモデルを生成して，それに`Convolutional2D`や`Dense`を追加していくだけでネットワークを構築できます．
`Convolutional2D`が畳み込み層，`Dense`が全結合層に相当する感じです．
一番最初の，

```py
Convolution2D(96, 3, 3, border_mode='same', input_shape=(3, 32, 32))
```
だけは`input_shape`を指定する必要があります．  
`Convolutional2D`について簡単に説明すると，最初の引数が，畳み込みカーネルの枚数，第2，3引数が畳み込みカーネル大きさを指定するものです．`border_mode`には`same`と`valid`の2種類がありますが，`same`はpaddingがカーネルの大きさの半分，つまり出力の縦横の大きさは入力の縦横の大きさは変わりません．`valid`はpaddingがない状態，つまり出力の縦横の大きさは入力の縦横の大きさよりも小さくなります．paddingについは[こちら](https://github.com/vdumoulin/conv_arithmetic)がわかりやすいです．今回は`same`で畳み込みカーネルの`shape`が`(96, 3, 3)`，入力の`shape`が`(3, 32, 32)`のためこの層の出力の`shape`は`(96, 32, 32)`となります．`valid`の場合の出力の`shape`は`(96, 32-(3-1), 32-(3-1))=(96, 30, 30)`となります．その他に`stride`なども設定することができます．



## モデルの学習

```py
from keras.callbacks import EarlyStopping
from keras.callbacks import LearningRateScheduler
from keras.optimizers import Adam
from keras.optimizers import SGD
from animeface import AnimeFaceDataset

class Schedule(object):
	def __init__(self, init=0.01):
		self.init = init
	def __call__(self, epoch):
		lr = self.init
		for i in xrange(1, epoch+1):
			if i%5==0:
				lr *= 0.5
		return lr

def get_schedule_func(init):
	return Schedule(init)

dataset = AnimeFaceDataset()
dataset.load_data_target()
x = dataset.data
y = dataset.target
n_class = len(set(y))
perm = np.random.permutation(len(y))
x = x[perm]
y = y[perm]

model = build_deep_cnn(n_class)
model.summary()
init_learning_rate = 1e-2
opt = SGD(lr=init_learning_rate, decay=0.0, momentum=0.9, nesterov=False)
model.compile(loss='sparse_categorical_crossentropy', optimizer=opt, metrics=["acc"])
early_stopping = EarlyStopping(monitor='val_loss', patience=3, verbose=0, mode='auto')
lrs = LearningRateScheduler(get_schedule_func(init_learning_rate))

hist = model.fit(x, y, 
				batch_size=128, 
				nb_epoch=50, 
				validation_split=0.1, 
				verbose=1, 
				callbacks=[early_stopping, lrs])
```

学習する`fit`関数の引数の`callbacks`には，収束判定したら自動的に学習を終了する`EarlyStopping`や，`epoch`毎に学習率を調整することができる`LearningRateScheduler`などを指定することができ，便利です．  

簡単に`LearningRateScheduler`について説明すると，「引数に現在の`epoch`数を(0はじまり)を与えた時に学習率を返してくれる関数」を引数に取ります．例えば，

```py
class Schedule(object):
    def __init__(self, init=0.01):
        self.init = init
    def __call__(self, epoch):
        lr = self.init
        for i in xrange(1, epoch+1):
            if i%5==0:
                lr *= 0.5
        return lr

def get_schedule_func(init):
    return Schedule(init)

lrs = LearningRateScheduler(get_schedule_fun(0.01))
```

のようにやると，初期学習率0.01で5epoch毎に学習率が半減する，といった感じになります．  

また，`fit`の返り値のオブジェクトの`.history`には辞書型で各epoch毎の誤差関数等を保存してくれます．PandasとMatplotlibを使うと便利に可視化することができます．

```py
import pandas as pd
import matplotlib.pyplot as plt
plt.style.use("ggplot")

df = pd.DataFrame(hist.history)
df.index += 1
df.index.name = "epoch"
df[["acc", "val_acc"]].plot(linewidth=2)
plt.savefig("acc_history.pdf")
df[["loss", "val_loss"]].plot(linewidth=2)
plt.savefig("loss_history.pdf")
```

# 実験結果
結果は以下の通りとなり，検証用の正解率は約6割弱となりました．  
実は最適化手法にAdamを使うと検証用の正解率は7割を超えるのでやってみてください．

## 誤差関数の推移
<img width="480" alt="スクリーンショット 2016-06-12 14.40.39.png" src="https://qiita-image-store.s3.amazonaws.com/0/31899/2156aabb-56ed-155a-63f2-2c1999ee9252.png">

## 正解率の推移
<img width="480" alt="スクリーンショット 2016-06-12 14.40.15.png" src="https://qiita-image-store.s3.amazonaws.com/0/31899/e97f347f-a7cd-d27a-8903-816327f1deec.png">