畳み込みニューラルネットワーク(CNN)でマイクラのバイオーム識別
Hello, everyone!
今日はTensorFlow公式チュートリアルを参考にしてMinecraftのバイオーム16種を識別させるよ!
前準備・予備知識
私はWindows機にUbuntuの仮想環境を突っ込んで動かしていますが、 その辺の周辺環境の話は一旦おいておきます。これは後々記事にしましょう。
まずは今回TensorFlowで設計する畳み込みニューラルネットワーク(Convolutional Neural Network, 通称"CNN")と、これで識別するMinecraftのバイオームがどのようなものかを説明していきます。
畳み込みニューラルネットワーク(CNN)
CNNでは2次元上の特徴抽出を行うことが最大の特徴で、画像認識の分野で広く使われています。
対して、おそらく最も知られているニューラルネットワークの多層パーセプトロンでは入力は1次元として扱われます。
今回設計する学習器は2次元上の特徴を拾った後、それらを1次元化して通常の多層パーセプトロンに放り込みます。とりあえず今はこれだけ理解しておけば十分でしょう。
Minecraft
海外発の有名なサンドボックスゲーム。ランダムに生成される世界は"バイオーム"と呼ばれる地形や気候に分けられるので、これをスクリーンショットから識別するのが今回の目的です。
細かく分けるとかなりの種類あるようですが、主要バイオーム16種に限定します。どうあがいても識別できない例と、学習データの収集が難しい例は除外しましょう。
コーディング
細かいところはすっ飛ばして、とりあえずコードの浅い部分から深い部分までを順に追えるようになるのが今回の目標です!理屈で理解するのは後から!
基本的に1つのグラフで1つの学習器を構成するようです(上図参照)。複数のグラフで構成することは出来ないっぽいですが、どうなんでしょうね。
グラフはセッションと結びついて初めて各デバイス上で実行できるようになります。もっとも、最初にデフォルトグラフを設定して普通にセッションを作れば自動で結び付けられるので、この辺を意識することは少ないです。
実際に学習を行う部分はtf_main.pyのstep回してる部分に詰め込んであります。それでは、ここから遡って解説します。
今回書いたコードや利用する訓練データ・テストデータはこちら。利用はご自由にどうぞ。
データの形式はCIFAR-10を参考にしました。
53 : _, loss_value, accuracy_value = sess.run([train_op, loss, accuracy])
tf.Session.run()ではエッジとノードを"実行"することができます。何が違うんでしょう?と思ってたら、ちゃんとAPIに書いてありました。
ノードの実行は値を返さず、エッジの実行は値が返るようです。TensorFlowでTensorと呼ばれているものはエッジに当たるので、言われてみればその通りですね。
この行では学習・コスト関数の計算・適合率の計算を一度に行っています。まずは学習の中身から。
学習器の設計
34 : train_op = tf_model.train(loss, global_step)
train_opの中身がどこで返ってきてるのかを見ると、ちょっと上の行で外部プログラムに投げていました。lossはコスト関数のエッジなので、それを先に見ていきます。
lossは33行目で適合率と一緒に返ってきてます。lossとaccuracyの中身を作るのにlogitsとlabelsを使います。
で、logitsは学習器に従って入力を出力に変換するエッジになります。学習器の入力になるimagesと期待される正しい出力を意味しているlabelsは31行目で外部ファイルから取得します。
この部分はキューやらバッチやらでややこしいことになっているので、32行目を追っていきましょう。
56 : # conv_1
57 : with tf.variable_scope("conv1") as scope:
58 : kernel = _get_variable("weights", [7, 7, 3, 64], tf.truncated_normal_initializer(stddev=1e-4), 0.0)
59 : conv = tf.nn.conv2d(images, kernel, strides=[1, 1, 1, 1], padding="SAME")
60 : bias = _get_variable("biases", [64], tf.constant_initializer(0.0), 0.0)
61 : conv1 = tf.nn.relu(tf.nn.bias_add(conv, bias), name=scope.name)
62 : _activation_summary(conv1)
63 : _image_summary(tf.slice(conv1, [0, 0, 0, 0], [1, 64, 64, 64]))
さて、やっとニューラルネットワークの本体が見えてきました。入力に近い側から1層ずつ設計していきます。
58行目で畳み込みに使うフィルタをkernelに入れておきます。呼び出している関数_get_variableで実際にフィルタが作られています。
同時にL2正則化したコスト関数を定義することもできますが、それはフィルタには適用しません。
59行目のtf.nn.conv2dはimagesをkernelで畳み込みます。stridesはimagesを畳み込む間隔で、例えば[1, 3, 3, 1]のように指定するとimagesが3分の1の大きさになって返ってきます。
60行目でバイアスを作ります。畳み込みで64の特徴を抽出しているので、バイアスもそれと同じ数だけ用意します。
最後に61行目で畳み込まれた画像にバイアスを足し合わせた後、ランプ関数を適用して出てきた値を次の層に渡します。以降はTensorBoardで可視化させたい値を出力する処理です。
65 : # pool_1
66 : pool1 = tf.nn.max_pool(conv1, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding="SAME", name="pool1")
66行目で最大プーリングを行っています。stridesの間隔でksizeの範囲内から最も大きい値を取り出し、結果的に畳み込み層と同じくstridesで指定された分だけ画像は小さくなります。
68 : # norm_1
69 : norm1 = tf.nn.lrn(pool1, 4, bias=1.0, alpha=1e-4, beta=0.75, name="norm1")
69行目でLocalResponseNormalization(LRN)を適用します。CrossChannelNormalizationとも言います。日本語だと何て書くんでしょう?
自身を含む前後4チャネルの計9チャネルで色々やって正規化します。チャネルってのは特徴のことですね。これを64の特徴すべてに対して行っています。
71 : # local_2
72 : with tf.variable_scope("local2") as scope:
73 : dim = 1
74 : for d in norm1.get_shape()[1:].as_list():
75 : dim *= d
76 : reshape = tf.reshape(norm1, [FLAGS.batch_size, dim])
77 : weights = _get_variable("weights", [dim, 256], tf.truncated_normal_initializer(stddev=0.04), 0.004)
78 : biases = _get_variable("biases", [256], tf.constant_initializer(0.1), 0.0)
79 : local2 = tf.nn.relu(tf.matmul(reshape, weights) + biases, name=scope.name)
80 : _activation_summary(local2)
抽出された64の2次元の特徴をすべてまとめて1次元にします。用意すべき配列の大きさは元画像の大きさで決まるので、ちゃんと計算しますよ。
その後は重みの定義→バイアスの定義→ランプ関数の適用の順で進みます。_get_variableにwd=0.004を渡しているので、この重みのコスト関数にL2正則化が適用されます。
余談ですが、ニューラルネットワークの世界で"次元"と言うと色々意味があって混乱するからなのか、画像が2次元って言い方はあまりしないらしいですね。
単に次元と言えば普通は層のユニットの数を指すようです。例えばこのlocal2は256次元の層、って感じに。
82 : # local_3
83 : with tf.variable_scope("local3") as scope:
84 : weights = _get_variable("weights", [256, 128], tf.truncated_normal_initializer(stddev=1/256), 0.004)
85 : biases = _get_variable("biases", [128], tf.constant_initializer(0.1), 0.0)
86 : local3 = tf.nn.relu(tf.matmul(local2, weights) + biases, name=scope.name)
87 : _activation_summary(local3)
88 :
89 : # softmax
90 : with tf.variable_scope("softmax_linear") as scope:
91 : weights = _get_variable("weights", [128, NUM_CLASSES], tf.truncated_normal_initializer(stddev=1/128), 0.0)
92 : biases = _get_variable("biases", [NUM_CLASSES], tf.constant_initializer(0.0), 0.0)
93 : softmax_linear = tf.add(tf.matmul(local3, weights), biases, name=scope.name)
94 : _activation_summary(softmax_linear)
95 :
96 : return softmax_linear
後は256次元→128次元→16次元で学習器本体の設計は完了です。出力層の次元は分類すべきクラスの数と同じです。
出力層では活性化関数を通さずに、重みをかけてバイアスを足したものをそのまま出力としています。理由は後々tf.nn.softmax_cross_entropy_with_logitsで出力層の交差エントロピーを楽に計算するため。
その計算式を把握していれば自力で計算することも出来ますが、私の場合は何故かできませんでした...後でじっくりやってみます。
コスト関数の計算
32 : logits = tf_model.inference(images)
33 : loss, accuracy = tf_model.loss(logits, labels)
tf_model.inferenceから返ってきたエッジを見ることで学習器の出力を得られるので、これを使って今度はコスト関数と適合率を求めます。
99 : sparse_labels = tf.reshape(labels, [FLAGS.batch_size, 1])
100 : indices = tf.reshape(tf.range(0, FLAGS.batch_size), [FLAGS.batch_size, 1])
101 : concated = tf.concat(1, [indices, sparse_labels])
102 : dense_labels = tf.sparse_to_dense(concated, [FLAGS.batch_size, NUM_CLASSES], 1.0, 0.0)
labelsは[FLAGS.batch_size]の配列なので、後の処理で使えるようにするために[FLAGS.batch_size, NUM_CLASSES]に変換します。変換方法自体は単純で、例えばlabels[i]の値が5だったらdense_labels[i, 5]の値を1.0にして、他の部分は0.0になります。
関数の仕様のせいか、かなり複雑な処理になってしまっています。
104 : # cross_entropy
105 : cross_entropy = tf.nn.softmax_cross_entropy_with_logits(logits, dense_labels, name="cross_entropy_per_example")
106 : cross_entropy_mean = tf.reduce_mean(cross_entropy, name="cross_entropy")
107 : tf.add_to_collection("losses", cross_entropy_mean)
・・・
113 : return tf.add_n(tf.get_collection("losses"), name="total_loss"), accuracy
105行目はソフトマックス関数の適用と交差エントロピーの計算を同時に行っています。バッチに含まれる1組毎の値が出力されるので、全ての値の平均を取って実数値とします。
113行目でコスト関数を返すのですが、それにはL2正則化を適用したい重みに対して正則化項を加えた値も含まれています。
109 : # accuracy
110 : correct_prediction = tf.equal(tf.argmax(logits, 1), tf.argmax(dense_labels, 1))
111 : accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))
ここで訓練用データに対する学習器の性能を計算します。
機械学習の世界でAccuracyと言えば正解率、つまり学習器の出力と実際の答えが一致している割合を指します。
性能を表す数値は一般的には正解率ですが、処理するデータの性質によっては適合率(Precision)や再現率(Recall)を取った方がいい場合もあるようです。
トレーニング
34 : train_op = tf_model.train(loss, global_step) (tf_main.py)
↓
130 : lr = tf.train.exponential_decay(INITIAL_LEARNING_RATE, global_step, decay_steps, LEARNING_RATE_DECAY_FACTOR, staircase=True)
131 : tf.scalar_summary("learning_rate", lr)
132 :
133 : loss_averages_op = _add_loss_summaries(total_loss)
学習器のトレーニングでは先に説明したコスト関数の値が出来るだけ小さくなるように、重みやバイアスの値を更新します。
値の更新には「どの方向に(勾配)」「どれくらい(学習率)」の2つの情報が必要で、130行目でtf.train.exponential_decayを使って計算しているのは学習率の方です。
学習率は固定としても問題はないのですが、より効率的にコスト関数を収束させられるように、トレーニングが一定回進んだら学習率を減らすような仕組みになっています。
最初は大雑把に学習し、時間が経つにつれて丁寧に学習するようなイメージ。
131行目と133行目ではそれぞれ学習率とコスト関数の値をTensorBoardに出力しています。
135 : with tf.control_dependencies([loss_averages_op]):
136 : opt = tf.train.GradientDescentOptimizer(lr)
137 : grads = opt.compute_gradients(total_loss)
138 :
139 : apply_gradient_op = opt.apply_gradients(grads, global_step=global_step)
tf.control_dependenciesを使うと、sess.runで実行する際の依存関係を定義できます。
例えばここのwithステートメント以下2行を実行しようとすると、先にloss_averages_opが実行される、といった具合に。
トレーニングの方法にはいくつかの種類があって、今回は最急降下法(GradientDescent)を使用しています。 でも具体的にどんなアルゴリズムを使っているのかは良く分からなかった...
ランダムにシャッフルしたミニバッチで学習しているので、おそらく確率的勾配降下法をミニバッチ学習に応用したものだと思われます。最急降下法だけだと局所最適解に収束しがちなので、本来はオンライン学習のアルゴリズムである確率的勾配降下法をこんな形で応用することがあります。
この方法に従って137行目で勾配を計算します。そして139行目で実際に重みやバイアスを更新しています。
この2行はopt.minimizeで1行に纏めてしまうことも可能ですが、ここでは個々の勾配をTensorBoardに出力したいので分割しています。
141 : for var in tf.trainable_variables():
142 : tf.histogram_summary(var.op.name, var)
143 :
144 : for grad, var in grads:
145 : if grad:
146 : tf.histogram_summary(var.op.name + "/gradients", grad)
特に何もせずにTensorBoardに出力します。144行目の処理で勾配のデータが必要なので、opt.minimizeとせずに分割したんでしたね。
148 : #variable_averages = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY, global_step)
149 : #variables_averages_op = variable_averages.apply(tf.trainable_variables())
この辺は...詳しくは触れないでおきます。
TensorFlowでは複数回にわたって訓練することを想定していて、重みやバイアスなどの値を記憶しておいて後で読み出す(チェックポイント)という使い方をサポートしています。
今回のプログラムでは学習器の評価を別プログラムに分けているので、この機能を使って値をそちらに渡したいのですが、その際にひと工夫加えているのがこの2行の処理です。
ところが私がインストールしたTensorFlowのバージョンだと必要な関数がサポートされておらず、コメントアウトしても特に問題ない状態になってます。
151 : with tf.control_dependencies([apply_gradient_op]):
152 : train_op = tf.no_op(name="train")
153 :
154 : return train_op
tf.no_opは何もしないノードを返します。これに重みとバイアスの更新を行うノードとの依存関係を定義した後で、最終的に何もしない方のノードを呼び出し元に返しています。
36 : saver = tf.train.Saver(tf.all_variables())
37 : summary_op = tf.merge_all_summaries()
38 :
39 : sess = tf.Session(config=tf.ConfigProto(log_device_placement=False))
40 : with sess.as_default():
41 : if os.path.exists(FLAGS.train_dir + "checkpoint"):
42 : ckpt = tf.train.latest_checkpoint(FLAGS.train_dir)
43 : saver.restore(sess, ckpt)
44 : else:
45 : tf.initialize_all_variables().run()
46 :
47 : tf.train.start_queue_runners()
48 :
49 : summary_writer = tf.train.SummaryWriter(FLAGS.train_dir)
ここからは学習前に必要なちょっとした処理を実行しておきます。
36行目でチェックポイントを出力したい値のリストをtf.train.Saverに渡します。今回は特に工夫せずに、全ての値を出力します。
37行目でこれまでTensorBoardに出力するために作ったノードを全てまとめます。このsummary_opを実行することで出力したい値を一度に得ることができます。
39行目でセッションを定義して、40行目でデフォルトセッション化します。仕様を見るとデフォルトセッションを作るメリットは特に感じられないのですが、とりあえずやっておきましょう。
それとは別に、セッションで実行する部分をwithでハッキリ括れるメリットはありますが。
41~45行目で値の初期化を行います。すでにチェックポイントがあるならそれを読み込み、無ければ通常の初期化を行います。
47行目ではキューを操作するためのスレッドを開始させます。今回はデータからミニバッチを作る際にキューを使っています。
49行目でTensorBoardに出力したい値を書き出すために、tf.train.SummaryWriterに書き出す場所を渡しておきます。
厳密に言うと、その値はイベントファイルと言うファイルに独自の形式で書き出され、それをTensorBoard側で読み出すことになります。
51 : for step in xrange(global_step.eval()+1, FLAGS.max_steps):
52 : start_time = time.time()
53 : _, loss_value, accuracy_value = sess.run([train_op, loss, accuracy])
54 : duration = time.time() - start_time
55 :
56 : if step % 5 == 0:
57 : examples_per_sec = FLAGS.batch_size / duration
58 : sec_par_batch = float(duration)
59 : format_str = ("%s: step %d, loss = %.2f, accuracy = %.2f (%.1f examples/sec; %.3f sec/batch)")
60 : print (format_str % (datetime.now(), step, loss_value, accuracy_value, examples_per_sec, sec_par_batch))
61 :
62 : summary_str = summary_op.eval()
63 : summary_writer.add_summary(summary_str, step)
64 :
65 : if step % 50 == 0 or (step+1) == FLAGS.max_steps:
66 : checkpoint_path = os.path.join(FLAGS.train_dir, "model.ckpt")
67 : saver.save(sess, checkpoint_path, global_step=step)
68 :
69 : sess.close()
いよいよトレーニングを実行する部分です。
実行回数はとりあえず30000ステップにしてあります。何度かに分けてトレーニングを実行する場合は、チェックポイントに保存されていたglobal_stepの値を見ることで既に経過したステップ数を得ることができます。
53行目でトレーニングの実行とコスト関数・学習器の性能の評価を行います。その前後で現在時刻を取得して、トレーニングにかかった時間を計測しています。
デフォルトセッションを設定しているのでtrain_op.run()と言った感じで1つずつ実行することも可能ですが、何故か3つまとめてしまった方が学習時間が短く済みます。セッションの準備に意外と時間がかかるのでしょうか?
56行目以降は5ステップごとにコスト関数や経過時間を表示する処理と、50ステップごとにチェックポイントを書き出す処理になります。
最後に69行目でセッションを閉じて終了!
と言っても30000ステップ経過した瞬間プログラムが終わるので特に意味はありませんが、公式APIにちゃんと閉じるようにと書いてあったので従っておきます。
実行結果
Accuracy : 98.1%
Precision / Recall :
score\label | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
Precision | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 0.90 | 1.00 | 0.90 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 0.91 |
Recall | 1.00 | 0.90 | 1.00 | 0.89 | 0.89 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
Confusion :
true\pred | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
0 | 783 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 721 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 80 |
2 | 0 | 0 | 826 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
3 | 0 | 0 | 0 | 738 | 0 | 0 | 0 | 0 | 84 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
4 | 0 | 0 | 0 | 0 | 720 | 0 | 90 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
5 | 0 | 0 | 0 | 0 | 0 | 803 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
6 | 0 | 0 | 0 | 0 | 0 | 0 | 814 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
7 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 799 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
8 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 782 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
9 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 798 | 0 | 0 | 0 | 0 | 0 | 0 |
10 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 785 | 0 | 0 | 0 | 0 | 0 |
11 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 770 | 0 | 0 | 0 | 0 |
12 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 786 | 0 | 0 | 0 |
13 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 798 | 0 | 0 |
14 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 809 | 0 |
15 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 814 |
(ラベルに対応するバイオーム名は0から順番にOcean, Plains,
Desert, ExtremeHills, Forest, BirchForest, RoofedForest, Swampland,
IcePlains, Jungle, Taiga, MegaTaiga, Savanna, Mesa, Hell, End)
考察
Accuracyは目標値の96%に達していたので、結果としては成功かな?訓練データとテストデータの差は1%程度だったので、過学習もそこそこ上手く避けられてるかと。
ほとんどのバイオームはPrecision/Recallともに100%で、分類ミスしたのがPlain→End、ExtremeHills→IcePlains、Forest→RoofedForestの3パターン。
DesertをEndと誤分類するパターンを一番想定していたので、この結果は意外でした。Endを構成しているブロックの緑成分が思ったより多かったのかな...
ExtremeHillsには雪が積もっている亜種があるので、その辺が紛らわしかった可能性。ForestとRoofedForestは単に似ているからかな?
何にせよ、明らかにデータが足りませんでした。1バイオーム当たり100枚集めようとしたけど心が折れた。Googleが説くデータの重要性を肌で感じられたような気がする。
初期学習率0.1だと明らかに高すぎて安定しないので0.05開始。10000ステップごとに0.1倍の設定にしてみたものの、予想以上のペースで収束したので6000ステップ辺りで手動で0.005に変更。後で考えるとちょっと早かったかも。
やっぱり自動で学習率を調整できるように、トレーニングにはAdaGradやAdamを使った方がいいね。
TensorFlowは機械学習系ライブラリ特有の癖が大きいけど、慣れてくると面白みがあって良いかな。先発のTheanoやTorchと比べるとソフトな設計ができるようになってるのかな?と言った印象。そして何よりTensorBoardが使える。これは大きい!
ただし、ハードな設計が必要な学習器はちょっと作りにくい。いわゆる概念設計的なレベルから気を使わないといけないって感じはする。
今回、データを集めやすいという理由でMinecraftを取り上げてみて、これは正解だったなと思います。リアルの写真とかだとどうしても収集がネックになってしまうので。その辺が大手検索サイトの強みなんだろうな...
とか言ってたらMicrosoftがMinecraft上でAI動かし始めたんですけど...なんか先越されちゃったなぁ(・ω・`)
オープンソースらしいから公開されたら使ってみよっと!
課題
今回は大まかな流れを中心に解説したので、次回以降は細かい部分も見ていきたい。データ入力の部分とか完全にノータッチだったし。
もうちょっと複雑なデータを分類させたい。学習器作り出す前からバイオーム分類が簡単すぎることには気づいていたけど...せめて生物認識ぐらいはさせようよ。
教師なし学習系のアルゴリズムも面白そうだからやってみたい。どっちかと言うとそれ関係の独自研究を元々やってたからこのブログ始めたってのもあるんだけど。
あと、GPU欲しい。これは一番切実な問題...
次は畳み込み・プーリング層の解説をしようかな。これは計算式より図示された方が圧倒的に分かりやすいので。
今月中の投稿は忙しいからちょっと難しいかも。来月までモチベ維持出来たらまた会えますね。
それでは、See you next time!