からっぽのしょこ

読んだら書く!書いたら読む!同じ事は二度調べ(たく)ない

7.4.4:Poolingレイヤの実装【ゼロつく1のノート(実装)】

はじめに

 「プログラミング」学習初手『ゼロから作るDeep Learning』民のための実装攻略ノートです。『ゼロつく1』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。

 関数やクラスとして実装される処理の塊を細かく分解して、1つずつ処理を確認しながらゆっくりと組んでいきます。

 この記事は、7.4.4項「Poolingレイヤの実装」の内容になります。Poolingレイヤの処理を確認して、Pythonで実装します。

【前節の内容】

www.anarchive-beta.com

【他の節の内容】

www.anarchive-beta.com

【この節の内容】

7.4.4 Poolingレイヤの実装

 Poolingレイヤの概念的な説明は、7.3節を読んでください。この項では、必要な処理を1つずつ確認しから実装します。

・順伝播

 Convolutionレイヤでは、全てのチャンネルの同じ範囲のデータ(縦横)を同じ行として展開しました。これはその行の要素を全て足し合わせるためです。Poolingレイヤではチャンネルごとにフィルターの範囲の最大値を抽出するため、チャンネルごとに別の行に展開する必要があります。

・数式で確認

 7.4.2項の例を使って説明すると次のようになります。

 変換前のデータ$\mathbf{X}$を

$$ \begin{aligned} \mathbf{X}_{1,1} = \begin{pmatrix} x_{1,1,1,1} & x_{1,1,1,2} & x_{1,1,1,3} & x_{1,1,1,4} \\ x_{1,1,2,1} & x_{1,1,2,2} & x_{1,1,2,3} & x_{1,1,2,4} \\ x_{1,1,3,1} & x_{1,1,3,2} & x_{1,1,3,3} & x_{1,1,3,4} \\ x_{1,1,4,1} & x_{1,1,4,2} & x_{1,1,4,3} & x_{1,1,4,4} \end{pmatrix} ,\ \mathbf{X}_{1,2} = \begin{pmatrix} x_{1,2,1,1} & x_{1,2,1,2} & x_{1,2,1,3} & x_{1,2,1,4} \\ x_{1,2,2,1} & x_{1,2,2,2} & x_{1,2,2,3} & x_{1,2,2,4} \\ x_{1,2,3,1} & x_{1,2,3,2} & x_{1,2,3,3} & x_{1,2,3,4} \\ x_{1,2,4,1} & x_{1,2,4,2} & x_{1,2,4,3} & x_{1,2,4,4} \end{pmatrix} \\ \mathbf{X}_{2,1} = \begin{pmatrix} x_{2,1,1,1} & x_{2,1,1,2} & x_{2,1,1,3} & x_{2,1,1,4} \\ x_{2,1,2,1} & x_{2,1,2,2} & x_{2,1,2,3} & x_{2,1,2,4} \\ x_{2,1,3,1} & x_{2,1,3,2} & x_{2,1,3,3} & x_{2,1,3,4} \\ x_{2,1,4,1} & x_{2,1,4,2} & x_{2,1,4,3} & x_{2,1,4,4} \end{pmatrix} ,\ \mathbf{X}_{2,2} = \begin{pmatrix} x_{2,2,1,1} & x_{2,2,1,2} & x_{2,2,1,3} & x_{2,2,1,4} \\ x_{2,2,2,1} & x_{2,2,2,2} & x_{2,2,2,3} & x_{2,2,2,4} \\ x_{2,2,3,1} & x_{2,2,3,2} & x_{2,2,3,3} & x_{2,2,3,4} \\ x_{2,2,4,1} & x_{2,2,4,2} & x_{2,2,4,3} & x_{2,2,4,4} \end{pmatrix} \end{aligned} $$

とします。$\mathbf{X}_{n,c}$の添字は、それぞれデータインデックスとチャンネルインデックスを表し、$n = 1, 2, \cdots, N$、$c = 1, 2, \cdots, C$です。この例では、データ数$N = 2$、チャンネル数$C = 2$とします。図7-13に例えると、$\mathbf{X}_{1,1}$の奥に$\mathbf{X}_{1,2}$が並ぶブロックと、$\mathbf{X}_{2,1},\ \mathbf{X}_{2,2}$が並ぶブロックが2つある状態です。この2つのブロックの各要素は$x_{n,c,h,w}$で表し、$h = 1, 2, \cdots, H$は縦のピクセルデータ、$w = 1, 2, \cdots, W$は横のピクセルデータのインデックスです。この例では$H = 4,\ W = 4$とします。

 $\mathbf{X}$から$2 \times 2$のプーリングのウィンドウにかかる要素を取り出すと

