第5回 画像処理に適したニューラルネットワークとは?

  1. 畳み込みニューラルネットワークとは
  2. 畳み込みニューラルネットワークの実装
  3. CIFAR-10 (画像認識) とは
  4. まとめ

1. 畳み込みニューラルネットワークとは

さて、これまでは各レイヤーの各ノードが個別の入力を受けとる 「普通の」ニューラルネットワークを扱ってきた。 しかし、こと画像処理の分野では、同一のノードが異なる複数の入力を 処理する 畳み込みニューラルネットワーク (convolutional neural network, CNN) と呼ばれる方式が一般的である。

まず最初に「畳み込み (convolution)」とは何かを説明しよう。 数学的には、関数の畳み込みとは 2つの関数 f(x) と g(x) を以下のやりかたで合成することである:

(f*g)(x) = Σ f(x)·g(t-x)

本来、畳み込みは無限和 (積分) として定義されるが、 コンピュータで扱う場合は関数 f、g ともに有限の範囲でのみ定義され、 それ以外の値はすべて 0 と仮定される。そのため、数値計算における畳み込みは 多くの場合「ある関数 f(x) の各部分を、一定のやり方で足し合わせた新しい関数を作る」 という処理であるといってよい。

f(x-1) f(x) f(x+1) f(x) f(x+1) f(x+2) (f*g)(x) (f*g)(x+1) (f*g)(x+2)
関数 f と g の畳み込み
(ここでの関数 g は -1, 0, +1 でのみ定義されている)

たとえば関数 g が以下のように定義されているとして:

与えられた関数 f(x) と g との畳み込みを計算する Python 関数は以下のようになる:

def conv_g(f, x):
    return f(x-1)*2 + f(x)*1 + f(x+1)*(-1)
演習5-1. 畳み込みを計算する

関数 f が以下のように定義されるとして、 上の関数 conv_g() を使って畳み込みを計算せよ:

f(0) = 5
f(1) = 9
f(2) = 4
f(3) = 0
f(4) = 7
f(5) = 3

1.1. 画像処理における畳み込み

画像処理の文脈では、畳み込みは 2次元でおこなわれる。 画像における畳み込みとは 「画像の各ピクセルに対して、近隣のピクセル (3×3 あるいは 5×5 など) に特定の係数をかけて合計する」処理をいう。 ここで使われる係数の表ことを カーネル (kernel) と呼ぶ。 カーネルは2次元配列 (行列) で表される。 画像の畳み込みは、さしずめカーネルという「特殊レンズ」を使って 画像を投影したものと考えることができる。

カーネル 入力画像 出力画像
画像における畳み込み (3×3 のカーネルを使った場合)

Python で模式的に書くと、以下のような処理になる:

# K: 近隣の各ピクセルにかけるカーネル (2次元配列)
K = [[0,0,0], [0,1,0], [0,0,0]]
# input: 入力画像 (2次元配列)
input = [ ... ]
# output: 出力画像 (2次元配列) - 入力画像よりもカーネルの分だけ小さい。
output = [ ... ]
# 画像の各ピクセルに対して処理をおこなう。
for y in range(height-2):
    for x in range(width-2):
        # 近隣のピクセル値を合計する。
        v = 0
        for dy in range(3):
            for dx in range(3):
               v += K[dy,dx] * input[y+dy,x+dx]
        output[y,x] = v

NumPy を使うと、forループの中は次のように簡単化できる:

for y in range(height-2):
    for x in range(width-2):
        # 近隣のピクセル値を合計する。
        output[y,x] = np.sum(K * input[y:y+3, x:x+3])

異なるカーネルを使うと、各部分における異なった特徴を抽出できる。 画像にいろいろなカーネルを適用してみた例を以下に示す:

説明カーネル結果
変化なし
(元の画像)
000
010
000
平均化
111
111
111
横線を強調
-1-1-1
000
111
縦線を強調
-101
-101
-101
演習5-2. 画像の畳み込みを計算する

