Deep Learning

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


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

畳み込みニューラルネットワーク (convolutional neural network, CNN) は、 画像認識や音声認識などさまざまな分野で使われている。

これまで見て来たニューラルネットワークでは、隣接する層の間で全てのニューロンが結合 (全結合, fully-connected) されていた。また、実際に Affine レイヤという名前で実装した。

全結合層によるネットワーク
$ input \rightarrow \left[ Affine \rightarrow ReLU \right] \rightarrow \left[ Affine \rightarrow ReLU \right] \rightarrow \left[ Affine \rightarrow ReLU \right] \rightarrow \left[ Affine \rightarrow ReLU \right] \rightarrow \left[ Affine \rightarrow Softmax \right] \rightarrow output $

「畳み込みニューラルネットワーク」では 「Convolution layer (畳み込み層)」と 「Pooling layer (プーリング層)」が追加される。 「Pooling layer (プーリング層)」は省略されることもある。 出力に近い層では、今まで通りの $Affine \rightarrow ReLU$ という組み合わせが用いられる。 また、出力直前の層では $Affine \rightarrow Softmax$ という組み合わせが用いられる。

CNN によるネットワーク
$ input \rightarrow \left[ Conv \rightarrow ReLU \rightarrow Pooling \right] \rightarrow \left[ Conv \rightarrow ReLU \rightarrow Pooling \right] \rightarrow \left[ Conv \rightarrow ReLU \right] \rightarrow \left[ Affine \rightarrow ReLU \right] \rightarrow \left[ Affine \rightarrow Softmax \right] \rightarrow output $

全結合の問題点

全結合層においては、データの形状が"無視" されてしまうことが問題点である。

たとえば2次元画像は本来「縦・横・チャネル(RGB$\alpha$)」 という3次元のデータであるが、 これを1次元の入力データとして全結合層に入力すると、 元々の空間的な情報を効果的に利用することができない。

畳み込み層

畳み込み層 (Convolution レイヤ) では、元データの形状を維持することができる。 畳み込み層の入出力データを「特徴マップ (feature map)」と呼び、 入力データを「入力特徴マップ (input feature map)」、 出力データを「出力特徴マップ (output feature map)」という。

畳み込み演算

「畳み込み演算」は画像処理における「フィルタ演算」に相当する。 ある領域に同じ大きさのフィルタ演算をする場合は、 「対応する要素を乗算し、その結果の総和を求める」処理となる。

同じ大きさの領域に対する畳み込み演算
$\displaystyle \left[ \begin{array}{ccc} 1 & 2 & 3 \\ 0 & 1 & 2 \\ 3 & 0 & 1 \end{array} \right] \mathbf{(*)} \left[ \begin{array}{ccc} 2 & 0 & 1 \\ 0 & 1 & 2 \\ 1 & 0 & 2 \end{array} \right] = \begin{array}{l} (1*2 + 2 * 0 + 3 * 1) \\ + (0 * 0 + 1 * 1 + 2 * 2) \\ + (3 * 1 + 0 * 0 + 1 * 2) \end{array} = 5 + 5 + 5 = 15 $

領域の方がフィルタよりも大きい場合は、フィルタと同じ大きさの領域を スライドさせながら繰り返し取り出して、フィルタ処理を行う。

