Re:ゼロから始めるML生活

どちらかといえばエミリア派です

【写経】簡単なCNNを書いてみた

前回までは基本的なニューラルネットワークを勉強していました。

tsunotsuno.hatenablog.com

急に難しいことはできないので、次はCNNをやってみたいと思います。 今回も参考にしたのはこちらです。

CNN (Convolutional Neural Network)

ざっくりとした歴史背景

はじめにちょっとだけ歴史的な背景を書いておくと、最近やたらAIだのディープラーニングだのが流行っていますが、その火付け役がCNN (Convolutional Neural Network)です。

もともとニューラルネットワークの基礎理論自体は大昔(1950年代)からありました。 1980年代後半になって程度ディープラーニングの理論は確立してきましたが、当時のマシンパワーでは全く歯が立ちませんでした。 そんな中理論が先行して、1990年台にはCNNの原型が論文発表されました。 しかし、それでもマシンパワーが足りず、目立った結果を出せませんでした。

そこに、GPUコンピューティングが爆発的に研究されるようになり、計算性能が飛躍的に向上しました。 そして、2012年にGPUとCNNを組み合わせたAlexNetが発表され、機械学習がうまくいくことが実証されました。 こうして現在のアホみたいな機械学習の盛り上がりに繋がったというわけです。

ネットワークの構成

ニューラルネットワークで画像認識なんかをすると、一旦二次元の画像を一次元に並べ直すため、全ての画素が平等に取り扱われてしまいます。 CNNの最大の利点としては、画像の形状を二次元のまま取り扱うことができるため、二次元の情報を考慮した学習ができることです。

基本的なニューラルネットワークは全てのノードがすべて結合しています。

f:id:nogawanogawa:20180114141523j:plain

これがCNNになるとこんな感じになります。

f:id:nogawanogawa:20180114201844j:plain

これでどんな学習がされるかを表したわかりやすい図がこちらです。

f:id:nogawanogawa:20180114203439j:plain

Machine Learning, Neural Networks and Algorithms – Chatbots Magazine

なんとなく、物体の輪郭を学習している層、車のパーツを学習している層、全体像を学習していることがわかります。 ふわっと伝わってれば問題ないです。

さて、ざっくりとCNNの特徴を抑えたところで実装について考えます。 CNNでは、前回使っていたAffine層の代わりにConvolution層とPooling層を使用します。

前回作っていた普通のNNのイメージとしてはこんな感じ。

f:id:nogawanogawa:20180120093536j:plain

そんでもって、今回作ってみるCNNはこんな感じ。

f:id:nogawanogawa:20180120093557j:plain

活性化関数がSigmoid関数からReLU関数に変更されています。 先人の知恵で、こっちの方がうまくいくみたいです。

こんな感じに前使ってた層をガチャンと外して、新しくConvolution層とPooling層をガッチャンコすればCNNになります。

Convolution層

まずはConvolution層について見ていきます。

forward

Convolution層では積和演算というのをやっていて、イメージで書くとこんな感じの演算です。

f:id:nogawanogawa:20180121093555j:plain

試しに出力を計算すると、

{ \displaystyle
d_1=a_1 \cdot b_1 + a_2 \cdot b_2 + a_3 \cdot b_3 + a_5 \cdot b_4 + a_6 \cdot b_5 + a_7 \cdot b_6 + a_9 \cdot b_7 + a_{10} \cdot b_8 + a_{11}\cdot b_9 + c
}

入力された信号とフィルターの対応する位置の要素同士をかけ合わせて最後にバイアスを含めて足し合わせます。 99%写経した中身を見てみるとこんな感じです。

def forward(self, x):
    # フィルターと出力の形状のセットアップ                                                                                                                  
    FN, C, FH, FW = self.W.shape
    N, C, H, W = x.shape
    out_h = 1 + int((H + 2*self.pad - FH) / self.stride)
    out_w = 1 + int((W + 2*self.pad - FW) / self.stride)

    # フィルターのサイズに分割、1次元のベクトルに整形                                                                                                       
    col = im2col(x, FH, FW, self.stride, self.pad)
    col_W = self.W.reshape(FN, -1).T

    # ベクトルの積和演算                                                                                                                                    
    out = np.dot(col, col_W) + self.b

    # 出力の転置(並べ替え)                                                                                                                                
    out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)

    # 元の形状を記憶                                                                                                                                       
    self.x = x
    self.col = col
    self.col_W = col_W

    return out
backward

逆伝播に関しては、計算された重みに対して誤差の分をかけ合わせてネットワークの重みを修正します。

def backward(self, dout):
    FN, C, FH, FW = self.W.shape
    dout = dout.transpose(0,2,3,1).reshape(-1, FN)

    # affine層と同様の逆伝播                                                                                                                                
    self.db = np.sum(dout, axis=0)
    self.dW = np.dot(self.col.T, dout)
    self.dW = self.dW.transpose(1, 0).reshape(FN, C, FH, FW)

    dcol = np.dot(dout, self.col_W.T)
    dx = col2im(dcol, self.x.shape, FH, FW, self.stride, self.pad)

    return dx

Pooling層

次にPooling層について見てみます。

pooling層では対応する領域内から最大値を取得していきます。 イメージとしてはこんな感じです。

f:id:nogawanogawa:20180121100043j:plain

forward

写経した中身を見てみると、行列の次元の変更等はありますがその他は最大値を抽出しているだけです。

    def forward(self, x):
        # 出力の形状を計算                                                                                                                                      
        N, C, H, W = x.shape
        out_h = int(1 + (H - self.pool_h) / self.stride)
        out_w = int(1 + (W - self.pool_w) / self.stride)

        # 2D => 1D                                                                                                                                              
        col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
        col = col.reshape(-1, self.pool_h*self.pool_w)

        # max pooling                                                                                                                                          
        arg_max = np.argmax(col, axis=1)
        out = np.max(col, axis=1)
        out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)

        self.x = x
        self.arg_max = arg_max

        return out
backward

Pooling層は重みを持たないので、活性化関数なんかと同じで逆伝播の際には次の層の入力を求めるだけです。 最大値が出力された要素にロスの分を格納して逆伝播させます。

    def backward(self, dout):
        dout = dout.transpose(0, 2, 3, 1)

        # 逆伝播されてきた誤差を最大値だった要素に格納して                                                                                                      
        # 逆伝播する                                                                                                                                      
        pool_size = self.pool_h * self.pool_w
        dmax = np.zeros((dout.size, pool_size))
        dmax[np.arange(self.arg_max.size), self.arg_max.flatten()] = dout.flatten()
        dmax = dmax.reshape(dout.shape + (pool_size,))

    dcol = dmax.reshape(dmax.shape[0] * dmax.shape[1] * dmax.shape[2], -1)
        dx = col2im(dcol, self.x.shape, self.pool_h, self.pool_w, self.stride, self.pad)

        return dx

評価

気になるところは、「CNNってどんくらいの精度がでんねん」ってとこだと思うので、実行してみます。

f:id:nogawanogawa:20180121163942p:plain

前回の普通のNNが16epochで精度が97%越えるくらい、今回は99% 超えとかなので若干いいくらいでしょうか。 MNISTなのであんまり参考にはなりませんが、まぁこんなもんでしょう。 もうちょい複雑なことやらないと良さは実感できないってことですね。

参考

今回もちゃんとやった証拠にgithubにあげました。証拠を残す意味なので意味なので特に意味はありません。

https://github.com/nogawanogawa/SimpleCNN.git