以下のような 4×4 の入力画像に対して、3×3 のカーネルを適用することを考える:

[1 0 0 0]
[0 2 1 1]
[0 0 2 0]
[0 1 2 0]

適用するカーネル:

[ 0 -1  0]
[-1  4 -1]
[ 0 -1  0]

出力画像はカーネルの幅だけ小さくなり 2×2 で表される。 この各ピクセル値を求めよ。

[ 7 -1]
[-5  5]

1.2. ニューラルネットワークにおける畳み込み

従来のニューラルネットワークでは、各ピクセル間の直接的な関係を学習していた。 これに対して畳み込みニューラルネットワークでは、各ピクセルを区別しない。 かわりに、全ピクセルに対して等しく適用される カーネルを学習するのである。 画像に対する「変換」を学習するといってもよい。 たとえばカーネルの大きさが 3×3 の場合、 学習するのは以下のような 9個の入力をもつネットワークである:

入力 出力
3×3 のカーネルを表すニューラルネットワーク

畳み込みニューラルネットワークの利点として、以下のようなものがある:

実際には、畳み込みニューラルネットワークではひとつの入力画像に対して 複数のカーネルを学習する。 画像の各ピクセルに含まれる独立した成分のことをチャンネル (channel) と呼ぶが (たとえば、カラー画像は R, G, B の 3つのチャンネルからなる画像である)、 畳み込みニューラルネットワークでは 1チャンネルからなる入力画像に対して、 複数の並列なチャンネルからなる出力画像を生成するのである。 個々のカーネルはランダムに初期化されるため、ここでは別々のカーネルが それぞれ異なった重みを学習し、異なった特徴量が各チャンネルに抽出されることが期待される。 従来のニューラルネットワークと同様に、この構造はレイヤーの一種であり、 畳み込みレイヤー (convolutional layer) と呼ばれる。 (いっぽう従来のレイヤーは、 線型レイヤー (linear layer) や 全接続レイヤー (fully connected layer)、あるいは アフィンレイヤー (affine layer) などとも呼ばれる。)

入力画像 出力画像 カーネル
畳み込みレイヤーの構造

さらに、複数の畳み込みニューラルネットワークを重ねる場合、 畳み込みレイヤーでは、個々の出力チャンネルが、すべての入力チャンネルの値を合計する。 つまり、ひとつの畳み込みレイヤーが (入力チャンネル数 × 出力チャンネル数) 個のカーネルをもち、 各入力チャンネルと出力チャンネルが全結合された状態になるのである。 この構造は、従来のニューラルネットワークと似ている。 ただ各ノードがひとつの値をとるのではなく、 1枚 (1チャンネル) の画像をとると考えればよい:

入力 (1チャンネル) 出力/入力 (4チャンネル) 出力 (4チャンネル) カーネル カーネル レイヤー1 レイヤー2
複数の畳み込みレイヤーの接続

さて、こうして入力画像に何層もの畳み込みレイヤーを適用していったとして、 文字認識のようなタスクの場合、ニューラルネットワークは最終的にどこかで 「判定処理」をおこなう必要がある。このような場合は、畳み込みレイヤーの 出力チャンネルのピクセル値をフラットな1次元配列に変換し、 そこに従来型の全接続レイヤーをつけ加えることによって判定する。 この段階までに、すでに畳み込みレイヤーが画像から特徴量を抽出しており、 認識しやすくなっていることが期待されている:

畳み込み レイヤー (4チャンネル) 全接続 レイヤー (4×ピクセル数)個のノード
畳み込みレイヤーの出力を全接続レイヤーの入力に変換する

重要なこと: 畳み込みニューラルネットワークにおいても微分可能性は保たれている。 ここでは各ピクセルを全接続レイヤーに変換したものも微分可能であり、 したがって全接続レイヤーからさかのぼって、 各畳み込みレイヤーに対して誤差逆伝播法を使うことができるわけである。

