キカベン
機械学習でより便利な世の中へ
G検定対策
お問い合わせ
   

自作したフィルタによる畳み込みニューラルネットワークで数字画像を識別

thumb image

CNNを人にわかるように説明しようとすると結構込み入っている。なので今回は簡単なCNNをフィルタを自作しながら解説してみました。自分の頭の中の整理にもなりました。

1. 画像データ🔝

この8x8のデータにある数字を識別します。

数字の7

画像として表示するとこうなります。

import copy # あとで使う
import numpy as np
import matplotlib.pyplot as plt

image1 = np.float32([
    [0, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 0, 0, 0, 0, 1, 0],
    [0, 0, 0, 0, 0, 1, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0],    
    [0, 0, 0, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 0, 0, 0, 0],
])

plt.imshow(image1, cmap='gray')
plt.show()
数字の7の画像

これは間違いなく数字の7ですね。人間はすぐに認識できます。

CNNを訓練すれば認識できますが、CNNがフィルタ(カーネル)を自動で作るので実際どんなことになっているのか分かりにくい。よく使いますが、細かいことは忘れがちなCNNの内部。今回は自作したフィルタによるCNNで画像識別してみました。

2. フィルタ(カーネル)🔝

縦の線を抽出するためのフィルタとして以下の3x3のフィルタを準備しました。

縦線のためのカーネル

なぜこんな数値の配列になっているかというと、縦の線は1が縦に並んでおりその周りは0になっているという想定をしたからです。例えば、画像中央したの部分に縦線があります。これにフィルタを適用すると3という結果になります。

畳み込み①

縦の線がないところに適用してみます。画像の中央上部での結果は-1でした。このフィルタにはそれなりの効果があるようです。

畳み込み②

同様に横線用と斜線用のフィルタを準備しました。

横線のためのカーネル

斜線は右上から左下への斜線のみ対応です。

斜線のためのカーネル

コードにすると以下になります。

kernel1 = np.float32([
    [-1, 1, -1],
    [-1, 1, -1],
    [-1, 1, -1]
])


kernel2 = np.float32([
    [-1, -1, -1],
    [ 1,  1,  1],
    [-1, -1, -1]
])


kernel3 = np.float32([
    [-2, -1,  1],
    [-1,  1, -1],
    [ 1, -1, -2]
])

3. ゼロパディング🔝

画像全体に畳み込みをする前に、画像にゼロパディングを施します。さもないと端っこの特徴を見落としてしまうからです。例えば、画像上部の横線はゼロパディングをしていないと横専用のフィルタでは抽出できません。

ゼロパディングされた画像を使えば上部の横線も抽出できます。

畳み込み④

コードはこんな感じです。

def zero_pad(image, padding):
    # 画像サイズとパディングのサイズ
    h, w = image.shape
    ph, pw = padding
    
    # 両側をゼロにするためにサイズを広げる
    h += ph*2
    w += pw*2
    out = np.zeros((h, w))
    
    # 中身をコピー
    out[pw:h-ph, pw:w-pw] = copy.deepcopy(image)
    
    return out

ゼロパディングのサイズはカーネルのサイズによって決めますが、本記事では深追いしません。

4. 最大値プーリング🔝

画像から3つのフィルタで抽出されたチャンネルはゼロパディングのおかげで元の画像と同じサイズ(縦横)になっています。しかし、このままだと細かいパターンしか認識できません。

もっと大域の特徴を取り出したいので2x2の領域ごとに最大値を取り出し、その領域の特徴量とします。

最大値プーリング

コードはこんな感じになります。

def maxpool(image):
    # 画像の縦横を2分の1にする
    h,w = image.shape
    h //=2
    w //=2
    
    # 2x2の領域で最大値を選ぶ
    out = np.zeros((h, w))
    for r in range(0, h):
        for c in range(0, w):
            out[r, c] = image[r*2:r*2+2, c*2:c*2+2].max()
            
    return out

これによって画像のサイズが2分の1になるとともに各領域で代表的な特徴量が選ばれることになります。

5. 活性化関数🔝

最大値プーリングの後にReLUを使って非線形の要素を導入します。抽出された特徴量から負の値を無視することになります。

def relu(x):
    out = copy.deepcopy(x)
    out[x <= 0] = 0
    return out

ディープコピーをすることで元のデータを汚さないようにしています。今回はあまり重要ではないですが。

6. 1回目の畳み込み🔝

1回目の畳み込み層は3つのチャンネル(縦線のチャンネル、横線のチャンネル、斜線のチャンネル)を出力します。

