人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

Theanoによる畳み込みニューラルネットワークの実装 (1)

Theanoによる多層パーセプトロンの実装(2015/6/18)のつづき。今回は、Deep Learning Tutorialの畳み込みニューラルネットワーク(Convolutional Neural Network: CNN, ConvNet)を実装してみる。

CNNは人間の視覚野を参考にした手法であり、画像認識に特化したDeep Learningアルゴリズムである。ImageNetの物体認識コンテストでぶっちぎりの成果を上げた手法はさまざまな工夫があるもののこのCNNをベースにしている。

本当は一般物体認識の実験をやりたいところだけどお楽しみは後に残しておいて、まずはMNISTの手書き数字認識を追試して感触をつかみたい。

ソースコード全体はここに置いた。

畳み込みニューラルネットワーク

まず今回実装する畳み込みニューラルネットワーク(CNN)の構成を図でまとめてみた(Deep Learning Tutorialの図は実装と関係ないじゃん・・・)

f:id:aidiary:20150626203849p:plain

一番左の入力から分類結果を出力する右側まで畳み込み層(convolution layer)プーリング層(pooling layer)を何回か繰り返したあと最後に全結合した多層パーセプトロンが配置される構成になっている。前回実験した(2015/6/18)多層パーセプトロンは一番右側の部分にあたる。なんか一気に複雑になった・・・理解できんのこれ?

というわけでチュートリアルの実装に即して自分なりに理解したことをまとめてみたい。

畳み込み層

畳み込み層は入力画像に対してフィルタをかける(畳み込む)層である。画像の畳み込みによって画像内のパターンが検出できるようになる。畳み込みの数式は参考文献に書いてあるがここでは省略。Theanoではtheano.tensor.nnet.conv.conv2d()という関数が提供されているので実装する上ではあまり問題にならない。

先の図では28x28ピクセルの「7」という入力画像に対して5x5のそれぞれ異なるフィルタを20個かけて24x24の20枚の画像を出力している。フィルタをかけると画像サイズが少し小さくなる。入力画像サイズがW \times WでフィルタがH \times Hだと出力画像サイズは W - 2 \lfloor H/2 \rfloor \times W - 2 \lfloor H/2 \rfloorになる。ここで\lfloor \cdot \rfloorは小数点以下切り下げて整数化する演算。今回は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個ある。ランダムなパターンでなんかよくわからない(笑)

f:id:aidiary:20150626220554p:plain

CNNの学習が完了した後の同じ場所のフィルタを可視化すると下のようになる。

f:id:aidiary:20150626220604p:plain

少しわかりにくいが縦線や斜めの線が強調されるフィルタパターンが学習されたのががわかる。この20個のフィルタを数字の「7」の画像に畳み込むと下のような畳み込み層の出力画像が得られる。先の図で左から2つめの24x24ピクセルの20枚の画像がこれである。

f:id:aidiary:20150626220932p:plain

フィルタや畳み込み層の出力を可視化するコードはこの記事の最後に載せた。

プーリング層

プーリング層は畳み込み層の直後に置かれ、抽出された特徴の位置感度を低下させる働きがある。つまり、数字の「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が必須だな。

フィルタと畳み込み層の出力の可視化

チュートリアルにはないけどおまけでフィルタと畳み込み層の出力を可視化するスクリプトを書いた。学習の最後でlayer0layer1のオブジェクトをcPickleでファイルにダンプしておくコードを追加した。

    import cPickle
    cPickle.dump(layer0, open("layer0.pkl", "wb"))
    cPickle.dump(layer1, open("layer1.pkl", "wb"))

こうして学習結果をファイルにダンプしておけばまた最初から学習しなおさなくてすむ。学習したフィルタの重みとバイアスはこのオブジェクトのインスタンス変数Wbで取得できる。

汎用的ではないけどフィルタと畳み込み層(プーリング層ではない)の出力画像を可視化するスクリプト。学習したフィルタ重みとバイアスをもとに再度畳み込みニューラルネットを構築しなおしている。layer0.outputlayer1.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の説明はわかりにくく、これだけでは理解できなかった。次のサイト、書籍、論文で何とか自分なりに理解できたと思う。比較して確認したから勘違いはしてないと思うのだけど・・・