なお、畳み込みレイヤーおける誤差の逆伝播はやや直感的に 想像しづらいかもしれないが、仕組みは従来の誤差逆伝播法とまったく同じである。 ここではひとつの出力ピクセルに対して、それに対応するカーネルの範囲にある 複数の入力ピクセルが誤差を蓄積すると考える。 最終的な入力ピクセルの誤差は、これらを重ね合わせたものになる:

カーネル
畳み込みにおける誤差の逆伝播

2. 畳み込みニューラルネットワークの実装

では実際に、畳み込みレイヤーを表す ConvolutionalLayer を実装してみよう。 畳み込みニューラルネットワークは、2次元の画像を入力するものであった。 1枚の画像は2次元配列で表せるが、実際の畳み込みレイヤーは複数のチャンネルを扱うので、 入力と出力はそれぞれ (チャンネル数 × 高さ × 幅) の 3次元配列で表されることになる。

入力 (ninチャンネル) 出力 (noutチャンネル)
畳み込みレイヤーの入力と出力

さらに、各カーネルは 3×3 などの大きさをもつ正方形の2次元配列であるが、 これが (入力チャンネル × 出力チャンネル) 個だけ存在するので、 重みの総数は (入力チャンネル数 × 出力チャンネル数 × カーネル幅 × カーネル幅) の大きさをもつ 4次元配列で表現できる。すると ConvolutionalLayer クラスの初期化は以下のようになる:

# 入力 ninチャンネル、出力 noutチャンネルで (ksize×ksize) のカーネルを使った
# 畳み込みレイヤーを定義する。
class ConvolutionalLayer:

    def __init__(self, nin, nout, ksize):
        self.nin = nin
        self.nout = nout
        self.ksize = ksize
        # 重み・バイアスを初期化する。
        self.w = np.random.random((self.nout, self.nin, self.ksize, self.ksize))-.5
        self.b = np.random.random((self.nout, self.nin))-.5
        # 計算用の変数を初期化する。
        self.x = self.y = None
        self.dw = np.zeros((self.nout, self.nin, self.ksize, self.ksize))
        self.db = np.zeros((self.nout, self.nin))
        return

残念ながら、NumPy には2次元配列の畳み込み演算を簡単におこなう方法はないため、 画像の各チャンネルを 1ピクセルずつ処理しなければならない。 そのために四重の for ループ (出力チャンネル × 入力チャンネル × 高さ × 幅) が必要になるが、 ここではコードの見やすさのため、簡単なユーティリティ関数 enumerate2d() を定義しておく。 これは Python組み込みの enumerate() 関数の 2次元版のようなもので、ksize×ksize のカーネルが動ける範囲で 与えられた2次元配列 m の部分を列挙する。

(注意: 畳み込みレイヤーの出力画像は、入力画像よりも (ksize-1) だけ小さくなっている。 これは入力画像中のカーネルが動ける範囲がそれだけ狭くなっているためである。 出力画像の大きさを変えないよう、入力画像の周囲に 0 を補完する場合もある。 これは パディング (padding) と呼ばれるが、詳細は後述する。)

h w ksize (i, j) ksize
enumerate2d() 関数の動き
# ksize×ksize のカーネルに対応する2次元配列 m の各部分を返す。
def enumerate2d(ksize, m):
    # カーネルの幅だけ小さくしたサイズを計算する。
    (h, w) = m.shape
    h -= ksize-1
    w -= ksize-1
    for i in range(0, h):
        for j in range(0, w):
            # 配列 m から、位置 (i,j) 大きさ ksize×ksize の部分を2次元で切り出す。
            yield (i, j, m[i:i+ksize, j:j+ksize])
    return