畳み込みのコードはこんな感じにしました。

def convolve(image, kernel, padding=None):
    # 画像にチャンネル次元がないなら追加する
    # これで2Dから3Dになる
    if len(image.shape) == 2:
        image = np.expand_dims(image, axis=0)

    # kernel size
    kh, kw = kernel.shape[-2:]
        
    # zero padding
    if padding is None:
        padding = (kh//2, kw//2)
    inp = np.float32([zero_pad(img, padding) for img in image])

    # output
    _, h, w = inp.shape
    h = h - kh + 1
    w = w - kw + 1
    out = np.zeros((h, w))
        
    # convolution
    for r in range(0, h):
        for c in range(0, w):
            out[r, c] = (inp[:, r:r+kh, c:c+kw]*kernel).sum()
            
    return out

簡単にまとめると畳み込みの操作が縦横無尽に稲刈りのように実行されます。

ちなみに、畳み込み、最大値プーリング、活性化関数でひとくくりにしたものを畳み込み層として考えていますが、活性化関数はreluだけではないので別になっています。

第1回目の畳み込みでは、convolveの後にreluを呼びます。

縦線の抽出はこんな感じになります。画像中央下部が白くなっており、そのあたりに縦線があると抽出されました。

縦線の抽出

横線の抽出はこんな感じになります。画像の上部が反応していますね。

横線の抽出

斜線の抽出は中央やや右側が比較的強く反応しています。

斜線の抽出

7. 2回目の畳み込み🔝

まだ、細かい特徴量ばかりで数字の認識ができるほどのパターンが見つかっていません。よって、さらに畳み込み、最大値プール、活性化関数という流れをくり返します。

1回目の畳み込み層の結果を見ながら、2回目の畳み込みで使うフィルタを定義しました。このフィルタは入力に3つのチャンネル(縦線のチャンネル、横線のチャンネル、斜線のチャンネル)があるのでフィルタにも3チャンネルあります。

kernel4 = np.float32([
    [
        [ 1, -1,  1],
        [-1,  1,  1],
        [-1,  1, -1]
    ],
    [
        [ 1,  1,  1],
        [-1, -1, -1],
        [-2, -2, -2]
    ],
    [
        [-1, -1,  1],
        [-1,  1,  1],
        [-1,  1, -1]
    ]
])

上記のフィルタで中央下部に縦線があり、上部に横線があり、中央から右のあたりに斜線があるというパターンを認識します。

こんな結果になりました。

これが数字の7のパターンとなるのでさらにもう一回畳み込みを行い識別をします。

8. 3回目の畳み込み🔝

最後のフィルターはパターンをまとめるものとして次のようにしました。

kernel5 = np.float32([
    [-1, 1],
    [1, -2]
])

試行錯誤で決まった値なので誤差逆伝播法を使えば異なる値になるでしょう。

3回目の畳み込みはreluではなくsigmoidを使いました。数字が7である確率を出すためです。

def sigmoid(x):
    return 1.0/(1 + np.exp(-x))

上記の画像で試した結果、画像が7である確率が99.9%と出ました。

まあ、そうなるように手で調節したので当然の結果ですが。

9. 反例を試す🔝

試しに違う数字でテストします。数字の3です。

image2 = np.float32([
    [0, 0, 0, 1, 1, 0, 0, 0],
    [0, 0, 1, 0, 0, 1, 0, 0],
    [0, 0, 0, 0, 0, 1, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 1, 0, 0],    
    [0, 0, 0, 0, 0, 1, 0, 0],
    [0, 0, 1, 0, 0, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0, 0],
])

plt.imshow(image2, cmap='gray')
plt.show()

結果は26.8%なのでほぼ7ではないと予測されました。

ほかの数字もテストしたい方は最後に全てのコードのまとめがあるので参考にしてみてください。

10. 全てのコードのまとめ🔝

import copy
import numpy as np
import matplotlib.pyplot as plt


def zero_pad(image, padding):
    # 画像サイズとパディングのサイズ
    h, w = image.shape
    ph, pw = padding
    
    # 両側をゼロにするためにサイズを広げる
    h += ph*2
    w += pw*2
    out = np.zeros((h, w))
    
    # 中身をコピー
    out[pw:h-ph, pw:w-pw] = copy.deepcopy(image)
    
    return out


def convolve(image, kernel, padding=None):
    # 画像にチャンネル次元がないなら追加する
    # これで2Dから3Dになる
    if len(image.shape) == 2:
        image = np.expand_dims(image, axis=0)

    # kernel size
    kh, kw = kernel.shape[-2:]
        
    # zero padding
    if padding is None:
        padding = (kh//2, kw//2)
    inp = np.float32([zero_pad(img, padding) for img in image])

    # output
    _, h, w = inp.shape
    h = h - kh + 1
    w = w - kw + 1
    out = np.zeros((h, w))
        
    # convolution
    for r in range(0, h):
        for c in range(0, w):
            out[r, c] = (inp[:, r:r+kh, c:c+kw]*kernel).sum()
            
    return out


def maxpool(image):
    # 画像の縦横を2分の1にする
    h,w = image.shape
    h //=2
    w //=2
    
    # 2x2の領域で最大値を選ぶ
    out = np.zeros((h, w))
    for r in range(0, h):
        for c in range(0, w):
            out[r, c] = image[r*2:r*2+2, c*2:c*2+2].max()
            
    return out


def debug(data, clim):
    print(data.shape)
    print(data)
    h, w = data.shape[-2:]
    plt.imshow(data, cmap='gray')
    plt.xticks(range(w))
    plt.yticks(range(h))
    plt.clim(0, clim)
    plt.show()

    
def relu(x):
    out = copy.deepcopy(x)
    out[x <= 0] = 0
    return out


def sigmoid(x):
    return 1.0/(1 + np.exp(-x))


# 「7」の画像
image1 = np.float32([
    [0, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 0, 0, 0, 0, 1, 0],
    [0, 0, 0, 0, 0, 1, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0],    
    [0, 0, 0, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 0, 0, 0, 0],
    [0, 0, 0, 1, 0, 0, 0, 0],
])

plt.imshow(image1, cmap='gray')
plt.show()


# 「3」の画像
image2 = np.float32([
    [0, 0, 0, 1, 1, 0, 0, 0],
    [0, 0, 1, 0, 0, 1, 0, 0],
    [0, 0, 0, 0, 0, 1, 0, 0],
    [0, 0, 0, 0, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 1, 0, 0],    
    [0, 0, 0, 0, 0, 1, 0, 0],
    [0, 0, 1, 0, 0, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0, 0],
])

plt.imshow(image2, cmap='gray')
plt.show()


# 第1回目の畳み込み用
kernel1 = np.float32([
    [-1, 1, -1],
    [-1, 1, -1],
    [-1, 1, -1]
])


kernel2 = np.float32([
    [-1, -1, -1],
    [ 1,  1,  1],
    [-1, -1, -1]
])


kernel3 = np.float32([
    [-2, -1,  1],
    [-1,  1, -1],
    [ 1, -1, -2]
])


# 第2回目の畳み込み用
kernel4 = np.float32([
    [
        [ 1, -1,  1],
        [-1,  1,  1],
        [-1,  1, -1]
    ],
    [
        [ 1,  1,  1],
        [-1, -1, -1],
        [-2, -2, -2]
    ],
    [
        [-1, -1,  1],
        [-1,  1,  1],
        [-1,  1, -1]
    ]
])


# 第3回目の畳み込み用
kernel5 = np.float32([
    [-1, 1],
    [1, -2]
])


# CNN
def cnn(image):
    # 1回目の畳み込み層(フィルター3つ)
    conv1 = [convolve(image, kernel) for kernel in (kernel1, kernel2, kernel3)]
    
    # プーリング層
    pool1 = [maxpool(ch) for ch in conv1]
    
    # relu活性化関数
    relu1 = [relu(ch) for ch in pool1]
    relu1 = np.float32(relu1) # まとめる

    debug(relu1[0], clim=5)
    debug(relu1[1], clim=5)
    debug(relu1[2], clim=5)    
    
    # 2回目の畳み込み層(フィルター1つ)
    conv2 = convolve(relu1, kernel4)
    
    # プーリング層
    pool2 = maxpool(conv2)
    
    # relu活性化関数
    relu2 = relu(pool2)
    
    debug(relu2, clim=10)

    # 3回目の畳み込み層(フィルター1つ)、パディングなし
    conv3 = convolve(relu2, kernel5, padding=(0, 0)) 
        
    print(conv3[0])
        
    # 予測、シグモイド
    return sigmoid(conv3[0])


# 実験1
prob1 = cnn(image1)
print(f'image1 is 7: {prob1*100.0}%')

# 実験2
prob2 = cnn(image2)
print(f'image2 is 7: {prob2*100.0}%')



コメントを残す

メールアドレスは公開されません。