自作したフィルタによる畳み込みニューラルネットワークで数字画像を識別
この記事で学ぶこと
CNNを人にわかるように説明しようとすると結構込み入っている。なので今回は簡単なCNNをフィルタを自作しながら解説してみました。
普段、CNNの理解があやふやな方でも、単純な例を経験することで、頭の中の整理にもなると思います。
画像データ
この8x8のデータにある数字を識別します。

画像として表示するとこうなります。
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ですね。人間はすぐに認識できます。
CNNを訓練すれば認識できますが、CNNがフィルタ(カーネル)を自動で作るので実際どんなことになっているのか分かりにくい。よく使いますが、細かいことは忘れがちなCNNの内部。今回は自作したフィルタによるCNNで画像識別してみました。
フィルタ(カーネル)
縦の線を抽出するためのフィルタとして以下の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]
])ゼロパディング
画像全体に畳み込みをする前に、画像にゼロパディングを施します。さもないと端っこの特徴を見落としてしまうからです。例えば、画像上部の横線はゼロパディングをしていないと横専用のフィルタでは抽出できません。
ゼロパディングされた画像を使えば上部の横線も抽出できます。

コードはこんな感じです。
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ゼロパディングのサイズはカーネルのサイズによって決めますが、本記事では深追いしません。
最大値プーリング
画像から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になるとともに各領域で代表的な特徴量が選ばれることになります。
活性化関数
最大値プーリングの後にReLUを使って非線形の要素を導入します。抽出された特徴量から負の値を無視することになります。
def relu(x):
out = copy.deepcopy(x)
out[x <= 0] = 0
return outディープコピーをすることで元のデータを汚さないようにしています。今回はあまり重要ではないですが。
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を呼びます。
縦線の抽出はこんな感じになります。画像中央下部が白くなっており、そのあたりに縦線があると抽出されました。

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

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

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のパターンとなるのでさらにもう一回畳み込みを行い識別をします。
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%と出ました。
まあ、そうなるように手で調節したので当然の結果ですが。
反例を試す
試しに違う数字でテストします。数字の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ではないと予測されました。
ほかの数字もテストしたい方は最後に全てのコードのまとめがあるので参考にしてみてください。
全てのコードのまとめ
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}%')