memo.sugyan.com
の続編。
あれから色々な変更しつつ実験してみたりしたのでその記録。
結論を先に書くと、これくらい改善した。
DCGAN with feature matching 左側は全体的にずっとガチャガチャと目まぐるしく変化していて落ち着かない感じなのが、右側は比較的早い段階から顔っぽいものが出来てゆるやかに安定していくように変化している様子が伺える。また左は14,000stepくらいから全体的に白っぽく薄くなっていっているのが 右側では起こらなくなっているのも確認できる。
DCGAN ざっくりおさらい
●Generator: 乱数の入力から画像を生成する ●Discriminator: 入力した画像がGeneratorが生成したものか学習データのものかを判別する という2種類のネットワークを用意し、お互いを騙す・見破るように学習を行うことで Generatorが学習データそっくりの画像を生成できるようになる、というもの学習用画像の増加
前回の記事では90人の顔画像データから生成していたけど、あれから収集を続けて もう少し多く集まったので、今回は260人から集めた顔画像100点ずつ、計26,000件を学習に使用した。Feature matching
openai.com の記事で紹介されている "Improved Techniques for Training GANs" という論文を読んで、使われたコードも読んでみまして、正直何やっているのか分からない部分が多く理解できていないことだらけなのだけど その中の "3.1 Feature matching" のところは分かりやすく効きそうだったので取り入れてみた。 原理としては、﹁Discriminatorの中間層出力には分類のための特徴(feature)が含まれるはずなので、それがGeneratorによるものと学習データ由来のものとで似たようなものになっていれば(学習データに近いものがGeneratorから生成されている、ということになるので)より良いはず﹂ということのようだ。 なので、Discriminatorの最終出力(入力画像が学習データのものか否かを判定するもの)の1つ前の、4回の畳み込みを行った段階での出力をそれぞれ(Generator由来の画像を入力した場合/学習データの画像を入力したとき)で取得し、各mini batchごとの平均値の差分が少なくなるよう 適当な倍率を掛けてGeneratorのloss値として加えた。def build(self, input_images, learning_rate=0.0002, beta1=0.5, feature_matching=0.0): """build model, generate losses, train op""" generated_images = self.g(self.z)[-1] outputs_from_g = self.d(generated_images) outputs_from_i = self.d(input_images) logits_from_g = outputs_from_g[-1] logits_from_i = outputs_from_i[-1] if feature_matching > 0.0: ), feature_matching)) features_from_g = tf.reduce_mean(outputs_from_g[-2], reduction_indices=(0)) features_from_i = tf.reduce_mean(outputs_from_i[-2], reduction_indices=(0)) tf.add_to_collection('g_losses', tf.mul(tf.nn.l2_loss(features_from_g - features_from_i), feature_matching)) 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))))後述するけれど、従来の方法だと 学習を続けていくと生成画像が全体的に白っぽく薄くなる、という現象があって、おそらくこれはDiscriminatorが画像を判別する際に全体の色合いなどは注視しないからなのではないかと思われ(以前の記事で実験しているように、人間の感覚とは全然違う特徴抽出しているようだ)、 それを防ぐためにも このfeature matchingと同様のものを最終出力の画像にも適用してみた。
logits_from_g = outputs_from_g[-1] logits_from_i = outputs_from_i[-1] if feature_matching > 0.0: + mean_image_from_g = tf.reduce_mean(generated_images, reduction_indices=(0)) + mean_image_from_i = tf.reduce_mean(input_images, reduction_indices=(0)) + tf.add_to_collection('g_losses', tf.mul(tf.nn.l2_loss(mean_image_from_g - mean_image_from_i), feature_matching)) features_from_g = tf.reduce_mean(outputs_from_g[-2], reduction_indices=(0)) features_from_i = tf.reduce_mean(outputs_from_i[-2], reduction_indices=(0)) tf.add_to_collection('g_losses', tf.mul(tf.nn.l2_loss(features_from_g - features_from_i), feature_matching))比較結果が以下の動画。左側が従来の普通のDCGAN、右側がfeature matchingを加えたもの。
DCGAN with feature matching 左側は全体的にずっとガチャガチャと目まぐるしく変化していて落ち着かない感じなのが、右側は比較的早い段階から顔っぽいものが出来てゆるやかに安定していくように変化している様子が伺える。また左は14,000stepくらいから全体的に白っぽく薄くなっていっているのが 右側では起こらなくなっているのも確認できる。
Learning rate, Batch size
しかし上記の方法でもどうにも限界があるようで ある程度まではキレイに顔っぽいものが生成するようになっても、まだまだ崩れたものになってしまう場合も多い。 変化を観察していると10,000stepくらいでそれなりのクオリティになって、そこからは30,000stepくらいまで続けてもあまり変化が見られない、という感じだった。 どうにかもっと良い画像が生成されるように改善されないか、とlea
rning_rate
をデフォルトより小さめにしてみたり、batc
h_size
を128
よりもっと大きくしてみたりもしたけど、結局どれもそれほど効果は無さそうだった。
Discriminatorの出力を見る
とはいえ Generatorは無限の乱数入力から無限のパターンを生成するわけで、すべてがキレイな顔画像になるわけがない というのは当たり前といえば当たり前。ならば複数生成されるものから上手くいったものだけ自動で抽出できれば良いのでは? ということで学習済みのGeneratorとDiscriminatorを使って、mini batchで生成される複数の画像をDiscriminatorに通した結果のsoftmax
値の高い順に表示してみた。
# 乱数mini batchから画像を生成する images = sess.run(dcgan.g(dcgan.z)[-1]) # discriminatorの出力にsoftmaxかけたものを反転してtop_kを抽出 # `0`を高く出力したもの `1`を高く出力したもの 上位10件ずつの値とindexが取れる values, indices = tf.nn.top_k(tf.transpose(tf.nn.softmax(dcgan.d(images)[-1])), 10) forxin sess.run([values, indices]): print(x.tolist()) # top_kで得たindicesを使って生成画像から抽出し、縦横に連結 rows = [] for cols in tf.split(0, 2, tf.gather(images, indices)): rows.append(tf.concat(3, tf.split(1, 10, cols))) result = tf.squeeze(tf.concat(2, rows), [0, 1]) # 余計な次元を削減してjpeg画像に変換して出力 img = tf.image.encode_jpeg(tf.image.convert_image_dtype((result + 1.0) / 2.0, tf.uint8)) filename = os.path.join(FLAGS.images_dir, 'out.jpg') with open(filename, 'wb') as f: print('write to %s' % filename) f.write(sess.run(img))上段が、Discriminatorのsoftmax出力が
0
で高かったもの上位。自分のDCGAN実装ではこれはDiscriminatorがGeneratorによる画像だと判定したもの。下段が、softmax出力が1
で高かったもの上位 すなわち学習データと判定されたもの(うまく騙せたもの)、となる。
うーん、確かに下段のものの方がキレイに出来ているものが多いような気もするけど、別に全部が良いわけでもないし 上段にもそれなりのものが出てきてたりするし… これも以前の記事で確認した通り、モデルが判別する特徴は人間の感覚と全然ちがうからあまり当てにはならない、のかも知れない…。
Web UIで入出力を調べる
ならば入力の値を弄ってどうにかすることはできないか、と思ったのだけど Generatorはブラックボックスすぎて﹁どんな値を入力すると どんな画像が生成されるか﹂が直感的にはまったく分からない。 ので、入力値を色々変えて実験できるよう こんなWeb UIを作ってみた。 入力乱数を16次元の数値として、それらは実際には小数値なのだけど分かりやすいように0
-255
の整数値に置き換えてスライダーなどで操作できるように。それらの値に応じてAPI経由でその入力値からGeneratorによる顔画像生成を行い結果を描画。そのときの入力値を32文字のhex stringで表現して再現に使えるようにする。
というもの。Reactとか勉強しながらMaterial-UIで作ってみた。
入力値を操作する
このUIで色々とランダムな入力で試してみると、例えばすごく崩れる顔がどんな入力から生まれるのかが把握できる。82763953b2740fef4d321dde7af002f7
75cd0382329c4a341e296530c615b674
54795b1ef616f55d2cd32f288a3f41f2
という感じに。
これらを幾つか抽出して雑に平均を取ってみると
samples = %w( 82763953b2740fef4d321dde7af002f7 75cd0382329c4a341e296530c615b674 54795b1ef616f55d2cd32f288a3f41f2 b21ce031415c8abb73d8200bc115476c b0064a3e5ec757ae09898814edd94264 2e790129bd66adfc8796201ff947259a 097c5a73700498603e43ab439a854a83 2a57676c479b4953d1694c45074229a2 584bcb88c0609c61161ef62cab740b98 725f3fe61612a4becf920302e936b022 618b23b2189ea810998968b7dc60e4b5 0f254708a300a458f504d97fcba07442 ) lists = samples.map do |hex| hex.scan(/.{2}/).map(&:hex) end avg = lists.transpose.map do |a| a.inject(&:+).to_f / a.size end puts avg.map { |e| format('%02x', e) }.join
$ ruby average.rb 5b605461755d86826d6b6348b168598dというのが得られ、これを入力として使ってみると…
5b605461755d86826d6b6348b168598d
と、およそ顔とも分からないようなすごいのが出力されることが分かる。
逆に、そこそこキレイに上手く生成されたものを集めて平均を取ってみると
samples = %w( e7a8fea0affc366aa0fc77911c54201a c5c3ede294e988a1e8ebb7941def3297 a1b3f8d8be647c6775cd94e184bb4f08 75dcabe39c9f8b7e908ecd88546e2c9d a582debdcf74d579b990ce7123a48675 ede6a4fc6cdab7828677e7dd6a998880 d6e1a99db03f44a29fc49c9427c70569 fcd35bd348836cc7a18d92d29367196c e789ec78b2cf2ba5e5bd9f87723e913f f788ded2733f4eb7e7fa9acc2a4aae26 b2c5b8d3a6b54bd5e7e1cc90838774cf dcd9b5bb87a86ec8c3dbace763a65d5c c8f8a9e199e244e0f59ab4db62466783 ) lists = samples.map do |hex| hex.scan(/.{2}/).map(&:hex) end avg = lists.transpose.map do |a| a.inject(&:+).to_f / a.size end puts avg.map { |e| format('%02x', e) }.join
cbbfc4c798a26ba1babeaeaf53865766
と、とても自然なイイカンジの画像が生成されることが確認できる。
この﹁良い例﹂と﹁悪い例﹂の差分を取って、﹁悪い入力﹂から﹁良い入力﹂へ向かうベクトルを乱数入力値にオフセットとして加えてやれば、より良い結果が生まれやすいのでは!? ということでやってみた結果がこれ。
入力値の範囲が狭められたことで ちょっと似たり寄ったりなものが多いような気もしないでもないけど、より高確度で比較的安定した顔画像が得られるようになった。
一応ちゃんとそれぞれ髪型や顔の角度・表情は違っているし、悪くないと思う。
この﹁入力値にオフセットを加える﹂手法で、例えば﹁左向きの顔が出力される入力﹂﹁右向きの顔が出力される入力﹂を調べて差分ベクトルを入力値に加えることで右向きばかりのものが生成されるようになったり、表情や髪・肌の色とか 色んな要素を調節しつつ生成できるようになることが期待できる。(まだそこまでは出来ていない。そういった特徴を抽出するのもなかなか面倒…。)