スライドさせながら畳み込み演算
$\displaystyle \left[ \begin{array}{cccc} 1 & 2 & 3 & 0 \\ 0 & 1 & 2 & 3 \\ 3 & 0 & 1 & 2 \\ 2 & 3 & 0 & 1 \end{array} \right] \mathbf{(*)} \left[ \begin{array}{ccc} 2 & 0 & 1 \\ 0 & 1 & 2 \\ 1 & 0 & 2 \end{array} \right] = \left[ \begin{array}{cc} \left[ \begin{array}{ccc} 1 & 2 & 3 \\ 0 & 1 & 2 \\ 3 & 0 & 1 \end{array} \right] \mathbf{(*)} \left[ \begin{array}{ccc} 2 & 0 & 1 \\ 0 & 1 & 2 \\ 1 & 0 & 2 \end{array} \right] & \left[ \begin{array}{ccc} 2 & 3 & 0\\ 1 & 2 & 3\\ 0 & 1 & 2 \end{array} \right] \mathbf{(*)} \left[ \begin{array}{ccc} 2 & 0 & 1 \\ 0 & 1 & 2 \\ 1 & 0 & 2 \end{array} \right] \\ \left[ \begin{array}{ccc} 0 & 1 & 2 \\ 3 & 0 & 1 \\ 2 & 3 & 0 \end{array} \right] \mathbf{(*)} \left[ \begin{array}{ccc} 2 & 0 & 1 \\ 0 & 1 & 2 \\ 1 & 0 & 2 \end{array} \right] & \left[ \begin{array}{ccc} 1 & 2 & 3 \\ 0 & 1 & 2 \\ 3 & 0 & 1 \end{array} \right] \mathbf{(*)} \left[ \begin{array}{ccc} 2 & 0 & 1 \\ 0 & 1 & 2 \\ 1 & 0 & 2 \end{array} \right] \end{array}\right] = \left[ \begin{array}{cc} 15 & 16 \\ 6 & 15 \end{array} \right] $

バイアス項の加算は、フィルタ適用後に行われる。 バイアス行はスカラ値であるが、全ての要素に加算する。

パディング

$n \times n$ の領域に $m \times m$ のフィルタをそのまま適用すると、 演算結果は $(n - m + 1) \times (n - m + 1)$ になってしまう。 演算結果を元の領域の大きさと同じにするために、元の領域周りを0ので埋めて (padding) からフィルタをかけることがある。 元の大きさを保つためには、 $ n-m+1 + 2p = n$ が成り立つように周りに $\displaystyle p = \frac{m-1}{2}$ の幅のパディングを行えばよい。

上の例では、$4 \times 4$ の領域に $3 \times 3$ のフィルタを適用したので、 結果が$2 \times 2$ の大きさになった。周りに幅 1 のパディングを行うと 領域は $6 \times 6$ の大きさになり $m - n + 1 = 6 - 3 + 1 = 4$ なので 結果は $4 \times 4$ の大きさになる。

ストライド

フィルタを適用する位置の間隔をストライド (stride) という。

入力サイズを $(H, W)$、フィルタサイズを $(FH,FW)$、出力サイズを$(OH, OW)$、 パディングを$P$、ストライドを$S$とする。

$\displaystyle \begin{equation} OH = \frac{H + 2P - FH}{S} +1 \\ OW = \frac{W + 2P - FW}{S} +1 \quad\quad\quad (7.1) \end{equation} $

3次元データの畳み込み演算

3次元の入力データに対して、同じ大きさの3次元フィルタをかける場合は、 対応する項同士を乗算した後、総和を求めればよい。

3次元の入力データの方が大きい場合は、3次元フィルタと同じ大きさを (スライドさせながら繰り返し)切り出して、フィルタを適用すればよい。 入力データと、フィルタの3次元方向(縦・横・チャネルのうちのチャネル方向) の大きさは同じにする(のが普通のようだ)。

ブロックで考える

チャネル数 $C$, 高さ $H$, 横幅 $W$ のデータの形状を $(C, H, W)$ と表すことにする。

形状が $(C, H, W)$ である入力データに 形状が $(C, FH, FW)$ であるフィルタを適用すると、 出力データの形状は $\displaystyle (1, OH, OW)$ という 1 枚の特徴マップになる。

畳み込み演算出力を、チャネル方向にも厚みを持たせるには、 複数のフィルタを用いる。 フィルタを $FN$ 個用意して、これらを適用した結果をチャネル方向に並べてその方向の厚みとする。 これにより $(FN, OH, OW)$ の形状の結果が得られることになる。

以上により、畳み込みフィルタは4次元のデータとして (output_channel, input_channel, height, weight) で表される。

バイアスは1チャネル毎にひとつのスカラ値であり、 バイアスの形状は $(FN, 1, 1)$ となる。 フィルタの出力結果の形状は $(FN, OH, OW)$ である。


プーリング層

プーリング (pooling) は、縦・横方向の空間を小さくする演算である。

