TensorFlowで顔識別モデルに最適化した入力画像を生成する

f:id:sugyan:20160710002948p:plain

動機

elix-tech.github.io

の記事を読んで、「可視化」の項が面白いなーと思って。 引用されている図によると、人間の目にはまったく出力クラスとは関係なさそうに見える画像でもCNNによる分類器は騙されてしまう、ということのようだ。

なるほど分類モデルの方を固定しておいて入力を変数として最適化していけば任意の出力に最適な入力を得ることができるのか、と。 自分でもやってみることにした。

分類モデル

TensorFlowによるDeep Learningでのアイドル顔識別モデルの性能評価と実験 - すぎゃーんメモ の記事で使ったモデルとデータセットで、ここではCross Validation用にデータを分けずに7,200件すべてを学習に使い20,000 step進めたものを用意した。

このモデルは学習したアイドルたちの顔画像に対してはかなりハッキリと分類できるようになっていて、試しに幾つかを入力して得た分類結果の上位3件をtf.nn.top_k(tf.nn.softmax(logits), k=3)で出力してみると

・例1: エルフロート・マアヤさん (label_index: 10)

f:id:sugyan:20160705200332j:plain

[0.9455398321151733, 0.016151299700140953, 0.013260050676763058] [10, 38, 7]
[0.9314587712287903, 0.02145007625222206, 0.0140310600399971] [10, 7, 38]
[0.9718993306159973, 0.0045845722779631615, 0.0037077299784868956] [10, 2, 17]
[0.9961466789245605, 0.001244293642230332, 0.0008690679096616805] [10, 7, 31]
[0.9985087513923645, 0.0003244238905608654, 0.0003135611186735332] [10, 30, 7]

・例2: フラップガールズスクール・横山未蘭さん (label_index: 13)

f:id:sugyan:20160705200355j:plain

[0.9963579773902893, 0.0019185648998245597, 0.0008565362659282982] [13, 20, 25]
[0.9986739158630371, 0.0006054828991182148, 0.00040348240872845054] [13, 19, 31]
[0.9996882677078247, 0.00011850777082145214, 6.301575194811448e-05] [13, 31, 20]
[0.9860101938247681, 0.006886496674269438, 0.0037682976108044386] [13, 19, 20]
[0.9992870688438416, 0.0002755637979134917, 0.00010664769797585905] [13, 19, 20]

・例3: じぇるの!・針谷早織さん (label_index: 24)

f:id:sugyan:20160705200406j:plain

[0.9933986663818359, 0.004436766263097525, 0.0004516197368502617] [24, 2, 36]
[0.9997298121452332, 6.973237032070756e-05, 5.891052205697633e-05] [24, 8, 2]
[0.9980282187461853, 0.000929205387365073, 0.000297865248285234] [24, 2, 36]
[0.9958142638206482, 0.0027367006987333298, 0.0004832764097955078] [24, 21, 20]
[0.991788923740387, 0.002572949742898345, 0.0013722123112529516] [24, 2, 26]

という具合に、正しいindexの番号の出力が0.9以上になるくらいのものとなっている。

このモデルを騙して誤識別させるような画像を生成する、というのが今回のテーマ。

inputs

今回は入力画像が変数となるので、そのサイズ(今回の場合は96 x 96 x 3)の変数を用意する。取り得る値の範囲は0.0 - 1.0とする。

import tensorflow as tf

with tf.variable_scope('input') as scope:
    v = tf.get_variable('input', shape=(96, 96, 3), initializer=tf.random_uniform_initializer(0.0, 1.0))

inference

学習済みのモデルにこの変数を入力として与え、分類結果を得る。 ただ、元々このモデルはJPEGなどの画像から復元した0 - 255の値をとるtf.uint8Tensortf.image.per_image_whiteningによって変換したものを入力として取るようにしていたので、それに従って同じように変換する。

image = tf.mul(tf.clip_by_value(v, 0.0, 1.0), 255.5)
input_image = tf.image.per_image_whitening(image)
# 以前までの記事で使っていた識別器。今回はbatch sizeを1とする
r = Recognizer(batch_size=1)
logits = r.inference(input_image)

loss

上記で得られた結果と、「理想とする出力」の差分が今回の損失の値になる。 単純に引き算なんかでも良いかもしれないけど、分類モデルの学習と同様にCross Entropyを使ってみることにする。

losses = tf.nn.sparse_softmax_cross_entropy_with_logits(logits, [FLAGS.target_class])

train

定義したlossを最小化する手続き。これも適当にAdamOptimizerを使っておく。 学習によって値を更新していくのは入力変数vのみ。

train_op = tf.train.AdamOptimizer().minimize(losses, var_list=[v])

replace tf.image.per_image_whitening

これだけで学習させていけるかなーと思ったのだけど、実際にSession作ってtrain_opを実行してみると

...
tensorflow.python.framework.errors.InvalidArgumentError: We only handle up to Tensor::dims() up to 8, not 0
         [[Node: gradients/Relu_grad/ReluGrad = ReluGrad[T=DT_FLOAT, _device="/job:localhost/replica:0/task:0/cpu:0"](gradients/Sqrt_grad/mul_1, Relu)]]