$$ \mathbf{X}_{1,1}^{(1)} = \begin{pmatrix} x_{1,1,1,1} & x_{1,1,1,2} \\ x_{1,1,2,1} & x_{1,1,2,2} \end{pmatrix} ,\ \mathbf{X}_{1,2}^{(1)} = \begin{pmatrix} x_{1,2,1,1} & x_{1,2,1,2} \\ x_{1,2,2,1} & x_{1,2,2,2} \end{pmatrix} $$

です。何回目に取り出した要素なのかを括弧付きの指数で示すことにします。
 取り出した要素をチャンネルごとに横一列に並べた

$$ \mathbf{X}^{\mathrm{col}(1)} = \begin{pmatrix} x_{1,1,1,1} & x_{1,1,1,2} & x_{1,1,2,1} & x_{1,1,2,2} \\ x_{1,2,1,1} & x_{1,2,1,2} & x_{1,2,2,1} & x_{1,2,2,2} \end{pmatrix} $$

が、input_data_colの1・2行目になります。またこれは展開後の入力データのことなので、右肩に$\mathrm{col}$として書き分けることにします。

 ストライドを2とすると、次の範囲の要素は

$$ \mathbf{X}_{1,1}^{(2)} = \begin{pmatrix} x_{1,1,1,3} & x_{1,1,1,4} \\ x_{1,1,2,3} & x_{1,1,2,4} \end{pmatrix} ,\ \mathbf{X}_{1,2}^{(2)} = \begin{pmatrix} x_{1,2,1,3} & x_{1,2,1,4} \\ x_{1,2,2,3} & x_{1,2,2,4} \end{pmatrix} $$

なので、横に並べると

$$ \mathbf{X}^{\mathrm{col}(2)} = \begin{pmatrix} x_{1,1,1,3} & x_{1,1,1,4} & x_{1,1,2,3} & x_{1,1,2,4} \\ x_{1,2,1,3} & x_{1,2,1,4} & x_{1,2,2,3} & x_{1,2,2,4} \end{pmatrix} $$

になります。

 これを繰り返すと、最終的に

$$ \mathbf{X}^{\mathrm{col}} = \begin{pmatrix} x_{1,1,1,1} & x_{1,1,1,2} & x_{1,1,2,1} & x_{1,1,2,2} \\ x_{1,2,1,1} & x_{1,2,1,2} & x_{1,2,2,1} & x_{1,2,2,2} \\ x_{1,1,1,3} & x_{1,1,1,4} & x_{1,1,2,3} & x_{1,1,2,4} \\ \vdots & \vdots & \vdots & \vdots \\ x_{1,1,3,3} & x_{1,1,3,4} & x_{1,1,4,3} & x_{1,1,4,4} \\ x_{1,2,3,3} & x_{1,2,3,4} & x_{1,2,4,3} & x_{1,2,4,4} \\ x_{2,1,1,1} & x_{2,1,1,2} & x_{2,1,2,1} & x_{2,1,2,2} \\ \vdots & \vdots & \vdots & \vdots \\ x_{2,2,3,1} & x_{2,2,3,2} & x_{2,2,4,1} & x_{2,2,4,2} \\ x_{2,1,3,3} & x_{2,1,3,4} & x_{2,1,4,3} & x_{2,1,4,4} \\ x_{2,2,3,3} & x_{2,2,3,4} & x_{2,2,4,3} & x_{2,2,4,4} \end{pmatrix} $$

となります。これがinput_data_colに対応します。

 図7-21の展開の仕方とは異なりますが、実装上はこのように展開しています。ただしこれは中間データなので、Poolingレイヤの出力データとして再変換する際にどのように並べるかの順番が変わるだけで、最終的に同じ結果となります。(図7-22で説明すると、出力データの1行1列1チャンネル目の2からチャンネル方向(奥)に4,4と並べて、次に1行2列目から4,6,4、2行1列目の3というように並んでいます。)

・処理の確認

 プログラムからも確認しましょう。各要素の動きを把握しやすいように、0から整数が並んだ4次元配列を入力データとします。