2x2のMaxプーリング
$\displaystyle Max2x2 \left[ \begin{array}{cccc} 1 & 2 & 1 & 0 \\ 0 & 1 & 2 & 3 \\ 3 & 0 & 1 & 2 \\ 2 & 4 & 0 & 1 \end{array} \right] = \left[ \begin{array}{cc} max \left[ \begin{array}{cc} 1 & 2 \\ 0 & 1 \\ \end{array} \right] & max \left[ \begin{array}{cc} 1 & 0 \\ 2 & 3 \\ \end{array} \right] \\ max \left[ \begin{array}{cc} 3 & 0 \\ 2 & 4 \\ \end{array} \right] & max \left[ \begin{array}{cc} 1 & 2 \\ 0 & 1 \\ \end{array} \right] \end{array} \right] = \left[ \begin{array}{cc} 2 & 3 \\ 4 & 2 \end{array} \right] $

プーリング層の特徴

プーリング層の特徴は次の通り。


Convolution / Pooling レイヤの実装

numpyではfor文を使って繰り返しを記述すると、実行速度が遅くなる。 そこでim2col という、フィルター(重み)にとって都合の良いように入力データを展開する関数を使う。

im2col.py
def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
    """

    Parameters
    ----------
    input_data : (データ数, チャンネル, 高さ, 幅)の4次元配列からなる入力データ
    filter_h : フィルターの高さ
    filter_w : フィルターの幅
    stride : ストライド
    pad : パディング

    Returns
    -------
    col : 2次元配列
    """
    N, C, H, W = input_data.shape
    out_h = (H + 2*pad - filter_h)//stride + 1
    out_w = (W + 2*pad - filter_w)//stride + 1

    img = np.pad(input_data, [(0,0), (0,0), (pad, pad), (pad, pad)], 'constant')
    col = np.zeros((N, C, filter_h, filter_w, out_h, out_w))

    for y in range(filter_h):
        y_max = y + stride*out_h
        for x in range(filter_w):
            x_max = x + stride*out_w
            col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]

    col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1)
    return col


numpy.pad(array, pad_width, mode, **kwargs)
  array : ランクN の配列
  pad_width : { sequence, array_like, int}
     各軸方向に端に追加で埋めるべきパッドの数。((before, after), ..., (before, after))
  mode: 文字列 または 関数 ("constant" は「固定値でpaddingする」ことを意味する)
