移行すると彼女できて結婚できて腰痛もコミュ障も治って年収が2桁増えて人生なんでも上手くいくようになる、ときいて。
@sugyan はてブロに移行したら彼女できて結婚できたって話をよく聞くけどな
— 福利厚生 (@ryopeko) June 8, 2016
移行すると彼女できて結婚できて腰痛もコミュ障も治って年収が2桁増えて人生なんでも上手くいくようになる、ときいて。
@sugyan はてブロに移行したら彼女できて結婚できたって話をよく聞くけどな
— 福利厚生 (@ryopeko) June 8, 2016
アイドル顔識別のためのデータ収集 をコツコツ続けて それなりに集まってきたし、これを使って別のことも…ということでDCGANを使ったDeep Learningによるアイドルの顔画像の「生成」をやってみた。
まだだいぶ歪んでいたりで あまりキレイじゃないけど…。顔画像を多く収集できているアイドル90人の顔画像からそれぞれ120件を抽出した合計10800件をもとに学習させて生成させたもの。
分類タスクとは逆方向の変換、複数のモデル定義などがあってなかなか理解が難しい部分もあったけど、作ってみるとそこまで難しくはなく、出来上がっていく過程を見るのが楽しいし とても面白い。
"Deep Convolutional Generative Adversarial Networks"、略してDCGAN。こちらの論文で有名になった、のかな?
あとは応用事例として日本語の記事では以下のものがとても詳しいのでそれを読めば十分かな、と。
一応あらためて書いておくと。
顔識別のような分類タスクは
といった分類器を作って学習させるだけだが、DCGANではそういった分類器を"Discriminator"として使い、それと別に"Generator"というモデルを構築し使用する。Generatorの役割は
というものであり、この出力が最終的な「機械学習による画像生成」の成果物となる。
原理としては、
これを繰り返してお互いに精度を上げることで、ランダム入力から学習データそっくりの画像を生成できるようになる、というもの。
(http://qiita.com/mattya/items/e5bfe5e04b9d2f0bbd47 より引用)
言葉にしてみるとまぁなるほど、とは思うけどそんな上手く双方を学習できるのか、という感じではある。そのへんをBatch Normalizationを入れたりLeaky ReLUを使ったりして上手くいくようになったよ、というのが上記の論文のお話のようだ。
先行のDCGAN実装例は既に結構ある。
TensorFlowによる実装も既にあったので、それを参考にしつつも自分で書いてみた。
乱数ベクトルから画像を生成するモデルは下図のようになる。
(arXiv:1511.06434より引用)
分類器などで使っている畳み込みの逆方向の操作で、最初は小さな多数のfeature mapにreshapeして、これを徐々に小数の大きなものにしていく。"deconvolution"と呼んだり呼ばなかったり、なのかな。TensorFlowではこの操作はtf.nn.conv2d_transpose
という関数で実現するようだ。
各層間の変換でW(weights)を掛けてB(biases)を加え、このWとBの学習により最終的な出力画像を変化させていくことになる。あと論文にある通りReLUにかける前にBatch Normalizationという処理をする。これはTensorFlow 0.8.0からtf.nn.batch_normalization
が登場しているのかな?ここにtf.nn.moments
で得るmeanとvarianceを渡してあげれば良さそう。
ということでこんな感じのコードで作った。
def model(): depths = [1024, 512, 256, 128, 3] i_depth = depths[0:4] o_depth = depths[1:5] with tf.variable_scope('g'): inputs = tf.random_uniform([self.batch_size, self.z_dim], minval=-1.0, maxval=1.0) # reshape from inputs with tf.variable_scope('reshape'): w0 = tf.get_variable('weights', [self.z_dim, i_depth[0] * self.f_size * self.f_size], tf.float32, tf.truncated_normal_initializer(stddev=0.02)) b0 = tf.get_variable('biases', [i_depth[0]], tf.float32, tf.zeros_initializer) dc0 = tf.nn.bias_add(tf.reshape(tf.matmul(inputs, w0), [-1, self.f_size, self.f_size, i_depth[0]]), b0) mean0, variance0 = tf.nn.moments(dc0, [0, 1, 2]) bn0 = tf.nn.batch_normalization(dc0, mean0, variance0, None, None, 1e-5) out = tf.nn.relu(bn0) # deconvolution layers for i in range(4): with tf.variable_scope('conv%d' % (i + 1)): w = tf.get_variable('weights', [5, 5, o_depth[i], i_depth[i]], tf.float32, tf.truncated_normal_initializer(stddev=0.02)) b = tf.get_variable('biases', [o_depth[i]], tf.float32, tf.zeros_initializer) dc = tf.nn.conv2d_transpose(out, w, [self.batch_size, self.f_size * 2 ** (i + 1), self.f_size * 2 ** (i + 1), o_depth[i]], [1, 2, 2, 1]) out = tf.nn.bias_add(dc, b) if i < 3: mean, variance = tf.nn.moments(out, [0, 1, 2]) out = tf.nn.relu(tf.nn.batch_normalization(out, mean, variance, None, None, 1e-5)) return tf.nn.tanh(out)
入力は乱数なのでtf.random_uniform
を使えば毎回ランダムな入力から作ってくれる。逆畳み込みはchannel数が変わるだけなのでfor loopで繰り返すだけで定義できる。最後の出力にはBatch Normalizationをかけずにtf.nn.tanh
で -1.0〜1.0 の範囲の出力にする。
こちらは以前までやっていた分類器とほぼ同じで、画像入力から畳み込みを繰り返して小さなfeature mapに落とし込んでいく。最後は全結合するけど、隠れ層は要らないらしい。出力は、既存のTensorFlow実装などでは1次元にしてsigmoidの出力を使うことで「0に近いか 1に近いか」を判定にしていたようだけど、自分はsigmoidを通さない2次元の出力にして、「0番目が大きな出力になるか 1番目が大きくなるか」で分類するようにした(誤差関数については後述)。
また各層の出力にはLeaky ReLUを使うとのことで、これに該当する関数はTensorFlowには無いようだったけど、tf.maximum(alpha * x, x)
がそれに該当するということで それを使った。
また、Discriminatorは「学習用データ」と「Generatorによって生成されたもの」の2つの入力を通すことになるのでフローが2回繰り返されることになる。けどこれは同じモデルに対して入出力を行う、つまり同じ変数を使い回す必要がある。こういうときはtf.variable_scope
でreuse=True
を指定すると2回目以降で同じ変数が重複定義されないようになるらしい。いちおう、初回の呼び出しか否かを使う側が意識する必要がないようPython3のnonlocalを使ってクロージャ的な感じで書いてみた。
ということでこんなコード。
def __discriminator(self, depth1=64, depth2=128, depth3=256, depth4=512): reuse = False def model(inputs): nonlocal reuse depths = [3, depth1, depth2, depth3, depth4] i_depth = depths[0:4] o_depth = depths[1:5] with tf.variable_scope('d', reuse=reuse): outputs = inputs # convolution layer for i in range(4): with tf.variable_scope('conv%d' % i): w = tf.get_variable('weights', [5, 5, i_depth[i], o_depth[i]], tf.float32, tf.truncated_normal_initializer(stddev=0.02)) b = tf.get_variable('biases', [o_depth[i]], tf.float32, tf.zeros_initializer) c = tf.nn.bias_add(tf.nn.conv2d(outputs, w, [1, 2, 2, 1], padding='SAME'), b) mean, variance = tf.nn.moments(c, [0, 1, 2]) bn = tf.nn.batch_normalization(c, mean, variance, None, None, 1e-5) outputs = tf.maximum(0.2 * bn, bn) # reshepe and fully connect to 2 classes with tf.variable_scope('classify'): dim = 1 for d in outputs.get_shape()[1:].as_list(): dim *= d w = tf.get_variable('weights', [dim, 2], tf.float32, tf.truncated_normal_initializer(stddev=0.02)) b = tf.get_variable('biases', [2], tf.float32, tf.zeros_initializer) reuse = True return tf.nn.bias_add(tf.matmul(tf.reshape(outputs, [-1, dim]), w), b) return model
Discriminatorには[batch size, height, width, channel]
の入力を与える前提で作っていて、学習用の画像データはその形のmini batchが作れれば良い。以前から 顔画像データはTFRecordsのファイル形式で作っていて それを読み取ってBatchにする処理は書いているので、それをほぼそのまま利用できる。
def inputs(batch_size, f_size): files = [os.path.join(FLAGS.data_dir, f) for f in os.listdir(FLAGS.data_dir) if f.endswith('.tfrecords')] fqueue = tf.train.string_input_producer(files) reader = tf.TFRecordReader() _, value = reader.read(fqueue) features = tf.parse_single_example(value, features={'image_raw': tf.FixedLenFeature([], tf.string)}) image = tf.cast(tf.image.decode_jpeg(features['image_raw'], channels=3), tf.float32) image.set_shape([INPUT_IMAGE_SIZE, INPUT_IMAGE_SIZE, 3]) image = tf.image.random_flip_left_right(image) min_queue_examples = FLAGS.num_examples_per_epoch_for_train images = tf.train.shuffle_batch( [image], batch_size=batch_size, capacity=min_queue_examples + 3 * batch_size, min_after_dequeue=min_queue_examples) return tf.sub(tf.div(tf.image.resize_images(images, f_size * 2 ** 4, f_size * 2 ** 4), 127.5), 1.0)
元々は分類タスクの教師データなのでlabel_id
とセットになっているデータセットだけど、ここではJPEGバイナリ部分だけ取り出して使うことになる。distort系の処理はとりあえずほぼ無しで、random_flip_left_right
(ランダム左右反転)だけ入れている。あと分類タスクでは最後にtf.image.per_image_whitening
を入れていたけど、これをやると元の画像に戻せなくなってしまうと思ったので 単純に 0〜255 の値を -1.0〜1.0 の値になるよう割って引くだけにしている。
で、GeneratorとDiscriminatorが出来たらあとは学習の手続き。それぞれに対して最小化すべき誤差(loss)を定義して、Optimizerに渡す。前述した「Discriminatorは正しく判定できるよう学習させ、GeneratorはDiscriminatorを欺いて誤判定させる画像を生成するよう学習する」というのをコードに落とし込む。
Discriminatorによる分類を「0
なら画像はGeneratorによるもの、1
なら学習データのもの」と判定する関数D(x)
と定義し、Generatorから生成した画像をG()
、学習データの画像をI
とすると
D(G())
がすべて1
になるのが理想D(G())
をすべて0
にし D(I)
をすべて1
にするのが理想なので、そのギャップをlossとして定義することになる。Discriminatorのような排他的な唯一の分類クラスを決める場合の誤差にはtf.nn.sparse_softmax_cross_entropy_with_logits
を使うのが良いらしい。
ということでこんな感じのコード。
def train(self, input_images): logits_from_g = self.d(self.g()) logits_from_i = self.d(input_images) tf.add_to_collection('g_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_g, tf.ones([self.batch_size], dtype=tf.int64)))) tf.add_to_collection('d_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_i, tf.ones([self.batch_size], dtype=tf.int64)))) tf.add_to_collection('d_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_g, tf.zeros([self.batch_size], dtype=tf.int64)))) g_loss = tf.add_n(tf.get_collection('g_losses'), name='total_g_loss') d_loss = tf.add_n(tf.get_collection('d_losses'), name='total_d_loss') g_vars = [v for v in tf.trainable_variables() if v.name.startswith('g')] d_vars = [v for v in tf.trainable_variables() if v.name.startswith('d')] g_optimizer = tf.train.AdamOptimizer(learning_rate=0.0001, beta1=0.5).minimize(g_loss, var_list=g_vars) d_optimizer = tf.train.AdamOptimizer(learning_rate=0.0001, beta1=0.5).minimize(d_loss, var_list=d_vars) with tf.control_dependencies([g_optimizer, d_optimizer]): train_op = tf.no_op(name='train') return train_op, g_loss, d_loss
論文によるとAdamOptimizer
のパラメータはデフォルト値ではなくlearning_rate
は0.0002
、beta1
は0.5
を使うとのことだったけれど、Qiitaでの先行事例ではlearning_rate
はさらに半分の0.0001
としていて 実際大きすぎると最初の段階で失敗してしまうことがあったので0.0001
にしておいた。
あと効くかどうか分からないけど一応すべてのweights
に 分類タスク で使っていたWeight Decayを入れておいた。
こうして学習のopsも定義できたらあとはそれを実行して繰り返していれば少しずつ「ランダムな出力」から「顔らしい画像」になっていく。はず。
で、その成果物を確かめたいのでやっぱり画像ファイルとして書き出したいわけで。Generatorからの出力を取得して変換かけて、scipy
やpylab
などを使って画像として出力できるみたいだけど、そのあたりも実はTensorFlowだけで出来るんですね。
Generatorからの出力は[batch size, height, width, channel]
の、 -1.0〜1.0 の値をとるTensorなので、まずはそれらをすべて 0〜255 の整数値に変換する。
で、それをbatch sizeにsplitしてやると、それぞれ[height, width, channel]
な画像データになるわけで。これらはtf.image.encode_png
とかにかければPNGのバイナリが得られる。
せっかくなので複数出力された画像をタイル状に並べて1つの画像として出力させたいじゃん、って思ったらtf.concat
で縦に繋げたり横に繋げたりを事前に入れておくことでそれも実現できる。
def generate_images(self, row=8, col=8): images = tf.cast(tf.mul(tf.add(self.g(), 1.0), 127.5), tf.uint8) images = [tf.squeeze(image, [0]) for image in tf.split(0, self.batch_size, images)] rows = [] for i in range(row): rows.append(tf.concat(1, images[col * i + 0:col * i + col])) image = tf.concat(0, rows) return tf.image.encode_png(image)
これで、得られたopsをevalして得たバイナリをファイルに書き出すだけで1つのbatchで生成された複数の画像出力を並べたものを一発で得ることができる。便利〜。
今回は64x64
でなく96x96
の画像を生成させようとしていて(学習データが112x112
で収集しているし 折角ならそれなりに大きく作りたい!)、元の論文では各層のchannel数が 1024, 512, 256, 128 になっていて(Qiitaの記事ではすべて半分にしていた)、そのパラメータ数で手元のCPUマシンで計算させる(僕はケチなのでGPUマシンとか持ってない…)と、 1step に 50sec とかとんでもなく時間がかかってしまい ちょっと絶望的だわ…と思い 少しでも計算量が減るよう 250, 150, 90, 54 という数字に変えた(50sec -> 18sec)。そしてbatch sizeも 128 から、半分の 64 だと流石に無理そうだったので 96 に(18sec -> 13sec)。一応このパラメータ数で200stepほど回してみたところ ちゃんとランダム出力から顔っぽいものに変わっていっているのが観測できたので これでやってみた。
少しずつ顔っぽいものが現れてきて、それぞれが個性ある顔に 鮮明に写るようになっていく変化がみてとれるかと。
こうして丸2日以上かけて、7000stepくらい回した結果 得たのが冒頭の画像になります。まだちゃんとした顔にならなかったりするのは、単に学習回数が足りていないのか パラメータが足りなすぎてこれ以上キレイにならないのか、はもうちょっと続けてみないと分からないけど 多分まだ回数が足りていないだけなんじゃないかな… もうちょっと続けてみます。
今回は「顔」っていうとても限定的な領域での生成だし、これくらいで大丈夫だろう、と かなり勘でパラメータ数を決めてしまっているので 本当はもっと理論的に適切な数を導き出したいところだけど…。
前回までの分類器だとsparsityというのを計測していたのでそれを元に削れるだろうな、と思っているのだけど、今回の場合すべてにBatch Normalizationが入っているのでそれも意味なくて、なんとも難しい気がする。それこそGPUで何度もぶん回して探っていくしかないのかなぁ。
DCGANによる生成ができると、今度は入力の乱数ベクトルを操作することで任意の特徴をもつ画像をある程度狙って生成できるようになる、とのことなのでそれも試してみたいと思ったけど まだ出来ていないのと 長くなってしまったので続きは次回。
TensorFlowでのDeep Learningによるアイドルの顔識別 のためのデータ作成 - すぎゃーんメモ の記事で書いているけれど、学習用データとして使うために収集した画像から「顔の領域」だけを切り出して「固定サイズ」(112x112など)に切り出す必要があって。
以前にも書いたけど、自撮り画像はけっこう顔が傾いた状態で写っているものが多いので、それも検出できるようにしたりしている。
で、せっかく傾きの角度も含めて検出できるならそのぶんを補正して回転加工して切り出すようにしていて。
…というのを RMagick のRVGを使ってcanvasっぽい感じでどやこや書いていたのだけど、どうも使っているImageMagickのバージョンなどの影響もあるのかもしれないけど
#destroy!
とか明示的に呼んでるはずなのにメモリ使用量がどんどん増え続けてしまうclockwork
プロセスごと死んでしまうといった問題が起きていて、ちょっと原因追うのも面倒 というかわざわざこれくらいの加工にRMagickで頑張りすぎることもないんじゃないか、と思って捨てることにした。
要はImageMagickのCLIを使いこなせればそれくらいのことが出来るはず、ということで調べたら
というのにピッタリな、「Scale-Rotate-Translate (SRT) Distortion」というのがあることを知った。
Angle | -> centered rotate | |||
Scale | Angle | -> centered scale and rotate | ||
X,Y | Angle | -> rotate about given coordinate | ||
X,Y | Scale | Angle | -> scale and rotate about coordinate | |
X,Y | ScaleX,ScaleY | Angle | -> ditto | |
X,Y | Scale | Angle | NewX,NewY | -> scale, rotate and translate coord |
X,Y | ScaleX,ScaleY | Angle | NewX,NewY | -> ditto |
という具合に、引数で「回転角」「倍率」「回転中心座標」「中心の移動先座標」をそれぞれ指定することで一発で変換ができるらしい。
ImageMagickのCLI wrapper的な MiniMagick を使ってそれぞれ実験してみる。
顔の検出は Google Cloud Vision API でのFACE_DETECTION
のレスポンスを使うとする。
1. まずは回転角度だけを指定する場合
MiniMagick.logger.level = Logger::DEBUG detected['responses'].first['faceAnnotations'].each do |annotation| srt = [ - annotation['rollAngle'] ].join(' ') MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert| convert.distort(:SRT, srt) end end
DEBUG -- : [0.39s] mogrify -distort SRT -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90415-1tuqqvg.jpg DEBUG -- : [0.36s] mogrify -distort SRT -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90415-kfub15.jpg
2. ちょっと背景を…
回転によって空く領域はvirtual-pixel
で指定できるらしい。デフォルトは白のようなので黒にする。
detected['responses'].first['faceAnnotations'].each do |annotation| srt = [ - annotation['rollAngle'] ].join(' ') MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert| convert.background('black') convert.virtual_pixel('background') convert.distort(:SRT, srt) end end
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90304-1e0od53.jpg DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90304-1yp6bof.jpg
それぞれの顔が真っ直になるよう微妙に回転角が調整されているのが確認できる
3. 回転中心座標を指定
何も指定していないと画像中央を中心として回転していたけど、顔の中心座標を指定すると
detected['responses'].first['faceAnnotations'].each do |annotation| xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] } ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] } srt = [ "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}", - annotation['rollAngle'] ].join(' ') MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert| convert.background('black') convert.virtual_pixel('background') convert.distort(:SRT, srt) end end
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91029-ctowe2.jpg DEBUG -- : [0.20s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91029-irskuw.jpg
4. スケールを指定する
顔のサイズと、切り出したいサイズの比 から倍率を求めて指定
size = 96 detected['responses'].first['faceAnnotations'].each do |annotation| xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] } ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] } srt = [ "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}", size.to_f / [xs.max - xs.min, ys.max - ys.min].max, - annotation['rollAngle'] ].join(' ') MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert| convert.background('black') convert.virtual_pixel('background') convert.distort(:SRT, srt) end end
DEBUG -- : [0.31s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90917-l2v5ta.jpg DEBUG -- : [0.29s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90917-1gf4b9q.jpg
倍率の差はあまり分からないけど、それぞれ回転したものが縮小されているのは間違いない
5. 移動先座標を指定
最終的に左上から指定サイズでcropするために、変換後のものを左上に寄せる。顔の中心を既に指定しているので、これが指定サイズ領域の中心になるようになれば良い。
detected['responses'].first['faceAnnotations'].each do |annotation| xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] } ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] } srt = [ "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}", size / [xs.max - xs.min, ys.max - ys.min].max, - annotation['rollAngle'], "#{size * 0.5},#{size * 0.5}" ].join(' ') MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert| convert.background('black') convert.virtual_pixel('background') convert.distort(:SRT, srt) end end
DEBUG -- : [0.28s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 48.0,48.0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91316-icip1v.jpg DEBUG -- : [0.30s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 48.0,48.0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91316-13jij7r.jpg
6. 指定サイズでcrop
既に左上に寄せてあるので、offsetなしで切り取れば良いだけ、となる。
detected['responses'].first['faceAnnotations'].each do |annotation| xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] } ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] } srt = [ "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}", size / [xs.max - xs.min, ys.max - ys.min].max, - annotation['rollAngle'], "#{size * 0.5},#{size * 0.5}" ].join(' ') MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert| convert.background('black') convert.virtual_pixel('background') convert.distort(:SRT, srt) convert.crop("#{size}x#{size}+0+0") end end
DEBUG -- : [0.28s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 48.0,48.0 -crop 96x96+0+0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91967-1wh75bk.jpg DEBUG -- : [0.27s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 48.0,48.0 -crop 96x96+0+0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91967-un47j8.jpg
…ということで、
$ convert <元画像> -background black -virtual-pixel background -distort SRT '<顔の中心座標> <拡大縮小倍率> <回転角度> <中心移動先座標>' -crop <切り出しサイズ>+0+0 <出力画像>
のような形でconvert
やmogrify
を使えば一発で検出された顔の領域を指定サイズで得ることができることが分かった。
のでRMagickの使用を止めてこの方法で顔画像領域を取得するよう変更した。今のところは問題なく動いているっぽい。
音咲セリナちゃんも宇佐美幸乃ちゃんも可愛い。
全裸bot for LINE - すぎゃーんメモ の記事にフィードバックをいただきまして。
全然知らなかったのだけど、Timing attackという攻撃手法が存在するそうで。
たとえば文字列の比較で、先頭から1文字ずつ比較していってその中身が異なっていたらreturnする、という処理をしている場合。
func cmpstring(s1, s2 string) int { l := len(s1) if len(s2) < l { l = len(s2) } for i := 0; i < l; i++ { c1, c2 := s1[i], s2[i] if c1 < c2 { return -1 } if c1 > c2 { return +1 } } if len(s1) < len(s2) { return -1 } if len(s1) > len(s2) { return +1 } return 0 }
パスワードや署名の検証など「正しい文字列が与えられたらtrue、そうでない場合はfalse」という場面でこういう方法で文字列比較を行っていると、与える文字列によって処理の演算回数が変わるので、その実行時間を計測しながら何度も試行することで正しい文字列が推測できてしまう、ということらしい。
今回のようなHTTP経由での数バイトの文字列比較ではネットワーク遅延などの誤差の方が遥かに大きく この手法で破れるとはあまり思えないけれど、こういったものを防ぐために「入力の値にかかわらず定数時間で処理を行う比較関数」がちゃんと用意されているので、それを使うのがベターでしょう。
func (bot *Bot) checkSignature(signature string, body []byte) bool { decoded, err := base64.StdEncoding.DecodeString(signature) if err != nil { return false } hash := hmac.New(sha256.New, []byte(bot.ChannelSecret)) hash.Write(body) return hmac.Equal(decoded, hash.Sum(nil)) }
このhmac.Equal
が中でsubtle.ConstantTimeCompare
を使うようになっていて、その実装が
// ConstantTimeCompare returns 1 iff the two slices, x // and y, have equal contents. The time taken is a function of the length of // the slices and is independent of the contents. func ConstantTimeCompare(x, y []byte) int { if len(x) != len(y) { return 0 } var v byte for i := 0; i < len(x); i++ { v |= x[i] ^ y[i] } return ConstantTimeByteEq(v, 0) } // ConstantTimeByteEq returns 1 if x == y and 0 otherwise. func ConstantTimeByteEq(x, y uint8) int { z := ^(x ^ y) z &= z >> 4 z &= z >> 2 z &= z >> 1 return int(z) }
のようになっていて、必ずすべてのbyteについて比較してその結果が等しくなっているかどうか、という計算になっているようだ。
こういった比較関数を使うことでTiming attackを防ぐことができる。
BOT API Trial Account Overview - LINE Business Center が公開されて、LINEのbotが作れるようになった、ということで 遅ればせながら自分も 過去に作ったTwitter bot を移植してみた。
テキストを受け取ってちょっと改変してオウム返しする、というのは練習としては良い題材ですね。kagome を使えばPure Goで形態素解析できるし、ということでGoで書いてみた。
Callback URLにSSLが必須、送信側はIP Whilelistに登録している必要がある、ということでちょっと制限があるけれど、これくらいのお遊び程度のものならherokuで受けて fixie addonを使えばIP固定させて使うことができる。もっと本格的に使おうと思ったらちゃんとした構成を考える必要があるでしょうけども。
実装の参考に、と幾つかの記事や公開コードを読んでみたのですが Signature validationを無視しているものが多くて気になりました。
お遊び程度のデモなら気にすることもないのかもしれないけど、Callback URLに対して誰でも出鱈目なJSONをPOSTできる状態で そのまま信頼してそのデータを使うなんてとんでもない。
そんなに難しいことでもないし、ちゃんとSignature validationはしておこう。
func (bot *Bot) handle(w http.ResponseWriter, req *http.Request) { defer req.Body.Close() body, err := ioutil.ReadAll(req.Body) if err != nil { ... } if !bot.checkSignature(req.Header.Get("X-Line-Channelsignature"), body) { return nil, errors.New("invalid signature") } ... } func (bot *Bot) checkSignature(signature string, body []byte) bool { hash := hmac.New(sha256.New, []byte(bot.ChannelSecret)) hash.Write(body) return signature == base64.StdEncoding.EncodeToString(hash.Sum(nil)) }
※追記しました
続・TensorFlowでのDeep Learningによるアイドルの顔識別 - すぎゃーんメモ の続き、というかなんというか。
前回までは「ももいろクローバーZのメンバー5人の顔を識別する」というお題でやっていたけど、対象をより広範囲に拡大してみる。
あまり知られていないかもしれないけど、世の中にはものすごい数のアイドルが存在しており、毎日どこかで誰かがステージに立ち 歌って踊って頑張っている。まだまだ知名度は低くても、年間何百という頻度でライブを中心に活動している、所謂「ライブアイドル」。俗に「地下アイドル」と言ったりする。
そういったアイドルさんたち 活動方針も様々だけど、大抵の子たちはブログやTwitterを中心としてWebメディアも活用して積極的に情報や近況を発信していたりする。
そんな中、近年登場した「自撮り投稿サービス」。
概要としては、各アイドルさんが自撮りなどの写真を投稿し、ファンのユーザーが「応援」としてポイントを送ることでその数で順位がつく、というもの。ある期間で上位にランクインすると街頭広告や雑誌インタビューに掲載されるといったイベントも開催されるため、アイドルさんは可愛い自撮りをたくさん投稿するしファンはポイント貯めたり買ったりして頑張って応援する。有名なところで以下の2つのサービスが登場している。
それぞれのサービスに登録されているアイドルさんを 雑にスクレイピング して取得したところ、軽く1000件を超えた。それだけたくさんアイドルがいるわけで、最終目的としては入力した顔画像をそれら1000人以上のアイドルに分類すること、となる。
…のだけど、つまりそれを学習させるためにそれらのアイドルさんたちの「顔画像」と「(それが誰であるかを示す)ラベル」のセットが大量に必要となるわけで。
多くのアイドルさんが利用しているこの自撮り投稿サービスから、投稿者と画像をセットで引っ張ってくれば「自撮り」なんだから自動でラベル付け済みの顔画像データセットを作れるんじゃないか、と考えたのだけど、まぁそんなに簡単な話でもなく。
まず「主に自撮りが投稿される」のであって必ず投稿した本人だけの顔が写っているとは限らない。別にそういうルールがあるわけではないので、共演した別のアイドルさんとの2ショットだったり 同じグループのメンバーと一緒の仲良しショットがあったりすることも珍しくない。そういった写真は顔検出は自動で出来ても、複数検出されたもののうちどの顔が投稿者本人なのかは分からない。
あと、そもそも上記の自撮り投稿サービスたちはデータ取得用のAPIが公開されていない。CHEERZの方はWeb版があるのでスクレイピングすれば可能そうではあるけど、DMM.yellはアプリ専用なのでそれなりにハックが必要になる。
…やはりラベル付けは人力でやるしかない、と覚悟を決めて、とにかくまずは収集してみることにした。
アプリには取得APIが無いが、投稿時にTwitterと連動する機能がついているようで、大抵のアイドルさんは自分のTwitterアカウントと紐付けてそちらにも画像付きTweetで流している。ので、連動投稿に付与されている「#CHEERZ」「#dmmyell」といったハッシュタグでTwitter検索をかければ大抵の画像は取得できる。それらの投稿から各アイドルさんの個人アカウントも把握できるので、自撮りアプリ連携ではない普段の画像Tweetも収集対象になるように登録しておく。
そうやって定期ジョブでTwitterからひたすら画像付きTweetを取得し、 自作の顔検出器 にかけて顔部分を抽出して保存していく。このあたりは 前回まで のももクロちゃんデータセットを作成したときと同様で、管理用のWebアプリを自作している。
以前はこれをHerokuで運用していたのだけど、今回はあっという間に顔画像が10000件を超えて 無料枠で利用できる範囲をオーバーしてしまったので、以前から持っていたのにほぼ使っていなかったさくらVPSのサーバに移行した。
こんな感じで、様々なアイドルさんの顔画像が取得できる。(かわいい)
が、これらをそれぞれ「誰であるか」のラベル付けをする必要があり。僕だって一応ドルヲタとして年間200〜300の現場に足を運び何百組というアイドルを見てきたし そのへんの人よりはアイドルに詳しいつもりだから 100人とか200人くらいなら顔を識別できる自信はあるけれど、1000人くらいの顔がごちゃ混ぜになっていると流石に無理がある。
とりあえずは知ってる顔はどんどん片付けていって、あとは知らない子でも普通に自撮りっぽい画像で1人で写っていればそれはまぁ本人に違いないだろう、と判断できるのでラベルを付けていく。本当に見たことのない知らない子たちの集合写真とかはまったく判別できないので後回し。
というのを考えて、顔が1つだけ検出されている画像を優先でランダムで選択しつつラベル付けしていったり。いちおう入力しやすいよう補完つけたりインタフェースを工夫したりはしている。
気の遠くなるような作業ではあるけれど、まぁ好きなので意外と飽きない。数千件は自力でラベル付けできた。
と、こんな作業を続けているうちに 何人かはある程度の枚数の顔画像が揃うので、集まっている限りのデータを使って 前回のもの と同じ、96x96 size, 3 channelの画像を入力とする4層の畳み込みと3層の全結合によるネットワークを使って学習させてみる。
1分類につき10枚程度だと心許ないが、30枚くらい学習させたらある程度の特徴を掴んでそれっぽいものは判別してくれるようになるのでは…?という目論見。
学習用と評価用とかデータセットを分ける余裕はないので、とりあえずはラベル付けしたものは全部学習用に使う。前回と同じTFRecordのファイルを入力に使うため、 ダウンロードできるように顔画像JPEGバイナリとラベル番号のセットを含むTFRecordバイナリを吐くエンドポイントも 実装 した。
ラベル付けして30枚以上集まっている顔画像セットに、それ以外のものとして 数枚しか集まっていないものやそもそも顔画像じゃないものも「分類対象外」のラベルとして3割ほど混ぜる。数千枚を50クラスくらいに分類するもの、となる。これは前回と同じモデル(むしろ畳み込み層のパラメータ数は減らしてる)で 1000〜2000 stepくらいでも十分にcross entropyが減少して 教師データに対してはほぼミスなく ちゃんと分類してくれるようになる。
で、ある程度学習が済んだモデルが出来上がったら、未分類の顔画像に対してその分類器にかけて推論してもらう。
学習済みの顔に近いものは高いスコアで識別されるはずなので、これを確認することで、未分類の顔画像に対して「誰であるか」を考えるのではなく「○○と推測されているが、合っているか」だけを考えることになるので、人間の負担が軽減し作業が捗る。
そして当然ながらまだまだ学習データ数が少ないので、この推論はすごくよく間違う(上記の画像のは全部あってます)。
傾向としては、一人金髪の子を学習すると 髪の明るい人物は大抵その子と認識するようになる とか、画質がボヤけ気味のが多い子を学習しているとボヤけてる画像はだいたいその子と認識する、とか。髪の短い子や 頬にほくろのある子 とか 一応なんらかの特徴を掴んでいるようで 似たようなものはそれっぽく分類することは多いけど、やっぱりヒトが見たら全然ちがうだろって思うような間違いをしていたりはする。
それはそれで確認しながら正しくラベル付けしてやることでまた学習に使えるデータが増えるので、ある程度の答え合わせが済んだら増えたデータセットを元に再び学習してやる。そうすることで以前間違えたようなものはもう同じ間違いはしないし、さらに精度の高い推論をするよう進化する。
似たような入力に対し何度も学習プロセスを繰り返すのに 毎回まっさらな状態から始めるのは非効率だと思ったので、学習し直すときは前回の学習済みモデルのパラメータをロードしてそこから始めることにした。分類対象数が増えた場合でも、それは最後の全結合の 隠れ層→出力層 の部分だけしか構造は変わらないので、それ以外のパラメータはそのまま使っても 初期化状態から始めるよりは早い、はず。
def restore_or_initialize(sess): if os.path.exists(FLAGS.checkpoint_path): for v in tf.all_variables(): print 'restore variables "%s"' % v.name try: restorer = tf.train.Saver([v]) restorer.restore(sess, FLAGS.checkpoint_path) except Exception: print 'could not restore, initialize!' sess.run(tf.initialize_variables([v])) else: print 'initialize all variables' sess.run(tf.initialize_all_variables())
https://github.com/sugyan/tf-classifier/blob/master/models/v2/train.py#L30-L46
ということで、学習用のデータを用意するのは大変だけど、
というサイクルを続けることで、なんだかんだで自力で13000点ほどの分類済みのアイドル顔データを作ることができている。
現時点での最新の学習済み分類器での結果はたとえば
というかんじで、100クラス以上の分類数である中で Luce Twinkle Wink☆ のメンバー5人中4人くらいは一応判別できるようになっている。
30枚くらい集まれば学習対象になって 推論結果にも出るからさらにデータを増やすのに使えるけれど、そもそもその30枚くらいまで集めるのが大変なわけで。
似たような特徴を持つ顔をクラスタリングしてまとめてラベル付けできたら良いのかな…?と思って、学習済みモデルの隠れ層の出力パターンが似たものとかで分類できないかと調べてみたのだけど ちょっと有意な傾向は把めなそうだった…。
あとは推論結果に対する検証なんかはアイドルに詳しいドルヲタの方々に手伝ってもらう形で集合知を利用して実現できれば良いのだけど、なんとも良いインタフェースが思い浮かばない。
分類作業しているうちに、知らなかった子の顔もけっこう覚えるようになる。人間のラーニング能力もすごいな、って。
だんだん「アイドルの顔画像」が集まってきたので、今度はこれらを利用して"生成する"というのにもチャレンジしてみたいと思っている。
他にもなにか良いアイディアがあれば。現時点で作ったデータセットも何かに利用したい、という方がおりましたら提供の相談させていただきますのでお気軽に連絡ください。
TensorFlowによるディープラーニングで、アイドルの顔を識別する - すぎゃーんメモ の続き。
前回は最も簡単に画像分類を試すために TensorFlow に同梱されているtensorflow.models.image.cifar10
パッケージのモデルや学習機構を利用して約75%の識別正答率の分類器を作ったが、それよりも良い結果を出したいし色々ためしてみたい、ということで今回は色々と自前で実装したり改良を加えてみた。
結論だけ先に書くと、約90%の正答率のものを作ることができた。分類数も変えてしまっているので一概には前回のものと比較できないけど。
まずは入力の画像について。
前回はCIFAR-10のデータセットに合わせて、検出して切り出した顔画像を32x32
サイズに縮小したものを利用していた。
流石に32x32
では小さすぎて人間が見てもなかなか区別できなかったりしたし、もうすこしハッキリと分かるくらいの画像サイズを入力に使えるようにしよう ということで各辺3倍サイズの96x96
画像を入力にすることにした(画素数で言うと9倍)。
そして、ある程度のスケーリング誤差も吸収できるようにと 顔画像収集時には検出された顔領域の1.2倍ほどの少し大きめの領域で切り出し112x112
サイズで取得し、そこから96〜112の間でランダムに切り出してさらに収縮させて最終的に96x96
サイズに収まるように、というのを後述のDistortionのところで行った。
切り出す領域を変更したので顔画像は収集し直してラベルも付け直した。収集方法は同じで、ももクロメンバー5人についてそれぞれ200点、計1000点を学習用のデータセットとして用いた。
前回は6番目のラベルとして「ももクロ以外」の人物の顔を学習・評価に使っていたが、どうにも種類が少なくて分類のラベルとして使うのに適しているとは思えなかったので除外することにした。
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のドキュメント読みながらコード書いてたら動かなくてハマった)
読み込んで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系メソッドでは加工の度合いの上限・下限を指定できたりするので、その幅を拡げてもっと極端にすると
のようになったりする。
どの程度までやるのが適切なのかは分からないけど 異常になりすぎない程度に抑えておいた。
コードとしてはこんなかんじ。
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
分類推定のモデルは、 VGGNet と呼ばれる画像分類のための畳み込みネットワークを参考に、独自に定義して作った。
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倍くらいには増えている。
コードとしてはこんなかんじ。
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
損失関数はcifar10のものと同様で、入力画像に対する出力と正解ラベルとのクロスエントロピー、そこに汎化性能向上のための正則化手法として(?)全結合層の最初と中間層のパラメータに適当な割合でweight decayを入れて それらを合計したものを最小化対象のtotal lossとしている。
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')
学習にはGradientDescentOptimizer
ではなくAdamOptimizer
というのを使ってみた。
ちゃんと比較はしていないのだけど、GradientDescentOptimizerよりも格段に早く(少ないstepで)lossが減少するように学習が進んでいるのは観測した。Learning Rateを減衰させて調整する、といったこともここでは行っていない。
ただ1500stepくらいまで行くとそれ以降1stepあたりの計算時間が4〜5倍かかるようになったのだけど これはAdamOptimizerのせいなのかな…?よく分かってない
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
最終的に出来た全体像のグラフはこんなかんじ。
潰れてしまって全然読めないと思うけど、下の方からTFRecordsのファイルを読み込んで加工した画像とラベルのbatchを作って 真ん中の分岐で左側では畳み込みニューラルネットワークに通して出力を計算して 右側を通るラベルと結果を突き合わせてクロスエントロピーを出して損失を計算したりしている、っていう感じになってるはず。
学習結果はこのように、数百〜千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%に上がるのはあまり変わらない気はする。。
けどまぁ少なくとも悪化はしていないはず。あとはやっぱり学習データ数かな、と。
なんとかもっとラクに大量の学習データを用意する方法は無いだろうか…