TensorFlowによるDCGANでアイドルの顔画像生成 その後の実験など

memo.sugyan.com

の続編。

あれから色々な変更しつつ実験してみたりしたのでその記録。 結論を先に書くと、これくらい改善した。

f:id:sugyan:20161012032542j:plain

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くらいまで続けてもあまり変化が見られない、という感じだった。

どうにかもっと良い画像が生成されるように改善されないか、とlearning_rateをデフォルトより小さめにしてみたり、batch_size128よりもっと大きくしてみたりもしたけど、結局どれもそれほど効果は無さそうだった。

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)
    for x in 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))

f:id:sugyan:20161012011133j:plain

上段が、Discriminatorのsoftmax出力が0で高かったもの上位。自分のDCGAN実装ではこれはDiscriminatorがGeneratorによる画像だと判定したもの。下段が、softmax出力が1で高かったもの上位 すなわち学習データと判定されたもの(うまく騙せたもの)、となる。

うーん、確かに下段のものの方がキレイに出来ているものが多いような気もするけど、別に全部が良いわけでもないし 上段にもそれなりのものが出てきてたりするし… これも以前の記事で確認した通り、モデルが判別する特徴は人間の感覚と全然ちがうからあまり当てにはならない、のかも知れない…。

Web UIで入出力を調べる

ならば入力の値を弄ってどうにかすることはできないか、と思ったのだけど Generatorはブラックボックスすぎて「どんな値を入力すると どんな画像が生成されるか」が直感的にはまったく分からない。

ので、入力値を色々変えて実験できるよう こんなWeb UIを作ってみた。

f:id:sugyan:20161012012411p:plain

入力乱数を16次元の数値として、それらは実際には小数値なのだけど分かりやすいように0-255の整数値に置き換えてスライダーなどで操作できるように。それらの値に応じてAPI経由でその入力値からGeneratorによる顔画像生成を行い結果を描画。そのときの入力値を32文字のhex stringで表現して再現に使えるようにする。

というもの。Reactとか勉強しながらMaterial-UIで作ってみた。

入力値を操作する

このUIで色々とランダムな入力で試してみると、例えばすごく崩れる顔がどんな入力から生まれるのかが把握できる。

82763953b2740fef4d321dde7af002f7 f:id:sugyan:20161012014750p:plain

75cd0382329c4a341e296530c615b674 f:id:sugyan:20161012014758p:plain

54795b1ef616f55d2cd32f288a3f41f2 f:id:sugyan:20161012014800p:plain

という感じに。 これらを幾つか抽出して雑に平均を取ってみると

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 f:id:sugyan:20161012015218p:plain

と、およそ顔とも分からないようなすごいのが出力されることが分かる。

逆に、そこそこキレイに上手く生成されたものを集めて平均を取ってみると

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 f:id:sugyan:20161012015859p:plain

と、とても自然なイイカンジの画像が生成されることが確認できる。

この「良い例」と「悪い例」の差分を取って、「悪い入力」から「良い入力」へ向かうベクトルを乱数入力値にオフセットとして加えてやれば、より良い結果が生まれやすいのでは!? ということでやってみた結果がこれ。

f:id:sugyan:20161012030450j:plain

入力値の範囲が狭められたことで ちょっと似たり寄ったりなものが多いような気もしないでもないけど、より高確度で比較的安定した顔画像が得られるようになった。 一応ちゃんとそれぞれ髪型や顔の角度・表情は違っているし、悪くないと思う。

この「入力値にオフセットを加える」手法で、例えば「左向きの顔が出力される入力」「右向きの顔が出力される入力」を調べて差分ベクトルを入力値に加えることで右向きばかりのものが生成されるようになったり、表情や髪・肌の色とか 色んな要素を調節しつつ生成できるようになることが期待できる。(まだそこまでは出来ていない。そういった特徴を抽出するのもなかなか面倒…。)

今後の展望

もうちょっと、入力値の良い取り方などは研究してみたいところ。金髪とかショートとか離れ目とか笑顔とか、様々な成分を指定して自由に自分好みの顔を生成できるようになる、のが目標かな。

あとは複数の顔をモーフィングで遷移するアニメーションとか作ると面白そうだと思っているので、そういうのも生成するUIを作ってみたいと思っている。

そもそもDCGANでの生成がこれが限界なのかどうか。さらに改良する方法や、またDCGANではない生成方法も調べて試してみたいところ。

Repository