# 入力データ数を指定
input_num = 2

# 入力データのチャンネル数を指定
C = 2

# 入力データの高さを指定
input_h = 4

# 入力データの横幅を指定
input_w = 4

# 整数を並べた4次元配列を作成
input_data = np.arange(input_num * C * input_h * input_w).reshape(input_num, C, input_h, input_w)
print(input_data)
print(input_data.shape)
[[[[ 0  1  2  3]
   [ 4  5  6  7]
   [ 8  9 10 11]
   [12 13 14 15]]

  [[16 17 18 19]
   [20 21 22 23]
   [24 25 26 27]
   [28 29 30 31]]]


 [[[32 33 34 35]
   [36 37 38 39]
   [40 41 42 43]
   [44 45 46 47]]

  [[48 49 50 51]
   [52 53 54 55]
   [56 57 58 59]
   [60 61 62 63]]]]
(2, 2, 4, 4)

 これを入力データとします。ちなみに入力のデータ$\mathbf{X}$のサイズは$(N, C, H, W)$です。


 Poolingの範囲を指定し、式(7.1)の計算を行いPoolingレイヤの出力サイズを求めます。

# Poolingの高さを指定
pool_h = 2

# Poolingの横幅を指定
pool_w = 2

# ストライドを指定
stride = 2

# 出力データに関するサイズを計算:式(7.1)
out_h = int(1 + (input_h - pool_h) / stride)
out_w = int(1 + (input_w - pool_w) / stride)
print(out_h, out_w)
2 2

 計算結果はint()で整数に丸めます。

 im2col()によってPoolingの範囲の全てチャンネルの要素を横に並べた2次元配列に展開します。
 それを.reshape()によって、チャンネルごとに行を分けるように再変換します。

# 2次元配列に展開
tmp_input_data_col = im2col(input_data_pad, pool_h, pool_w, stride=stride, pad=0)
input_data_col = tmp_input_data_col.reshape(-1, pool_h * pool_w)
print(tmp_input_data_col[0:5])
print(input_data_col[0:10])
print(input_data_col.shape)
[[ 0.  1.  4.  5. 16. 17. 20. 21.]
 [ 2.  3.  6.  7. 18. 19. 22. 23.]
 [ 8.  9. 12. 13. 24. 25. 28. 29.]
 [10. 11. 14. 15. 26. 27. 30. 31.]
 [32. 33. 36. 37. 48. 49. 52. 53.]]
[[ 0.  1.  4.  5.]
 [16. 17. 20. 21.]
 [ 2.  3.  6.  7.]
 [18. 19. 22. 23.]
 [ 8.  9. 12. 13.]
 [24. 25. 28. 29.]
 [10. 11. 14. 15.]
 [26. 27. 30. 31.]
 [32. 33. 36. 37.]
 [48. 49. 52. 53.]]
(16, 4)

 これでPooling適用領域内の要素を行とした2次元配列に展開できました。展開した入力データ$\mathbf{X}^{\mathrm{col}}$のサイズは$(N H_o W_o C, H_p W_p)$になります。ここで出力サイズを$H_o,\ W_o$、プーリングウィンドウのサイズを$H_p,\ W_p$とします(添字の$o$はoutput、$p$はpoolingの頭文字で、単に識別するための記号です)。

 ここからnp.max()で、行ごとに最大値を取り出します。行ごとなので、axis引数には1を指定します。
 結果は1次元配列になります。これを各次元が最初に指定した要素数(サイズ)になるように.reshape()で変換してから、.transpose()でデータ,チャンネル,高さ,横幅の順番になるように次元(軸)を入れ替えます(図7-20)。

# 最大値を抽出
output_data_col = np.max(input_data_col, axis=1)
print(output_data_col)
print(output_data_col.shape)

# 4次元配列に再変換
output_data = output_data_col.reshape(input_num, out_h, out_w, C).transpose(0, 3, 1, 2)
print(output_data)
print(output_data.shape)
[ 5. 21.  7. 23. 13. 29. 15. 31. 37. 53. 39. 55. 45. 61. 47. 63.]
(16,)
[[[[ 5.  7.]
   [13. 15.]]

  [[21. 23.]
   [29. 31.]]]


 [[[37. 39.]
   [45. 47.]]

  [[53. 55.]
   [61. 63.]]]]
