TensorFlowによるディープラーニングで、アイドルの顔を識別する - すぎゃーんメモ の続き。
前回は最も簡単に画像分類を試すために TensorFlow に同梱されているtensorflow.models.image.cifar10
パッケージのモデルや学習機構を利用して約75%の識別正答率の分類器を作ったが、それよりも良い結果を出したいし色々ためしてみたい、ということで今回は色々と自前で実装したり改良を加えてみた。
結論だけ先に書くと、約90%の正答率のものを作ることができた。分類数も変えてしまっているので一概には前回のものと比較できないけど。
入力画像の変更
まずは入力の画像について。
前回はCIFAR-10のデータセットに合わせて、検出して切り出した顔画像を32x32
サイズに縮小したものを利用していた。
32x32 → inside 96x96 of 112x112
流石に32x32
では小さすぎて人間が見てもなかなか区別できなかったりしたし、もうすこしハッキリと分かるくらいの画像サイズを入力に使えるようにしよう ということで各辺3倍サイズの96x96
画像を入力にすることにした(画素数で言うと9倍)。
そして、ある程度のスケーリング誤差も吸収できるようにと 顔画像収集時には検出された顔領域の1.2倍ほどの少し大きめの領域で切り出し112x112
サイズで取得し、そこから96〜112の間でランダムに切り出してさらに収縮させて最終的に96x96
サイズに収まるように、というのを後述のDistortionのところで行った。
6 → 5 Classification
切り出す領域を変更したので顔画像は収集し直してラベルも付け直した。収集方法は同じで、ももクロメンバー5人についてそれぞれ200点、計1000点を学習用のデータセットとして用いた。
前回は6番目のラベルとして「ももクロ以外」の人物の顔を学習・評価に使っていたが、どうにも種類が少なくて分類のラベルとして使うのに適しているとは思えなかったので除外することにした。
TFRecord file
CIFAR-10のバイナリデータの場合、各ピクセルについてのR, G, Bの値を1byteずつ使って表す形だったので1画像あたり32 * 32 * 3 = 3072
byteだったが、これが各辺3倍にするとデータサイズが9倍になってしまう。1000点集めると96 * 96 * 3 * 1000 = 27648000
byte(26.4MB)。
まぁ別にそれくらいならどうってことないのだけど、もう少し小さいサイズで済むならそれに越したことはない。
TensorFlowには"TFRecords"というバイナリデータ列も含めたシリアライズのファイル形式をサポートするReader & Writerがあり、固定長でない構造的なデータなども複数格納したりできる。
ので、ここに分類の正解ラベルの値とJPEG画像のバイナリデータ列をセットで入れてシリアライズして書き込むことで、112x112
サイズでも1画像あたり3~5KB程度でデータセットを作成できる。
使うときはtf.TFRecordReader
でTFRecord fileを読んでFeatureを取り出せばあとはJPEGのバイナリデータからtf.image.decode_jpeg
で画像に復元できる。
(tf.parse_single_example
あたりはtensorflow-0.6.0と最新コードでは引数などインタフェースが異なるので注意。最新masterのドキュメント読みながらコード書いてたら動かなくてハマった)
Distortion
読み込んでdecodeした画像を、学習データとしてさらにランダムに加工して使う。これはtensorflow.models.image.cifar10.distorted_inputs
でも使われている手法。
TensorFlowにはtf.image.random_crop
, tf.image.random_flip_left_right
, tf.image.random_brightness
, tf.image.random_contrast
などの画像加工系メソッドが用意されており、これらによる加工処理を入れることで明るさや色合いを変えたり反転・拡大縮小したりできるので、1つの顔画像からも異なる複数の画像を生成して学習に利用できる。
例えば
という具合に。
各random系メソッドでは加工の度合いの上限・下限を指定できたりするので、その幅を拡げてもっと極端にすると
のようになったりする。
どの程度までやるのが適切なのかは分からないけど 異常になりすぎない程度に抑えておいた。
Code
コードとしてはこんなかんじ。
def inputs(files, distort=False): fqueue = tf.train.string_input_producer(files) reader = tf.TFRecordReader() key, value = reader.read(fqueue) features = tf.parse_single_example(value, features={ 'label': tf.FixedLenFeature([], tf.int64), 'image_raw': tf.FixedLenFeature([], tf.string), }) image = tf.image.decode_jpeg(features['image_raw'], channels=3) image = tf.cast(image, tf.float32) image.set_shape([IMAGE_SIZE, IMAGE_SIZE, 3]) if distort: cropsize = random.randint(INPUT_SIZE, INPUT_SIZE + (IMAGE_SIZE - INPUT_SIZE) / 2) framesize = INPUT_SIZE + (cropsize - INPUT_SIZE) * 2 image = tf.image.resize_image_with_crop_or_pad(image, framesize, framesize) image = tf.image.random_crop(image, [cropsize, cropsize]) image = tf.image.random_flip_left_right(image) image = tf.image.random_brightness(image, max_delta=0.4) image = tf.image.random_contrast(image, lower=0.6, upper=1.4) image = tf.image.random_hue(image, max_delta=0.04) image = tf.image.random_saturation(image, lower=0.6, upper=1.4) else: image = tf.image.resize_image_with_crop_or_pad(image, INPUT_SIZE, INPUT_SIZE) min_fraction_of_examples_in_queue = 0.4 min_queue_examples = int(FLAGS.num_examples_per_epoch_for_train * min_fraction_of_examples_in_queue) images, labels = tf.train.shuffle_batch( [tf.image.per_image_whitening(image), tf.cast(features['label'], tf.int32)], batch_size=BATCH_SIZE, capacity=min_queue_examples + 3 * BATCH_SIZE, min_after_dequeue=min_queue_examples ) images = tf.image.resize_images(images, INPUT_SIZE, INPUT_SIZE) tf.image_summary('images', images) return images, labels
Inference
分類推定のモデルは、 VGGNet と呼ばれる画像分類のための畳み込みネットワークを参考に、独自に定義して作った。
x2 conv layers
VGGNetは「3x3でのconvolutionと2x2でのmax pooling」の組み合わせを複数(5回?)繰り返した後に 3層の全結合で最終的な出力を得ている。これを真似して、cifar10のときには2回だった畳み込み&プーリングを4回行なうようにして
96 * 96 * 3
→48 * 48 * 32
の畳み込み&プーリング層48 * 48 * 32
→24 * 24 * 64
の畳み込み&プーリング層24 * 24 * 64
→12 * 12 * 128
の畳み込み&プーリング層12 * 12 * 128
→6 * 6 * 256
の畳み込み&プーリング層9216(= 6 * 6 * 256) * 1024
の全結合層1024 * 256
の全結合隠れ層256 * 5
の全結合出力層
とした。畳み込みのときの出力channel数や中間層の数など、どれくらいに設定するのが適切なのかはよく分からないので適当に。
パラメータ数としてはweightだけで計算すると
(3 * 3 * 3 * 32) + (3 * 3 * 32 * 64) + (3 * 3 * 64 * 128) + (3 * 3 * 128 * 256) + (9216 * 1024) + (1024 * 256) + (256 * 5) = 10088544
くらい。cifar10のものでは
(5 * 5 * 3 * 64) + (5 * 5 * 64 * 64) + (4096 * 384) + (384 * 192) + (192 * 6) = 1754944
だったので5.7倍くらいには増えている。
Code
コードとしてはこんなかんじ。
def inference(images): def _variable_with_weight_decay(name, shape, stddev, wd): var = tf.get_variable(name, shape=shape, initializer=tf.truncated_normal_initializer(stddev=stddev)) if wd: weight_decay = tf.mul(tf.nn.l2_loss(var), wd, name='weight_loss') tf.add_to_collection('losses', weight_decay) return var def _activation_summary(x): tensor_name = x.op.name tf.scalar_summary(tensor_name + '/sparsity', tf.nn.zero_fraction(x)) with tf.variable_scope('conv1') as scope: kernel = tf.get_variable('weights', shape=[3, 3, 3, 32], initializer=tf.truncated_normal_initializer(stddev=0.1)) conv = tf.nn.conv2d(images, kernel, [1, 1, 1, 1], padding='SAME') biases = tf.get_variable('biases', shape=[32], initializer=tf.constant_initializer(0.0)) bias = tf.nn.bias_add(conv, biases) conv1 = tf.nn.relu(bias, name=scope.name) _activation_summary(conv1) pool1 = tf.nn.max_pool(conv1, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool1') with tf.variable_scope('conv2') as scope: kernel = tf.get_variable('weights', shape=[3, 3, 32, 64], initializer=tf.truncated_normal_initializer(stddev=0.1)) conv = tf.nn.conv2d(pool1, kernel, [1, 1, 1, 1], padding='SAME') biases = tf.get_variable('biases', shape=[64], initializer=tf.constant_initializer(0.0)) bias = tf.nn.bias_add(conv, biases) conv2 = tf.nn.relu(bias, name=scope.name) _activation_summary(conv2) pool2 = tf.nn.max_pool(conv2, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool2') with tf.variable_scope('conv3') as scope: kernel = tf.get_variable('weights', shape=[3, 3, 64, 128], initializer=tf.truncated_normal_initializer(stddev=0.1)) conv = tf.nn.conv2d(pool2, kernel, [1, 1, 1, 1], padding='SAME') biases = tf.get_variable('biases', shape=[128], initializer=tf.constant_initializer(0.0)) bias = tf.nn.bias_add(conv, biases) conv3 = tf.nn.relu(bias, name=scope.name) _activation_summary(conv3) pool3 = tf.nn.max_pool(conv3, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool3') with tf.variable_scope('conv4') as scope: kernel = tf.get_variable('weights', shape=[3, 3, 128, 256], initializer=tf.truncated_normal_initializer(stddev=0.1)) conv = tf.nn.conv2d(pool3, kernel, [1, 1, 1, 1], padding='SAME') biases = tf.get_variable('biases', shape=[256], initializer=tf.constant_initializer(0.0)) bias = tf.nn.bias_add(conv, biases) conv4 = tf.nn.relu(bias, name=scope.name) _activation_summary(conv4) pool4 = tf.nn.max_pool(conv4, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool4') with tf.variable_scope('fc5') as scope: dim = 1 for d in pool4.get_shape()[1:].as_list(): dim *= d reshape = tf.reshape(pool4, [BATCH_SIZE, dim]) weights = _variable_with_weight_decay('weights', shape=[dim, 1024], stddev=0.02, wd=0.005) biases = tf.get_variable('biases', shape=[1024], initializer=tf.constant_initializer(0.0)) fc5 = tf.nn.relu(tf.nn.bias_add(tf.matmul(reshape, weights), biases), name=scope.name) _activation_summary(fc5) with tf.variable_scope('fc6') as scope: weights = _variable_with_weight_decay('weights', shape=[1024, 256], stddev=0.02, wd=0.005) biases = tf.get_variable('biases', shape=[256], initializer=tf.constant_initializer(0.0)) fc6 = tf.nn.relu(tf.nn.bias_add(tf.matmul(fc5, weights), biases), name=scope.name) _activation_summary(fc6) with tf.variable_scope('fc7') as scope: weights = tf.get_variable('weights', shape=[256, NUM_CLASSES], initializer=tf.truncated_normal_initializer(stddev=0.02)) biases = tf.get_variable('biases', shape=[NUM_CLASSES], initializer=tf.constant_initializer(0.0)) fc7 = tf.nn.bias_add(tf.matmul(fc6, weights), biases, name=scope.name) _activation_summary(fc7) return fc7
Loss
損失関数はcifar10のものと同様で、入力画像に対する出力と正解ラベルとのクロスエントロピー、そこに汎化性能向上のための正則化手法として(?)全結合層の最初と中間層のパラメータに適当な割合でweight decayを入れて それらを合計したものを最小化対象のtotal lossとしている。
Code
def loss(logits, labels): sparse_labels = tf.reshape(labels, [BATCH_SIZE, 1]) indices = tf.reshape(tf.range(BATCH_SIZE), [BATCH_SIZE, 1]) concated = tf.concat(1, [indices, sparse_labels]) dense_labels = tf.sparse_to_dense(concated, [BATCH_SIZE, NUM_CLASSES], 1.0, 0.0) cross_entropy = tf.nn.softmax_cross_entropy_with_logits(logits, dense_labels) mean = tf.reduce_mean(cross_entropy, name='cross_entropy') tf.add_to_collection('losses', mean) return tf.add_n(tf.get_collection('losses'), name='total_loss')
Train
AdamOptimizer
学習にはGradientDescentOptimizer
ではなくAdamOptimizer
というのを使ってみた。
ちゃんと比較はしていないのだけど、GradientDescentOptimizerよりも格段に早く(少ないstepで)lossが減少するように学習が進んでいるのは観測した。Learning Rateを減衰させて調整する、といったこともここでは行っていない。
ただ1500stepくらいまで行くとそれ以降1stepあたりの計算時間が4〜5倍かかるようになったのだけど これはAdamOptimizerのせいなのかな…?よく分かってない
Code
def train(total_loss, global_step): loss_averages = tf.train.ExponentialMovingAverage(0.9, name='avg') losses = tf.get_collection('losses') loss_averages_op = loss_averages.apply(losses + [total_loss]) for l in losses + [total_loss]: tf.scalar_summary(l.op.name + ' (raw)', l) # Apply gradients, and add histograms with tf.control_dependencies([loss_averages_op]): opt = tf.train.AdamOptimizer() grads = opt.compute_gradients(total_loss) apply_gradient_op = opt.apply_gradients(grads, global_step=global_step) for var in tf.trainable_variables(): tf.histogram_summary(var.op.name, var) for grad, var in grads: if grad: tf.histogram_summary(var.op.name + '/gradients', grad) # Track the moving averages of all trainable variables variable_averages = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY, global_step) variables_averages_op = variable_averages.apply(tf.trainable_variables()) with tf.control_dependencies([apply_gradient_op, variables_averages_op]): train_op = tf.no_op(name='train') return train_op
Graph
最終的に出来た全体像のグラフはこんなかんじ。
潰れてしまって全然読めないと思うけど、下の方からTFRecordsのファイルを読み込んで加工した画像とラベルのbatchを作って 真ん中の分岐で左側では畳み込みニューラルネットワークに通して出力を計算して 右側を通るラベルと結果を突き合わせてクロスエントロピーを出して損失を計算したりしている、っていう感じになってるはず。
Results
学習結果はこのように、数百〜千step程度でじゅうぶんに0に近い値までlossが減少した。
最初 いくら学習を繰り返しても全然cross entropyが減少しなくて、なんでだ!?と思ったら序盤の畳み込み層の初期値(の幅、具体的にはtf.truncated_normal_initializer
に渡すstddev
)が小さすぎて途中の出力がすべて同一の値になってしまっていたのが原因だったようで そこを適切に設定し直すことで上記のように上手く学習が進むようになった。
各段階で保存した変数を使って「学習に使っていない」データを使って評価した結果が以下。
1600stepくらいのところでようやく90%ラインに到達して、あとはそこから上がらず まぁ誤差の範囲内かな くらいに。
実際に試すWebアプリも少しアップデートして結果の表示方法をちょっと変えた。冒頭に載せたやつは上手くいった例。
https://momoclo-face-recognizer.herokuapp.com/
↓最新アルバムのジャケ写。百田さんが「有安」になってしまっている
↓だいぶ昔のアー写。百田さんが「高城」に。
↓比較的最近のアー写。玉井さんが「有安」に。
…という具合に集合写真だとやっぱり5人中1人くらいは間違う感覚。
考察
問題設定を変えてしまったので比較しづらくなってしまったのだけど… 結局6クラス分類が75%に上がるのと5クラス分類が90%に上がるのはあまり変わらない気はする。。
けどまぁ少なくとも悪化はしていないはず。あとはやっぱり学習データ数かな、と。
なんとかもっとラクに大量の学習データを用意する方法は無いだろうか…