Theanoによる畳み込みニューラルネットワークの実装 (1)
Theanoによる多層パーセプトロンの実装(2015/6/18)のつづき。今回は、Deep Learning Tutorialの畳み込みニューラルネットワーク(Convolutional Neural Network: CNN, ConvNet)を実装してみる。
CNNは人間の視覚野を参考にした手法であり、画像認識に特化したDeep Learningアルゴリズムである。ImageNetの物体認識コンテストでぶっちぎりの成果を上げた手法はさまざまな工夫があるもののこのCNNをベースにしている。
本当は一般物体認識の実験をやりたいところだけどお楽しみは後に残しておいて、まずはMNISTの手書き数字認識を追試して感触をつかみたい。
ソースコード全体はここに置いた。
畳み込みニューラルネットワーク
まず今回実装する畳み込みニューラルネットワーク(CNN)の構成を図でまとめてみた(Deep Learning Tutorialの図は実装と関係ないじゃん・・・)
一番左の入力から分類結果を出力する右側まで畳み込み層(convolution layer)とプーリング層(pooling layer)を何回か繰り返したあと最後に全結合した多層パーセプトロンが配置される構成になっている。前回実験した(2015/6/18)多層パーセプトロンは一番右側の部分にあたる。なんか一気に複雑になった・・・理解できんのこれ?
というわけでチュートリアルの実装に即して自分なりに理解したことをまとめてみたい。
畳み込み層
畳み込み層は入力画像に対してフィルタをかける(畳み込む)層である。画像の畳み込みによって画像内のパターンが検出できるようになる。畳み込みの数式は参考文献に書いてあるがここでは省略。Theanoではtheano.tensor.nnet.conv.conv2d()
という関数が提供されているので実装する上ではあまり問題にならない。
先の図では28x28ピクセルの「7」という入力画像に対して5x5のそれぞれ異なるフィルタを20個かけて24x24の20枚の画像を出力している。フィルタをかけると画像サイズが少し小さくなる。入力画像サイズがでフィルタがだと出力画像サイズはになる。ここでは小数点以下切り下げて整数化する演算。今回はW=28, H=5なので上式で計算すると24になる。フィルタをかけた出力画像は特徴マップと呼ばれることが多いようだ。
フィルタは4次元テンソルW[20,1,5,5]
で表せる(テンソルの扱いnumpyのndarray
とほとんど同じ)この意味は5x5のフィルタで入力画像の枚数(チャネル数)が1で出力画像の枚数が20ということを意味している。MNISTのデータは白黒画像なので入力画像は1チャネルになる。もし入力がカラー画像のときはRGBの3チャネルである。今回の実装では1チャネルのフィルタが20枚使われる。フィルタを1つでなく、複数使うのがポイント。フィルタを増やすことで入力画像のさまざまな特徴を捉えられるようになる。
CNNがすごいのはこのフィルタを開発者が手動で設計するのではなく、学習によって自動獲得できるという点にある。最初、W
はランダムな値が入っていてよくわからない特徴にしか反応しないが、学習が進むと縦線や横線など画像認識に重要な特徴に強く反応するようになっていく。これがDeep Learningの力の源で表現学習と呼ばれる。これまでの説明からわかるようにCNNで更新(学習)するパラメータはフィルタW
になる。
学習する前のランダムな値が入ったフィルタを可視化すると下のようになる。5x5のサイズのフィルタが20個ある。ランダムなパターンでなんかよくわからない(笑)
CNNの学習が完了した後の同じ場所のフィルタを可視化すると下のようになる。
少しわかりにくいが縦線や斜めの線が強調されるフィルタパターンが学習されたのががわかる。この20個のフィルタを数字の「7」の画像に畳み込むと下のような畳み込み層の出力画像が得られる。先の図で左から2つめの24x24ピクセルの20枚の画像がこれである。
フィルタや畳み込み層の出力を可視化するコードはこの記事の最後に載せた。
プーリング層
プーリング層は畳み込み層の直後に置かれ、抽出された特徴の位置感度を低下させる働きがある。つまり、数字の「7」が画像の真ん中に書かれていても少し左や右にずれていても同じように「7」と判定するために必要ってこと。すごく難しそうだが処理自体は畳み込みよりずっと単純で2x2(poolsize
で指定)の矩形フィルタを入力画像内でずらしていき矩形内の最大の値を取り出して新しい画像を出力するような処理を行う。最大値に置き換える方法はmaxpooling
と呼ばれる。このプーリング層でも入力画像に比べて出力画像サイズは小さくなる。先の例では24x24の20枚の画像が12x12の20枚の画像になっている。画像のサイズは小さくなるが枚数は変わらない。Theanoではtheano.tensor.signal.downsample.max_pool_2d()
という関数が用意されている。数式や動作の詳細は参考文献参照。なんでプーリングって名前が付いたのかよくわからないな。
LeNetConvPoolLayerクラス
Deep Learning Tutorialでは、畳み込み層とプーリング層のペアをLeNetConvPoolLayer
というクラスで表現している。実際はペアである必要はなく、畳み込み層を3つ続けてプーリング層1つとかでもいいみたい。その場合はこのクラスは使えない。分けたほうがよいかもね。
LeNetというのは、畳み込みニューラルネットを発明したLeCunの最初のニューラルネットの名前に基づいているようだ。
class LeNetConvPoolLayer(object): """畳み込みニューラルネットの畳み込み層+プーリング層""" def __init__(self, rng, input, image_shape, filter_shape, poolsize=(2, 2)): # 入力の特徴マップ数は一致する必要がある assert image_shape[1] == filter_shape[1] fan_in = np.prod(filter_shape[1:]) fan_out = filter_shape[0] * np.prod(filter_shape[2:]) / np.prod(poolsize) W_bound = np.sqrt(6.0 / (fan_in + fan_out)) self.W = theano.shared( np.asarray(rng.uniform(low=-W_bound, high=W_bound, size=filter_shape), dtype=theano.config.floatX), # @UndefinedVariable borrow=True) b_values = np.zeros((filter_shape[0],), dtype=theano.config.floatX) # @UndefinedVariable self.b = theano.shared(value=b_values, borrow=T) # 入力の特徴マップとフィルタの畳み込み conv_out = conv.conv2d( input=input, filters=self.W, filter_shape=filter_shape, image_shape=image_shape) # Max-poolingを用いて各特徴マップをダウンサンプリング pooled_out = downsample.max_pool_2d( input=conv_out, ds=poolsize, ignore_border=True) # バイアスを加える self.output = T.tanh(pooled_out + self.b.dimshuffle('x', 0, 'x', 'x')) self.params = [self.W, self.b]
入力される画像がinput
でフィルターはW
で表現している。フィルタの値は多層パーセプトロンと同じくランダムな値で初期化される。b
はバイアスでプーリングの後に各画像に対して加えられる。b
はフィルタ数と同じ数の1次元配列なのでpooled_out
の4Dテンソルと足し合わせるようにdimshuffle
で次元を調整している。pooled_out
の2つめの次元がb
の次元数と一致するのがポイント。バイアスを加えたあとに活性化関数tanh
を適用している。活性化関数にはtanh
より高速で同じくらい高性能なReLU(Rectified Linear Unit)というのが最近ではよく使われるらしい。あとで置き換えて結果を比較してみたい。
Deep Learning Tutorialではプーリング層の出力に対してバイアスを加えて活性化関数を通すような実装になっているが、論文によっては畳み込み層の出力に対してバイアスを加えて活性化関数を通した後にプーリング層へというように説明されていることもある。どちらでもよいのかな?
このクラスは下のように使う。
# 入力 # 入力のサイズを4Dテンソルに変換 # batch_sizeは訓練画像の枚数 # チャンネル数は1 # (28, 28)はMNISTの画像サイズ layer0_input = x.reshape((batch_size, 1, 28, 28)) # 最初の畳み込み層+プーリング層 # 畳み込みに使用するフィルタサイズは5x5ピクセル # 畳み込みによって画像サイズは28x28ピクセルから24x24ピクセルに落ちる # プーリングによって画像サイズはさらに12x12ピクセルに落ちる # 特徴マップ数は20枚でそれぞれの特徴マップのサイズは12x12ピクセル # 最終的にこの層の出力のサイズは (batch_size, 20, 12, 12) になる layer0 = LeNetConvPoolLayer(rng, input=layer0_input, image_shape=(batch_size, 1, 28, 28), # 入力画像のサイズを4Dテンソルで指定 filter_shape=(20, 1, 5, 5), # フィルタのサイズを4Dテンソルで指定 poolsize=(2, 2))
先の図では入力画像は「7」だけでたった1つのように描いたが、学習するときはミニバッチ単位でbatch_size=500
枚の画像をまとめて渡す。このミニバッチ単位でフィルタのパラメータW
とバイアスb
を更新するためだ。
ここまでで先の図のlayer0
がやっと終わった。
2つめの畳み込み層とプーリング層
layer0
の出力は12x12ピクセルの画像(特徴マップ)が20枚になる。次はこの画像を入力としてlayer1
でさらに畳み込み層とプーリング層に通す。layer0
と異なり、複数チャネルの画像が入力される。
入力は20枚の画像から成るので20チャネルの画像と考えられる。この場合、畳み込みに使用するフィルタも20チャネル分用意する必要がある。複数チャネルの画像に複数チャネルのフィルタを畳み込むと1つの画像が出力される。はじめここを勘違いして理解するのに混乱していた・・・チュートリアルでは20チャネルのフィルタを50個用意しているので最終的に畳み込み層の出力として50個の画像(特徴マップ)が出力される。
フィルタは4次元テンソルW[50,20,5,5]
で表せる。この意味は5x5のフィルタで入力画像のチャネル数が20で出力画像のチャネル数が50ということを意味している。前と同様に畳み込むと画像のサイズは少し小さくなり、8x8ピクセルになる。
さらにこの50枚の画像をプーリング層に通すことでサイズが小さくなり、4x4ピクセルの画像が50枚出力される。
コードで書くと下のようになる。前と同じくLeNetConvPoolLayer
を再利用できる。入力はlayer0
の出力なのでlayer0.output
が指定されている。
# layer0の出力がlayer1への入力となる # layer0の出力画像のサイズは (batch_size, 20, 12, 12) # 12x12ピクセルの画像が特徴マップ数分(20枚)ある # 畳み込みによって画像サイズは12x12ピクセルから8x8ピクセルに落ちる # プーリングによって画像サイズはさらに4x4ピクセルに落ちる # 特徴マップ数は50枚でそれぞれの特徴マップのサイズは4x4ピクセル # 最終的にこの層の出力のサイズは (batch_size, 50, 4, 4) になる layer1 = LeNetConvPoolLayer(rng, input=layer0.output, image_shape=(batch_size, 20, 12, 12), # 入力画像のサイズを4Dテンソルで指定 filter_shape=(50, 20, 5, 5), # フィルタのサイズを4Dテンソルで指定 poolsize=(2, 2))
ここまでで先の図のlayer1
が終わった。
最後の多層パーセプトロン
畳み込みニューラルネットワークでは最後に全結合した多層パーセプトロンを配置して認識を行う。layer1
の出力は4x4ピクセルの画像が50枚である。2次元の画像のままでは多層パーセプトロンに入力できないので、4x4x50=800次元のベクトルに変換する。
# 隠れ層への入力 # 画像のピクセルをフラット化する # layer1の出力のサイズは (batch_size, 50, 4, 4) なのでflatten()によって # (batch_size, 50*4*4) = (batch_size, 800) になる layer2_input = layer1.output.flatten(2)
多層パーセプトロンは前回(2015/6/18)作成したHiddenLayerとLogisticRegressionクラスを組み合わせて実装する。チュートリアルでは入力層が800ユニット、隠れ層(layer2
)が500ユニット、出力層(layer3
)が10ユニット(MNISTの手書き数字認識なので0-9の10種類)の多層パーセプトロンを構成している。
# 全結合された隠れ層 # 入力が800ユニット、出力が500ユニット layer2 = HiddenLayer(rng, input=layer2_input, n_in=50 * 4 * 4, n_out=500, activation=T.tanh) # 最終的な数字分類を行うsoftmax層 layer3 = LogisticRegression(input=layer2.output, n_in=500, n_out=10)
確率的勾配降下法によるモデル訓練
モデルの訓練は多層パーセプトロンと同じく確率的勾配降下法によって行う。畳み込みニューラルネットワークは深い層構造を持つがプレトレーニング(各層を順番に学習して積み上げる方法)を使わずに古典的な勾配降下法だけで問題なく学習が行えることがわかっている。
いくつ層構造が積み重なってもTheanoの実装は非常にシンプル。
# コスト関数を計算するシンボル cost = layer3.negative_log_likelihood(y) # パラメータ params = layer3.params + layer2.params + layer1.params + layer0.params # コスト関数の微分 grads = T.grad(cost, params) # パラメータ更新式 updates = [(param_i, param_i - learning_rate * grad_i) for param_i, grad_i in zip(params, grads)] # index番目の訓練バッチを入力し、パラメータを更新する関数を定義 train_model = theano.function( [index], cost, updates=updates, givens={ x: train_set_x[index * batch_size: (index + 1) * batch_size], y: train_set_y[index * batch_size: (index + 1) * batch_size] })
更新すべきパラメータが増えているだけで他は多層パーセプトロンと同じ。Theanoの自動微分最高!Early-Stoppingによる収束判定はロジスティック回帰 (2015/5/26)のときとほとんど同じなので省略。
実行時間の計測
CPUとGPUで実行時間を比較してみよう。まず、CPUを使った場合。
Optimization complete. Best validation score of 0.910000 % obtained at iteration 19500, with test performance 0.920000 % The code for file convolutional_mlp.py ran for 552.67m
学習に552分かかった。約9時間!夜寝るまえに動かして朝起きたらまだ動いてた。その間、ずっとファンが回ってたので電気代が心配だよ・・・エラー率は0.92%なので正解率99.08%。多層パーセプトロンのエラー率が1.65%で正解率が98.35%なのでさらに超えてる。もうMNISTでは限界値に近いかな。
次にGPUを使って同じ学習をしてみる。
Optimization complete. Best validation score of 0.910000 % obtained at iteration 18500, with test performance 0.930000 % The code for file convolutional_mlp.py ran for 41.39m
学習は41分で終わった。CPUに比べて約13倍速い。畳み込みニューラルネットワークの実験にはGPUが必須だな。
フィルタと畳み込み層の出力の可視化
チュートリアルにはないけどおまけでフィルタと畳み込み層の出力を可視化するスクリプトを書いた。学習の最後でlayer0
とlayer1
のオブジェクトをcPickleでファイルにダンプしておくコードを追加した。
import cPickle cPickle.dump(layer0, open("layer0.pkl", "wb")) cPickle.dump(layer1, open("layer1.pkl", "wb"))
こうして学習結果をファイルにダンプしておけばまた最初から学習しなおさなくてすむ。学習したフィルタの重みとバイアスはこのオブジェクトのインスタンス変数W
やb
で取得できる。
汎用的ではないけどフィルタと畳み込み層(プーリング層ではない)の出力画像を可視化するスクリプト。学習したフィルタ重みとバイアスをもとに再度畳み込みニューラルネットを構築しなおしている。layer0.output
やlayer1.output
がそのまま使えると期待したけどTheanoのエラーが出てうまいくいかなかった。あとでもっとよい方法が思いついたら書き直すかも。
#coding: utf-8 import cPickle import pylab import matplotlib.pyplot as plt import theano import theano.tensor as T from theano.tensor.nnet import conv from theano.tensor.signal import downsample from logistic_sgd import load_data from convolutional_mlp import LeNetConvPoolLayer def visualize_filter(layer): """引数に指定されたLeNetConvPoolLayerのフィルタを描画する""" W = layer.W.get_value() n_filters, n_channels, h, w = W.shape plt.figure() pos = 1 for f in range(n_filters): for c in range(n_channels): plt.subplot(n_channels, n_filters, pos) plt.subplots_adjust(wspace=0.1, hspace=0.1) plt.imshow(W[f, c], cmap=pylab.cm.gray_r) plt.axis('off') pos += 1 plt.show() def feedforward(input, layer, image_shape): """inputをlayerに通した畳み込み層の結果(畳み込み層の結果を可視化するため)と 最終的な出力の特徴マップ(次の層への入力のため)を返す""" conv_out = conv.conv2d(input, filters=layer.W, filter_shape=layer.W.get_value().shape, image_shape=image_shape) pooled_out = downsample.max_pool_2d(input=conv_out, ds=(2, 2), ignore_border=True) output = T.tanh(pooled_out + layer.b.dimshuffle('x', 0, 'x', 'x')) return conv_out, output if __name__ == "__main__": layer0 = cPickle.load(open("layer0.pkl", "rb")) layer1 = cPickle.load(open("layer1.pkl", "rb")) print layer0, layer1 # 最初の畳み込み層のフィルタの可視化 visualize_filter(layer0) visualize_filter(layer1) # 各層の出力の可視化 input = T.tensor4() # 入力画像は1のためimage_shapeのbatch_size=1となる layer0_conv_out, layer0_out = feedforward(input, layer0, image_shape=(1, 1, 28, 28)) layer1_conv_out, layer1_out = feedforward(layer0_out, layer1, image_shape=(1, 20, 12, 12)) # 画像を1枚受けて、畳み込み層の出力を返す関数を定義 f0 = theano.function([input], layer0_conv_out) f1 = theano.function([input], layer1_conv_out) # 入力はテストデータから適当な画像を一枚入れる datasets = load_data("mnist.pkl.gz") test_set_x, test_set_y = datasets[2] input_image = test_set_x.get_value()[0] layer0_conv_image = f0(input_image.reshape((1, 1, 28, 28))) layer1_conv_image = f1(input_image.reshape((1, 1, 28, 28))) # 畳み込み層の出力画像を可視化 print layer0_conv_image.shape plt.figure() for i in range(20): plt.subplot(4, 5, i + 1) plt.axis('off') plt.imshow(layer0_conv_image[0, i], cmap=pylab.cm.gray_r) plt.show() print layer1_conv_image.shape plt.figure() for i in range(50): plt.subplot(5, 10, i + 1) plt.axis('off') plt.imshow(layer1_conv_image[0, i], cmap=pylab.cm.gray_r) plt.show()
参考文献
はっきり言ってDeep Learning TutorialのCNNの説明はわかりにくく、これだけでは理解できなかった。次のサイト、書籍、論文で何とか自分なりに理解できたと思う。比較して確認したから勘違いはしてないと思うのだけど・・・
- Theano で Deep Learning <3> : 畳み込みニューラルネットワーク - StatsFragments - Theanoで実装する上での補足説明がわかりやすい。
- 深層学習 (機械学習プロフェッショナルシリーズ) - 6章の説明が一番わかりやすい。
- 画像認識のための深層学習 - 上の本と同じく岡谷さんの解説論文。より専門的だが上の本の後に読んだらすんなり理解できた。
- CS231n Convolutional Neural Networks for Visual Recognition - Stanford大学の講義資料。途中のアニメーションがわかりやすいが、Deep Learning Tutorialと表記法が違うので混乱するかも。