なので
numpy.pad(a, [(0,0), (0,0), (pad,pad), (pad,pad)]
は、 「個数・チャネル・縦幅、横幅」の4軸のうち、「縦軸」と「横軸」の前と後ろに pad 幅でパディングすることを意味する。

配列のインデックスで a[start:end:step]を意味する。a[:]は全ての範囲を表す。

Convolutionの実装

convolution.py
class Convolution:
  def __init__(self, W, b, stride=1, pad=0):
    self.W = w
    self.b = b
    self.stride = stride
    self.pad = pad
    # intermidiate data (in backward)
    self.x = None
    self.col = None
    self.col_W = None
    # gradient of weight and bias
    self.dW = None
    self.db = None

  def forward(self, x):
    FN, C, FH, FW = self.W.shape
    N, C, H, W = x.shape
    out_h = int(1 + (H + 2*self.pad - FH) / self.stride)
    out_w = int(1 + (W + 2*self.pad - FW) / self.stride)
    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

  def backward(self, dout):
    FN, C, FH, FW = self.W.shape
    duot = dout.transpose(0,2,3,1).reshape(-1, FN)
    
    self.db = np.sum(dout, axis=0)
    self.dW = np.dot(self.co.T, dout)
    self.dW = self.dW.transpose(1, 0).rehape(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.py
class Pooling:
  def __init__(self, pool_h, pool_w, stride=1, pad=0):
    self.pool_h = pool_h
    self.pool_w = pool_w
    self.stride = stride
    self.pad = pad
    self.x = None
    self.arg_max = None

  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)

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

    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

  def backward(self, dout):
    dout = dout.transpose(0, 2, 3, 1)
  
    pool_size = self.pool_h * self.pool_w
    dmax = np.zeros((dout.size, ppol_size))
    dmax[np.arrage(self.arg_max.size), self.arg_max.flatten()] = dout.flatten()

    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の実装

手書き数字認識を行う CNN を組み立てる。 図 7-23 に示すように、次のユニットからナルニューラルネットワークとなる。

  1. Convolution
  2. ReLU
  3. Pooling
  4. Affine
  5. ReLU
  6. Affine
  7. Softmax
cnn.py
class SimpleConvNet:
  def __init__(self, input_dim=(1, 28, 28),
                     conv_param = {'filter_num': 30, 'filter_size':5, 'pad':0, 'stride':1 },
                     hidden_size=100, output_size=10, weight_init_std=0.01):
    filter_num = conv_param{'filter_num'}
    filter_size = conv_param{'filter_size'}
    filter_pad = conv_param{'pad'}
    filter_stride = conv_param{'stride'}
    input_size = input_dim[1]
    conv_output_size = (input_size - filter_size + 2*filter_pad) / filter_stride + 1
    pool_output_size = int(filter_num * (conv_output_size / 2) * (conv_output_size / 2))
    # initialize weight
    self.params = {}
    self.params['W1'] = weight_init_std * np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
    self.params['b1'] = np.zeros(filter_num)
    self.params['W2'] = weight_init_std * np.random.randn(pool_output_size, hidden_size)
    self.params['b2'] = np.zeros(hidden_size)
    self.params['W3'] = weight_init_std * np.random.randn(hidden_size, outpu_size)
    self.params['b3'] = np.zeros(output_size)
    # create layers
    self.layers = OrderedDict()
    self.layers['Conv1'] = Convolution(self.params['W1'], self.params['b1'], conv_param['stride'], conv_param['pad'])
    self.layers['Relu1'] = Relu()
    self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
    self.layers['Affine1'] = Affine(self.params['W2'], self.params['b2'])
    self.layers['Relu2'] = Relu()
    self.layers['Affine2'] = Affine(self.params['W3'], self.params['b3'])
    self.layer = SoftmaxWithLoss()

  def predict(self, x):
    for layer in self.layers.values():
      x = layer.forward(x)
    return x
  def loss(self, x, t):
    y = self.predict(x)
    return self.last_layer.forward(y, t)
  def accuracy(self, x, t, batch_size=100):
    if t.ndim != 1 : t = np.argmax(t, axis=1)
    acc = 0.0
    for i in range(int(x.shape[0] / batch_size)):
      tx = x[i*batch_size: (i+1)*batch_size]
      tt = t[i*batch_size: (i+1)*batch_size]
      y = self.predict(tx)
      y = np.argmax(y, axis=1)
      acc += np.sum(y == tt)
    return acc / s.shape[0]
  def numerical_gradient(self, x, t):
    loss_w = lambda w: self.loss(x, t)
    grads = {}
    for idx in (1, 2, 3):
      grads['W' + str(idx)] = numerical_gradient(loss_w, self.params['W' + str(idx)])
      grads['b' + str(idx)] = numerical_gradient(loss_w, self.params['b' + str(idx)])
    return grads
  def gradient(self, x, t):
    # forward
    self.loss(x, t)
    # backward
    dout = 1
    dout = self.last_layer.backward(dout)
    layers = list(self.layers.values())
    layers.reverse()
    for layer in layers:
      dout = layer.backward(dout)
    # Settings
    grads = {}
    grad['W1'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
    grad['W2'], grads['b2'] = self.layers['Affine1'].dW, self.layers['Affnie1'].db
    grad['W3'], grads['b3'] = self.layers['Affine2'].dW, self.layers['Affnie2'].db
    return grads






CNNの可視化

前の節で MNIST データセットに対して単純な CNN の学習を行った。 1層目の畳み込み層の形状は (30, 1, 5, 5) であった。 これは単純な構造(縦のエッジ、横のエッジ、など)に反応する層になる。

層が深くなるにしたがって、抽出される情報(強く反応するニューロン)は、より抽象化されていく。


代表的な CNN


Yoshihisa Nitta

http://nw.tsuda.ac.jp/