この関数を使って forward() メソッドと backward() メソッドを書くと、次のようになる。 どちらも二重の for ループ (nout × nin) を使って、 各チャンネルの各ピクセルごとに処理している。 今回は一気に計算できないので、まず一時的な配列 (ゼロ初期化されている) を用意し、 そこに各ピクセルごとの値を足していく方法をとっている:

    def forward(self, x):
        # xは (ninチャンネル×高さ×幅) の要素をもつ3次元配列。
        self.x = x
        # 出力画像の大きさを計算する。これは入力画像よりカーネルの幅だけ小さい。
        (_,h,w) = x.shape
        h -= self.ksize-1
        w -= self.ksize-1
        # yは (noutチャンネル×高さ×幅) の要素をもつ3次元配列。
        self.y = np.zeros((self.nout, h, w))
        # 各チャンネルの出力を計算する。
        for i in range(self.nout):
            for j in range(self.nin):
                # j番目のチャンネルの各ピクセルに対して、畳み込みを計算する。
                w = self.w[i,j]
                for (p,q,z) in enumerate2d(self.ksize, x[j]):
                    # p,q は出力画像の位置、z は入力画像の一部。
                    self.y[i,p,q] += np.sum(w * z)
        # 各要素にシグモイド関数を適用する。
        self.y = sigmoid(self.y)
        return self.y

backward() では (nout × nin)個のカーネルそれぞれに対して 偏微分を計算したあとに、誤差をあらわす一時的な二次元配列 dx を用意している。 その後、各出力ピクセルに対応する (dx内の) 入力ピクセルの領域に値を足している。 ここでやや注意が必要なのは、enumerate2d() の返り値が 「もとの配列とメモリを共有する部分列」になっているということである。 これは Python のリスト参照と同様に、部分列を変更すれば元の配列も変更される。

    def backward(self, delta):
        # deltaは (noutチャンネル×高さ×幅) の要素をもつ3次元配列。
        # self.y が計算されたときのシグモイド関数の微分を求める。
        ds = d_sigmoid(self.y)
        # 各偏微分を計算する。
        # self.dw += delta * ds * self.x
        # self.db += delta * ds
        for i in range(self.nout):
            for j in range(self.nin):
                dw = self.dw[i,j]
                db = self.db[i,j]
                for (p,q,z) in enumerate2d(self.ksize, self.x[j]):
                    # p,q は出力画像の位置、z は入力画像の一部。
                    d = delta[i,p,q] * ds[i,p,q]
                    dw += d * z
                    db += d
        # 各入力値の微分を求める。
        # dxは (ninチャンネル×高さ×幅) の要素をもつ3次元配列。
        # dx = np.dot(delta * ds, self.w)
        dx = np.zeros(self.x.shape)
        for i in range(self.nout):
            for j in range(self.nin):
                w = self.w[i,j]
                for (p,q,z) in enumerate2d(self.ksize, dx[j]):
                    # z はカーネルに対応する入力ピクセルの一部分。
                    # z は dx の一部を共有しているため、z を変化させることで dx の一部も変化する。
                    z += delta[i,p,q] * ds[i,p,q] * w
        return dx

いっさいがっさいをまとめると次のようになる。 ここでは出力5チャンネルをもつ畳み込みレイヤー conv1 を作成し、 その後に通常の Softmaxつき全接続レイヤー fc1 を接続している。 カーネルの大きさは 3×3 なので、畳み込みレイヤー (1×28×28) の出力範囲はすこし狭くなって 5×26×26 の3次元配列になる。これを reshape() でフラットな配列に変換し、 backward() 時にまたもとに戻している。