(2, 2, 2, 2)

 最大値を抽出した段階では、要素数$N H_o W_o C$の1次元配列です。それを.reshape()で$(N, H_o, W_o, C)$の4次元配列に変形して、更に.transpose()で$(N, C, H_o, W_o)$に軸(次元)の順序を入れ替えます。

 以上がPoolingレイヤの順伝播の処理になります。

・逆伝播

 Poolingレイヤの順伝播では、Pooling適用領域内の最大値を抽出し伝播しました。これは最大値の要素のみが次のニューロンと繋がっていることを意味します。なので逆伝播では、最大値の要素にだけ伝播してきます。また順伝播では最大値をとるだけなので、各要素に関する微分は1です。従ってPoolingレイヤの逆伝播では、最大値の要素は逆伝播した値をそのまま(厳密には1を掛けた値を)、他の要素は0を次のニューロンに伝播します。

 では逆伝播の処理を確認してきます。

 こちらも処理の影響を捉えやすいように0から整数を並べた配列を用いることにします。逆伝播の入力データのサイズは、順伝播の出力データのサイズと同じです。
 .sizeは変数の要素数を返します。なのでnp.arange(output_data.size)で0からoutput_dataの要素数未満の整数列を生成し、.reshape()で出力データと同じ形状に変形します。

# 逆伝播の入力を生成
dout = np.arange(output_data.size).reshape(output_data.shape)
print(dout)
print(dout.shape)
[[[[ 0  1]
   [ 2  3]]

  [[ 4  5]
   [ 6  7]]]


 [[[ 8  9]
   [10 11]]

  [[12 13]
   [14 15]]]]
(2, 2, 2, 2)

 逆伝播の入力doutのサイズは$(N, C, H_o, W_o)$です。

 この軸の順番を0,1,2,3として、図7-20の逆向きの変形を行います。

# 逆伝播の入力を変形:(図7-20の逆方法)
dout = dout.transpose(0, 2, 3, 1)
print(dout)
print(dout.shape)
[[[[ 0  4]
   [ 1  5]]

  [[ 2  6]
   [ 3  7]]]


 [[[ 8 12]
   [ 9 13]]

  [[10 14]
   [11 15]]]]
(2, 2, 2, 2)

 サイズが$(N, H_o, W_o, C)$になりました。

 逆伝播の入力の受け皿を作成します。受け皿は「逆伝播の入力の要素数」行、「プーリングウィンドウの要素数」列の全ての要素が0の2次元配列になります。これは2次元配列に展開した状態です。

# Poolingの要素数を計算
pool_size = pool_h * pool_w
print(pool_size)

# 受け皿を作成
dmax = np.zeros((dout.size, pool_size))
print(dout.size)
print(dmax.shape)
4
16
(16, 4)

 ちなみに順伝播の入力を展開したinput\_data\_colと同じ形状$(N H_o W_o C, H_p W_p)$になります。

 input_data_colの各行の最大値のインデックスを取得します。

# 最大値のインデックスを取得
arg_max = np.argmax(input_data_col, axis=1)
print(arg_max.size)
16

 要素数$N H_o W_o C$の1次元配列となります。

 各行の最大値の列(Pooling適用領域の最大値の要素)に、逆伝播の入力の値を代入します。代入先は各行1要素になるので、dout.flattenで1次元配列に変換してから代入します。最大値以外の(順伝播しなかった)要素は0を逆伝播します。

# 最大値の要素のみ伝播
dmax[np.arange(arg_max.size), arg_max.flatten()] = dout.flatten()
print(dmax.shape)
(16, 4)

 代入するだけなので形状は変わりません。(行のインデックスは:で、列は.flatten()なしでいいのでは(?))。

 (これまで避けていたのですが)タプル型変数の機能について少し触れます。要素を丸括弧で閉じることでタプル型変数となります。.shapeの返り値はタプル型です。また要素が1つのタプル型変数を作成する際は、要素の後に,を付けて括弧を閉じます。+でタプル型変数同士を結合できます。

# タプル型変数の追加
print(dout.shape)
print((pool_size,))
print(dout.shape + (pool_size,))
(2, 2, 2, 2)
(4,)
(2, 2, 2, 2, 4)

 (とりあえずここだけで使う機能だと思われるので、特に覚えなくても。)

 逆伝播させる全ての値を格納できたので、順伝播の入力と同じ形状に変形します。