Caused by op 'gradients/Relu_grad/ReluGrad', defined at:
...

みたいなエラーが出てしまう。 よく分からないのだけど、入力に変数を使っているときにReluがあると傾きを計算できないの…?TensorFlowのバグなんだろうか、、

tf.image.per_image_whiteningがやっているのは画像の各画素値から平均値とか分散とか求めて引いたり割ったりしているだけなので、そのあたりのソースを参考にtf.nn.reluを使っているところだけ除外して

# image = tf.image.per_image_whitening(image)
mean, variance = tf.nn.moments(image, [0, 1, 2])
pixels = tf.reduce_prod(tf.shape(image))
stddev = tf.sqrt(tf.maximum(variance, 0))
input_image = tf.sub(image, mean)
input_image = tf.div(input_image, tf.maximum(stddev, tf.inv(tf.sqrt(tf.cast(pixels, tf.float32)))))

と書き換えてみたら無事に学習できるようになった。

結果

これで学習を進めていくと、1000 step程度でlossは十分に減少し、softmaxの目標indexの出力は0.999を超えるくらいになる。

$ python optimal_inputs.py --target_class 10
0000 - loss: 4.388829 (0.012415)
0001 - loss: 4.170918 (0.015438)
0002 - loss: 3.950892 (0.019238)
0003 - loss: 3.728565 (0.024027)
0004 - loss: 3.509432 (0.029914)
0005 - loss: 3.291546 (0.037196)

...

0997 - loss: 0.000944 (0.999057)
0998 - loss: 0.000942 (0.999058)
0999 - loss: 0.000941 (0.999060)

で、この学習終了後の変数vを画像として出力してみると。

output_image = tf.image.convert_image_dtype(v, tf.uint8, saturate=True)
filename = 'target-%03d.png' % FLAGS.target_class
with open(filename, 'wb') as f:
    f.write(sess.run(tf.image.encode_png(output_image)))

(ちなみにこういったランダム要素の多い画像をjpeg出力するときはchroma_downsamplingオプションをFalseにしないとかなり情報が落ちてしまうようなので注意。結構ハマった)

f:id:sugyan:20160709224905p:plain

いちおう拡大してみると

f:id:sugyan:20160709225048p:plain

にわかには信じがたいけど、このデタラメ模様にしか見えないような画像が 今回の分類器に最適化された入力画像、となる。 実際にこの画像から分類器にかけてtf.nn.top_k(tf.nn.softmax(logits), k=3)を出力してみると、

with open('target-010.png', 'rb') as f:
    img = tf.image.decode_png(f.read())
img.set_shape([96, 96, 3])
inputs = tf.expand_dims(tf.image.per_image_whitening(img), 0)
logits = r.inference(inputs, FLAGS.num_classes)
softmax = tf.nn.softmax(logits)

...

with tf.Session() as sess:
    print(*[x.flatten().tolist() for x in sess.run(tf.nn.top_k(softmax, k=3))])

結果は

[0.9992352724075317, 0.00031209649750962853, 0.00014285057841334492] [10, 31, 18]

となり、確かにindex 10のものが0.999以上の値になっている。 つまり、この分類器からすると

f:id:sugyan:20160709235237j:plainf:id:sugyan:20160709224905p:plain

どちらも非常に高い確度で同じ人物だと認識する、ということになる。マジかよ。

こんなデタラメ模様のどこに特徴が隠れているんだ…。

実験

ちなみに一応、他のindexに最適化された画像もそれぞれ生成してみた。

f:id:sugyan:20160710001524p:plain

という感じで、どれもそれぞれ対応するindexに対し0.999くらいのsoftmax出力になる画像なのだけど、人間の目にはデタラメ模様にしか見えない。

ということは、ランダムな値ではなく別の画像を初期値として そこに人間の目には分からないような最適化を加えたものを作ることもできるわけで。 先ほどのマアヤさん (label_index: 10)の画像を変数の初期値として、異なるindexに最適化された画像を作ると

フラップガールズスクール・横山未蘭さん (label_index: 13)

f:id:sugyan:20160710002258p:plain

[0.9998733997344971, 4.3967520468868315e-05, 2.7816629881272092e-05] [13, 20, 31]

・じぇるの!・針谷早織さん (label_index: 24)

f:id:sugyan:20160710002305p:plain

[0.9999223947525024, 1.816232907003723e-05, 1.359641919407295e-05] [24, 4, 2]

というように、ちょっとしたノイズが載っているだけのように見えるけれど 分類器の結果は完全に別の人物としての高確度の識別をしてしまう。 不思議〜〜〜。

実際、顔画像を収集している中でたまにOpenCVによる顔検出で誤検出された壁紙の模様とか「まったく人間の顔ではないもの」が たまに高確度であるアイドルさんとして識別されることがあったりして不思議に思っていたけど、こういう結果を見るとまぁ起こり得るんだろうなぁと納得できる。むしろこんな意味わからん誤認識をするくせに94%とかの精度が出る方が不思議だわ…。

Source code

github.com