mnist_cnn.py
# 入力1チャンネル、出力5チャンネル、カーネル3×3 の畳み込みレイヤーを使う。
conv1 = ConvolutionalLayer(1, 5, 3)
fc1 = SoftmaxLayer(5*26*26, 10)
n = 0
for i in range(1):
    for (image,label) in zip(train_images, train_labels):
        # 28×28の画像を 1チャンネル×28×28 の3次元配列に変換。
        x = (image/255).reshape(1, 28, 28)
        # 正解部分だけが 1 になっている 10要素の配列を作成。
        ya = np.zeros(10)
        ya[label] = 1
        # 損失・勾配を計算。
        y = conv1.forward(x)
        y = y.reshape(5*26*26) # 3次元配列 → 1次元配列。
        y = fc1.forward(y)
        delta = fc1.cross_entropy_loss_backward(ya)
        delta = delta.reshape(5, 26, 26) # 1次元配列 → 3次元配列に戻す。
        delta = conv1.backward(delta)
        n += 1
        if (n % 50 == 0):
            print(n, fc1.loss)
            conv1.update(0.01)
            fc1.update(0.01)
演習5-3. 畳み込みニューラルネットワークを実行する

上のプログラム mnist_cnn.py を完成させ、 実際に実行して認識精度を確認せよ。

上のプログラムを実行すると、学習が非常に遅い (1時間程度) うえに 精度も向上していない (むしろ低下している) ことがわかる。 これは畳み込みレイヤーを 1層しか使っていないことが原因だが、 レイヤーを増やすとさらに計算時間が増加してしまう。 これを緩和する方法を次に説明する。

2.1. Pooling とは

畳み込みニューラルネットワークの特徴のひとつは、 入力ピクセルと出力ピクセルの位置関係が保たれていることである。 カーネルによる畳み込み操作はすべてのピクセルに対して均等に行われるため、 入力画像で同じ位置関係にあった 2つの特徴量は、 出力画像でも同じ位置関係にある。この性質を利用すると、 ちょうど画像を縮小するのと同じように、特徴量を「間引く」ことができる。 これがプーリング (pooling) である。 これは「複数のピクセルにある情報を、ひとつのピクセルにためる (プールする)」 ことからこう呼ばれる。 なお、プーリングが使えるのは、 畳み込みニューラルネットワークの出力だけである。 いっぽう通常の (畳み込みでない) ニューラルネットワークでは、 各ノードの値がどんな役割をもっているかは不明なので、 値を勝手に捨てることはできない。

プーリングにはいくつかやり方が存在するが、 もっとも一般的に使われているのは以下の 2つである:

  1. ピクセルの最大値 (max) を利用する (max pooling)
  2. ピクセルの平均値 (avg) を利用する (avg pooling)
ここでは max pooling (正確には「ストライド2 の max pooling」) について紹介する。 これは畳み込みレイヤーの (各チャンネルの) 出力ピクセルを 1/2 に縮小するもので、 「隣接する 2×2 ピクセルのもっとも大きい値 (max) を利用する」ことにより 画像を縮小する:
5940
7318
6500
0201
98
61
Max pooling を使って、4×4 の特徴量を 2×2 に縮小する例

Max pooling は通常、畳み込みレイヤーの (活性化関数を計算したあと) 最後のステップとしておこなう。ニューラルネットワークの他のすべての計算と同じように max pooling も 微分可能な演算でなければならない。 「最大値をとる操作の微分」というと想像しにくいかもしれないが、 関数 max() は入力された値の線形和として考えることができる。

たとえば x1, x2, ..., xn の 最大値が xm であるとき:

max(x1, x2, ..., xn) = xm = 0·x1 + 0·x2 + ... + 1·xm + ... + 0·xn

したがって、各変数による微分は以下のようになる:

これはつまり「もともと値が最大だった要素の微分が 1 になる」ということである。 実際の誤差逆伝播法では、これは「受けとった誤差の各要素を、 もともと最大値だった要素に再配置する」処理と考えることができる。 結局のところ、Python では以下の 2つの関数を実装すればよい:

5940
7318
6500
0201
98
61
, [1, 3, 0, 3]
maxpool2d() 関数による処理

0-100
0001
2000
0000
-11
20
, [1, 3, 0, 3]
rev_maxpool2d() 関数による処理

ここでは「最大だった位置」を取得するのに NumPy の np.argmax() 関数を利用する。 2次元配列に対して np.argmax() を使うと、 最初の要素を 0 とした位置を返すので、この値を記録しておく。