# 4次元配列に変換
dmax = dmax.reshape(dout.shape + (pool_size,))
print(dmax.shape)
dcol = dmax.reshape(dmax.shape[0] * dmax.shape[1] * dmax.shape[2], -1)
print(dcol.shape)
dx = col2im(dcol, input_data.shape, pool_h, pool_w, stride=stride, pad=0)
print(dx.shape)
(2, 2, 2, 2, 4)
(8, 8)
(2, 2, 4, 4)

 1つ目の.reshape()で$(N, H_o, W_o, C, H_p W_p)$の5次元配列に変形し、2つ目の.reshape()で$(N H_o W_o, C H_p W_p)$の2次元配列に変形し、col2im()で順伝播の入力$\mathbf{X}$と同じ$(N, C, H, W)$に変換します。

 以上が順伝播と逆伝播の処理になります。

・実装

 処理の確認ができたので、Poolingレイヤをクラスとして実装します。

# Poolingレイヤの実装
class Pooling:
    
    # インスタンス変数の定義
    def __init__(self, pool_h, pool_w, stride=1, pad=0):
        self.pool_h = pool_h # Poolingの高さ
        self.pool_w = pool_w # Poolingの横幅
        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) # 出力サイズ:式(7.1)
        out_w = int(1 + (W - self.pool_w) / self.stride)
        
        # 入力データを2次元配列に展開
        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) # 4次元配列に再変換:(図7-20)
        
        # 逆伝播用に中間データを保存
        self.x = x # 入力データ
        self.arg_max = arg_max # 最大値のインデックス

        return out

    # 逆伝播メソッドの定義
    def backward(self, dout):
        # 入力データを変形:(図7-20の逆方法)
        dout = dout.transpose(0, 2, 3, 1)
        
        # 受け皿を作成
        pool_size = self.pool_h * self.pool_w # Pooling適用領域の要素数
        dmax = np.zeros((dout.size, pool_size)) # 初期化
        
        # 最大値の要素のみ伝播
        dmax[np.arange(self.arg_max.size), self.arg_max.flatten()] = dout.flatten()
        
        # 4次元配列に変換
        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


 では動作確認です。プーリングウィンドウのサイズとストライド(通常プーリングウィンドウと同じ値)を指定して、Poolingクラスのインスタンスを作成します。

# Poolingの高さを指定
pool_h = 2

# Poolingの横幅を指定
pool_w = 2

# ストライドを指定
stride = 2

# インスタンスを作成
pool = Pooling(pool_h=pool_h, pool_w=pool_w, stride=stride, pad=0)


 続いて順伝播の入力データを作成して、順伝播メソッドで処理します。

# 入力データ数を指定
input_num = 2

# 入力データのチャンネル数を指定
C = 2

# 入力データの高さを指定
input_h = 4

# 入力データの横幅を指定
input_w = 4

# 順伝播の入力データを生成
X = np.random.randn(input_num, C, input_h, input_w)
print(X.shape)

# 順伝播メソッド
Y = pool.forward(X)
print(Y.shape)
(2, 2, 4, 4)
(2, 2, 2, 2)

 これが順伝播の処理になります。この出力は次のConvolutionレイヤまたはAffineレイヤの入力になります。

 次は逆伝播の入力データを作成して、逆伝播メソッドで処理します。

# 逆伝播の入力データを生成
dout = np.random.randn(*Y.shape)
print(dout.shape)

# 逆伝播メソッド
dx = pool.backward(dout)
print(dx.shape)
(2, 2, 2, 2)
(2, 2, 4, 4)

 逆伝播に必要なパラメータ類はインスタンス変数として保存されているので、逆伝播の処理はこれだけです。

 以上でPoolingレイヤを実装できました。5章で実装した活性化レイヤと併せてこれで畳み込みニューラルネットワーク(CNN)に必要なパーツを準備できました。次節では、簡単なCNNを実装します。

参考文献

  • 斎藤康毅『ゼロから作るDeep Learning』オライリー・ジャパン,2016年.

おわりに

 これでCNNに必要なパーツは揃いました。ちょっとややこしいだけで難しくはなかったです。

2020年8月25日は、(元)「Berryz工房」兼「Buono!」現「PINK CRES.」の夏焼雅さんの28歳のお誕生日!おめでとうございます!

 サムネイル左の方です。私はBuono!が一番好きなんです!!!

【次節の内容】

www.anarchive-beta.com