>>> np.argmax(np.array([[1,2,3], [4,5,6]]))
5
>>> np.argmax(np.array([[6,5,4], [3,2,1]]))
0

Python での実装は、以下のようになる:

# Max pooling を適用した3次元配列と、最大値をとる各要素の位置を返す。
def maxpool2d(stride, m):
    (nc, h1, w1) = m.shape
    # 配列サイズをstrideで割る。このとき端数は切り上げる。
    (h2, w2) = ((h1+stride-1)//stride, (w1+stride-1)//stride)
    # プーリング結果と、各要素の位置を入れる配列。
    p = np.zeros((nc, h2, w2))
    s = np.zeros((nc, h2, w2))
    for c in range(nc):
        # 各チャンネルごとに処理。
        for i in range(0, h2):
            for j in range(0, w2):
                # 部分列を取り出し、最大値を求める。
                i0 = i*stride
                j0 = j*stride
                z = m[c, i0:i0+stride, j0:j0+stride]
                p[c,i,j] = np.max(z)
                # 最大値をとった要素の添字を記録する。
                s[c,i,j] = np.argmax(z)
    return (p, s)
# 与えられた配列を、元の配列に配置しなおす。
def rev_maxpool2d(stride, p, s):
    (nc, h1, w1) = p.shape
    (h2, w2) = (h1*stride, w1*stride)
    # 元の大きさをもつ配列。
    m = np.zeros((nc, h1*stride, w1*stride))
    for c in range(nc):
        # 各チャンネルごとに処理。
        for i in range(0, h1):
            for j in range(0, w1):
                # 添字から元の要素の位置を復元する。
                i0 = i*stride
                j0 = j*stride
                k = int(s[c,i,j])
                # 元の要素は、(i0,j0) より (k//stride, k%stride) だけずれた位置にある。
                m[c, i0+k//stride, j0+k%stride] = p[c,i,j]
    return m

以上の関数を使って max pooling つきの畳み込みレイヤーである ConvolutionalLayerWithMaxPooling クラスを実装する。 実際に ConvolutionalLayer から追加する部分はわずかである:

# Max pooling つきの畳み込みレイヤー。
class ConvolutionalLayerWithMaxPooling:

    def __init__(self, nin, nout, ksize, stride):
        self.nin = nin
        self.nout = nout
        self.ksize = ksize
        self.stride = stride
        # 重み・バイアスを初期化する。
        self.w = np.random.random((self.nout, self.nin, self.ksize, self.ksize))-.5
        self.b = np.random.random((self.nout, self.nin))-.5
        # 計算用の変数を初期化する。
        self.x = self.y = self.sp = None
        self.dw = np.zeros((self.nout, self.nin, self.ksize, self.ksize))
        self.db = np.zeros((self.nout, self.nin))
        return
    def forward(self, x):
        ...
        # 各要素にシグモイド関数を適用する。
        self.y = sigmoid(self.y)
        # Max poolingを適用する。このとき最大値をとった要素の位置も保存しておく。
        (y, self.sp) = maxpool2d(self.stride, self.y)
        return y
    def backward(self, delta):
        # Max poolingされたものを復元する。
        delta = rev_maxpool2d(self.stride, delta, self.sp)
        # self.y が計算されたときのシグモイド関数の微分を求める。
        ds = d_sigmoid(self.y)
        ...

では実際にこのクラスを使って、畳み込みレイヤーを 2つ使った ニューラルネットワークを動かしてみよう。 最初のレイヤーでは 1×28×28 の元画像を受けとり、5チャンネルの画像を返す。 これは本来 (カーネル分だけ小さい) 26×26 の画像を出力するはずだが、 stride=2 で max pooling をおこなうので、実際に出力される画像は それを半分にした 13×13 になる。 次のレイヤーでは 5×13×13 の画像を入力し (出力は 11×11)、 それをさらに半分にするので、出力は 6×6 となる (端数は切り上げられる)。 ただしチャンネル数は倍の 10 としている。 このように畳み込みレイヤーを重ねる場合は、レイヤーが下がるにしたがって チャンネル数を倍々に増やしていくのが一般的である。 最後にこれを1次元配列に変換し Softmax つき全接続レイヤーに入力する:

mnist_cnn_maxpool.py
# レイヤーを 3つ作成する。
conv1 = ConvolutionalLayerWithMaxPooling(1, 5, 3, 2)  # 1×28×28 → 5×13×13
conv2 = ConvolutionalLayerWithMaxPooling(5, 10, 3, 2) # 5×13×13 → 10×6×6
fc1 = SoftmaxLayer(10*6*6, 10)

以下の図は各レイヤーにおける入力を図式的に表現したものである (この図は、 NN-SVG のサイト を使って描画した)。 各畳み込みレイヤーにおける入力は「チャンネル数 × 高さ × 幅 の体積をもつ直方体」として 表現できる (全接続レイヤーは 1次元の柱で表現するものとする)。 このような図は、畳み込みニューラルネットワークの構造を説明するために よく用いられる。

演習5-4. max pooling を使う

上のプログラム mnist_cnn_maxpool.py を完成させ、実際に実行せよ。 (mnist_cnn.py のさらに2倍以上の時間がかかるため注意)

2.2. 活性化関数 ReLU

畳み込みニューラルネットワークの学習をもう少し効率化する手段として、 畳み込みレイヤーではシグモイド関数の代わりに、 ReLU (Rectified Linear Unit) と呼ばれる活性化関数を使う方法がある。 ReLU はシグモイド関数と同様に微分可能だが、 以下のような違いがある:

+1 0 x +1 0 x
シグモイド関数ReLU

シグモイド関数の勾配は 0 < x を超えるとなめらかに減少していく (平坦になる) のに対して、ReLU の勾配はつねに 1 で変わらない。 このため、ReLU は勾配が大きい分だけ、重み・バイアスが早く収束する (つまり、効率的に学習できる) のではないかと期待できる。

注意: 活性化関数として ReLU を使うのは、 普通は畳み込みレイヤーだけである。 ReLU の出力は 0〜1 の範囲を超えてしまうため、最後の (畳み込みでない) レイヤーの出力には通常のシグモイド関数か、 Softmax 関数が使われる。

ReLU (とその微分) の実装は、シグモイド関数よりさらに簡単である。 NumPy では、これは以下のように実装する:

# 与えられた配列の各要素に対して ReLUを計算する。
def relu(x):
    return np.maximum(0, x)

# relu の微分を計算する。
def d_relu(x):
    return (0 < x) * 1

上の関数 d_relu() で使っている記法は、 NumPy においては条件式も演算の一種とみなされる性質を利用している。 つまり 0 < x のような式は、1つの要素が 配列中のすべての要素と比較される (broadcast)。 さらに Python では TrueFalse は それぞれ整数 1 と 0 とみなされるので、これに 1 をかけて 強制的に数値に変換している:

>>> 1 < np.array([1,2,3])
array([ False, True,  True])
>>> (1 < np.array([1,2,3])) * 1
array([0, 1, 1])
1 < np.array([1, 2, 3])
左辺の 1 が ndarray 中の各要素と比較される。

シグモイド関数と ReLU の他によく使われる活性化関数としては tanh (Hyperbolic Tangent、双曲線正接) 関数があるが、本講座では扱わない。

演習5-5. 活性化関数として ReLU を使う

上のプログラム mnist_cnn_maxpool.py の シグモイド関数を ReLU に変更し実行せよ。

3. CIFAR-10 (画像認識) とは

CIFAR-10 データセット は 画像認識のためのデータセットで、MNIST と並んで ディープラーニングの入門書でよく使われる。 これはカラー画像に写っている物体を、以下の 10種類のうちから判定するものである: 飛行機 (0)、自動車 (1)、 (2)、 ネコ (3)、シカ (4)、イヌ (5)、 カエル (6)、ウマ (7)、船舶 (8)、 トラック (9)。 画像の中の物体はひとつだけであり、必ずこのどれかの種類に属する (どちらとも判定可能なものは含まれていない)。 100種類の物体を判定する CIFAR-100 というタスクもあるが、 ここでは単純なほうをおこなう。

CIFAR-10 における入力と出力は、以下のように定義される:

CIFAR-10 は MNIST と非常によく似た問題なので、上で説明した ConvolutionalLayerWithMaxPooling クラスと SoftmaxLayer クラスをそのまま使って学習が可能である。 MNIST との違いは、画像サイズが若干大きい (32×32) のと、 カラー画像なので入力が R, G, Bの 3チャンネル (3×32×32) に なっているということである。

CIFAR-10 データセット のページから "CIFAR-10 python version" をダウンロードして展開すると、 以下のファイルが含まれている (ちなみに、Windows で tar.gz 形式を展開するには 7-Zip をインストールするか、 コマンドプロンプトから tar zxf cifar-10-python.tar.gz を実行する)。 訓練データが合計50,000個、テストデータが 10,000個ある:

上のファイルを cifar10.py ファイルで定義された load_cifar() 関数を使って読み込む。 2つの畳み込みレイヤー (それぞれチャンネル数は 5 と 10、 カーネルの大きさは 3) を使ったプログラムは、 以下のようになる:

cifar10_cnn.py
# レイヤーを 3つ作成。
conv1 = ConvolutionalLayerWithMaxPooling(3, 5, 3, 2)  # 3×32×32 → 5×15×15
conv2 = ConvolutionalLayerWithMaxPooling(5, 10, 3, 2) # 5×15×15 → 10×7×7
fc1 = SoftmaxLayer(10*7*7, 10)
n = 0
for i in range(1):
    # 各ファイルに対して処理をおこなう。
    for name in ['data_batch_1', 'data_batch_2']:
        # 訓練データの画像・ラベルを読み込む (パス名は適宜変更)。
        (train_images, train_labels) = load_cifar('/home/euske/data/CIFAR/cifar-10-batches-py/'+name)
        for (image,label) in zip(train_images, train_labels):
            x = (image/255)
            # 正解部分だけが 1 になっている 10要素の配列を作成。
            ya = np.zeros(10)
            ya[label] = 1
            # 学習させる。
            y = conv1.forward(x)
            y = conv2.forward(y)
            y = y.reshape(10*7*7)
            y = fc1.forward(y)
            delta = fc1.cross_entropy_loss_backward(ya)
            delta = delta.reshape(10, 7, 7)
            delta = conv2.backward(delta)
            delta = conv1.backward(delta)
            n += 1
            if (n % 50 == 0):
                print(n, fc1.loss)
                conv1.update(0.01)
                conv2.update(0.01)
                fc1.update(0.01)
# テストデータの画像・ラベルを読み込む (パス名は適宜変更)。
(test_images, test_labels) = load_cifar('test_batch')
correct = 0
for (image,label) in zip(test_images, test_labels):
    x = (image/255)
    y = conv1.forward(x)
    y = conv2.forward(y)
    y = y.reshape(10*7*7)
    y = fc1.forward(y)
    i = np.argmax(y)
    if i == label:
        correct += 1
print(correct, len(test_images))
演習5-6. CIFAR-10 の学習

本来ならばチャンネル数を増やしたほうが性能は上がるのだが、 計算に非常に時間がかかってしまう。 また上のプログラムでは実際に 50,000個与えられている訓練データのうち 20,000個しか使っていないため、認識精度はよくない。 次章では PyTorch を使って、これを高速化する方法を説明する。

4. まとめ


クリエイティブ・コモンズ・ライセンス
この作品は、クリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスの下に提供されています。
Yusuke Shinyama