はじめに
「プログラミング」学習初手『ゼロから作るDeep Learning』民のための実装攻略ノートです。『ゼロつく1』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。
関数やクラスとして実装される処理の塊を細かく分解して、1つずつ処理を確認しながらゆっくりと組んでいきます。
この記事は、7.4.2項「im2colによる展開」と7.4.3項「Convolutionレイヤの実装」の内容になります。CNNの処理を効率化するための4次元配列の入力データを2次元配列に展開する関数im2col()
をPythonで実装します。またConvolutionレイヤの順伝播の処理を確認します。
【前節の内容】
【他の節の内容】
【この節の内容】
7.2 畳み込み層
これまでのニューラルネットワークでは、Affineレイヤを用いました。Affineレイヤは、隣接する層の全てのニューロンが結合しています。これを全結合と呼びます。畳み込みニューラルネットワーク(CNN)では、畳み込み演算を行います。畳み込み演算について(の言葉での説明)は、本を読んでください。
7.2.2-4 畳み込み演算
入力サイズを(H, W)、フィルターサイズを(FH, FW)、出力サイズを(OH, OW)、パディングをP、ストライドをSとすると、出力サイズ(OH, OW)は次の式で計算できます。
これをプログラム上で計算します。
# 出力データの高さ H = 4 # 出力データの横幅 W = 4 # フィルターの高さ FH = 3 # フィルターの高さ FW = 3 # パディング P = 1 # ストライド S = 1 # 出力サイズを計算:式(7.1) OH = (H + 2 * P - FH) / S + 1 OW = (W + 2 * P - FW) / S + 1 print(OH, OW)
4.0 4.0
これは出力データの形状に関する値のため、整数である必要があります。なので実装の際には割り算の演算子/
ではなく、割り算の整数部分を返す演算子//
や、値の整数部分を返す関数int()
を使って小数部分(余り)を切り捨てます。
7.4 Convolution/Poolingレイヤの実装
7.4.1 4次元配列
7.2節のデータサイズに関するものを(違う記号を使いたいこともあり)ここでまとめておきます(図7-13)。
入力データのチャンネル数を$C$、高さを$H$、横幅を$W$とします。またバッチサイズを$N$とします。従って4次元の入力データがCNNを流れます。また入力サイズを$(N, C, W, H)$の順番とします。
フィルターの重みのチャンネル数は入力データのチャンネル数と同じ$C$であり、高さを$H_f$、横幅を$W_f$とします。またフィルター数を$N_f$とします。これはConvolutionレイヤ(畳み込み演算)の出力データのチャンネル数になります。つまりフィルターの重みも4次元データとなり、順番を$(N_f, C, H_f, W_f)$とします。添字の$f$は識別用のfilterの頭文字です。
バイアスは、出力データのチャンネルごとに1つだけ要素を持ちます。つまりチャンネル数が$N_f$で、高さと横幅が1になるので、サイズは$(N_f, 1, 1)$となります。
出力データ数は入力データと同じ$N$であり、チャンネル数はフィルター数$N_f$です。また高さを$H_o$、横幅を$W_o$とし、それぞれ式(7.1)により求められます。よってConvolutionレイヤの出力サイズは$(N, N_f, H_o, W_o)$になります。$o$はoutputのことです。
7.4.2 im2colによる展開
4次元配列のデータを効率よく処理するために、2次元配列に展開することにします。行列形式に変換することで、これまでのように計算することができます。ただしフィルターやストライドの影響を考慮して展開する必要があるため、.reshape()
で簡単に形状を変えることはできません。そこで展開処理関数im2col()
を実装します。
ちなみにim2col
はI'm too cool.(私って超イケてる。)の略だそうです。嘘です。
# Numpyをインポート import numpy as np
・処理の確認
まずはパディングを行い、その後フィルターとストライドを考慮した展開処理の流れを確認していきます。
処理をイメージしやすいように簡単な入力データとして、1から整数が並んだ各次元の要素数が2の4次元配列を作成します。
# 4次元配列を作成 input_data = np.array( [[[[1, 2], [3, 4]], [[5, 6], [7, 8]]], [[[9, 10], [11, 12]], [[13, 14], [15, 16]]]] ) print(input_data) print(input_data.shape)
[[[[ 1 2]
[ 3 4]]
[[ 5 6]
[ 7 8]]]
[[[ 9 10]
[11 12]]
[[13 14]
[15 16]]]]
(2, 2, 2, 2)
横(行)に2つ、縦(列)に2つ、奥に2つのブロックが2つあります。2のちなみに全体の要素数は$2^4 = 16$になります。
なので1から16までの1次元配列を作ってから、.reshape()
で形状を変更することで同じものを作れます。
# 4次元配列を作成 input_data = np.arange(1, 2 ** 4 + 1).reshape(2, 2, 2, 2) print(input_data) print(input_data.shape)
[[[[ 1 2]
[ 3 4]]
[[ 5 6]
[ 7 8]]]
[[[ 9 10]
[11 12]]
[[13 14]
[15 16]]]]
(2, 2, 2, 2)
各次元の要素数を取得します。1つ目の次元からバッチデータ数N
、チャンネル数C
、高さH
、横幅W
に対応します。
# 各次元の要素数を取得 N, C, H, W = input_data.shape print(N, C, H, W)
2 2 2 2
つまり縦横が2でチャンネルも2の画像2枚分のデータセットということです。
フィルターサイズとストライド、パディングを指定して、式(7.1)より出力データの高さと横幅を計算します。
# フィルターの高さ filter_h = 2 # フィルターの横幅 filter_w = 2 # ストライド stride = 1 # パディング pad = 1 # 出力データの高さ out_h = (H + 2 * pad - filter_h) // stride + 1 # 出力データの横幅 out_w = (W + 2 * pad - filter_w) // stride + 1 print(out_h, out_w)
3 3
//
は、割り算の(小数(余り)を切り捨てて)整数部分を返します。
np.pad()
でパディングを行います。第1引数に、配列データを渡します。第2引数には、各次元に対するパディングをリスト形式で指定します。リストの要素順と次元順が対応します。またリストの要素は、それぞれ要素数2のタプル型とします。その2つの要素は、各次元の要素の前(上)と後(下)のパディングになります。第3(mode
)引数に、パディングに使用する値を指定します。0で埋める場合は'constant'
を指定します。
はい分かりませんね。これは習うより慣れろ!とりあえず2次元配列に試して見ましょう。
# 2次元配列を作成 X = np.array([[1, 2], [3, 4]]) # 2次元配列に対するパディング X_pad = np.pad(X, [(1, 1), (1, 1)], 'constant') print(X_pad) print("input data :" + str(X.shape)) print("padding data:" + str(X_pad.shape))
[[0 0 0 0]
[0 1 2 0]
[0 3 4 0]
[0 0 0 0]]
input data :(2, 2)
padding data:(4, 4)
値を変えて試してみましょう。
入力データX
の行数(高さ)を$H$、列数(横幅)を$W$、第2引数に指定するリストを[(a, b), (c, d)]
とすると、X
の上に(全ての要素が0の行を)$a$行、下に$b$行追加します。列(2次元目)についても同様に、元のデータX
の左に$c$列、右に$d$列追加します。
つまりパディング後のデータの行数は$H + a + b$、列数は$W + c + d$になります。
では4次元の入力データに対してやってみましょう。4次元なので第2引数には、(2つセットの)要素が4つのリストを渡します。
CNNではあくまで画像データごとにパディングするので、3つ目と4つ目の要素(次元)のみ値を指定します。
# 4次元配列に対するパディング input_data_pad = np.pad(input_data, [(0, 0), (0, 0), (pad, pad), (pad, pad)], 'constant') print(input_data_pad) print("input data :" + str(input_data.shape)) print("padding data:" + str(input_data_pad.shape))
[[[[ 0 0 0 0]
[ 0 1 2 0]
[ 0 3 4 0]
[ 0 0 0 0]]
[[ 0 0 0 0]
[ 0 5 6 0]
[ 0 7 8 0]
[ 0 0 0 0]]]
[[[ 0 0 0 0]
[ 0 9 10 0]
[ 0 11 12 0]
[ 0 0 0 0]]
[[ 0 0 0 0]
[ 0 13 14 0]
[ 0 15 16 0]
[ 0 0 0 0]]]]
input data :(2, 2, 2, 2)
padding data:(2, 2, 4, 4)
これも値を変更して、動作を確かめましょう。ただしパディングの値は出力データサイズに影響するので、変数pad
の値を変更するときは他の処理も再実行する必要があります。
次に出力データの中間データを受け皿となる変数を作成しておきます。形状は次のように6次元配列とします。
# 出力データの受け皿を作成 tmp_input_data_col = np.zeros((N, C, filter_h, filter_w, out_h, out_w)) print(tmp_input_data_col.shape)
(2, 2, 2, 2, 3, 3)
この変数のサイズは$(N, C, H_f, W_f, H_o, W_o)$の6次元配列です。入力データから順番に取り出した要素をここに代入していきます。
変数(オブジェクト)から必要な要素にアクセスするスライスについて確認しておきます。これまでは、x[m:n]
で1次元配列x
のm
個目の要素からn-1
個目の要素を抽出してきました。このときもう1つ:
(ダブルコロン)と値を指定することで、抽出する要素の間隔を指定できます。つまりx[m:n:l]
とすることで、m
からn
までをl
間隔で抽出します。とりあえずやってみましょう!
# 0から9の整数を並べた1次元配列を作成 x = np.arange(10) print(x) # 偶数の要素を抽出 print(x[0:10:2]) # 奇数の要素を抽出 print(x[1:10:2])
[0 1 2 3 4 5 6 7 8 9]
[0 2 4 6 8]
[1 3 5 7 9]
0から始めて2個先の要素、2個先の要素と繰り返し10未満の要素を取得します。よって10未満の偶数を取り出すことができます。1から始めると奇数の要素にアクセスできます。
続いて2次元配列にもやってみましょう。
# 0から99の整を並べた2次元配列を作成 X = np.arange(100).reshape(10, 10) print(X)
[[ 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 64 65 66 67 68 69]
[70 71 72 73 74 75 76 77 78 79]
[80 81 82 83 84 85 86 87 88 89]
[90 91 92 93 94 95 96 97 98 99]]
2次元配列に対するスライスは、x[行のインデックス, 列のインデックス]
でした。またその次元の全ての要素を指定するときは、:
のみを指定します。
# 偶数行を抽出 print(X[0:10:2, :]) # 奇数列を抽出 print(X[:, 1:10:2])
[[ 0 1 2 3 4 5 6 7 8 9]
[20 21 22 23 24 25 26 27 28 29]
[40 41 42 43 44 45 46 47 48 49]
[60 61 62 63 64 65 66 67 68 69]
[80 81 82 83 84 85 86 87 88 89]]
[[ 1 3 5 7 9]
[11 13 15 17 19]
[21 23 25 27 29]
[31 33 35 37 39]
[41 43 45 47 49]
[51 53 55 57 59]
[61 63 65 67 69]
[71 73 75 77 79]
[81 83 85 87 89]
[91 93 95 97 99]]
偶数行、奇数列を取り出しました。
これを両方指定すると、偶数行かつ奇数列の要素にアクセスできます。
# 偶数行かつ奇数列を抽出 print(X[0:10:2, 1:10:2])
[[ 1 3 5 7 9]
[21 23 25 27 29]
[41 43 45 47 49]
[61 63 65 67 69]
[81 83 85 87 89]]
次はこの機能を使って効率よくフィルターとストライドの処理を行います。
(図解せず言葉で説明しようとしたら残念な感じになってしまいました。後で別の方法でも解説を試みるので、次の説明は正直流し読みで十分ですよ、、)
y
とx
は、それぞれフィルターの行と列を表すインデックスです。for
文でそれぞれフィルターサイズまで1ずつ増えていくようにします。つまりfilter_h * filter_w
回の処理で全ての要素を抽出します。
y
とx
からそれぞれ行と列方向にstride
間隔で要素を取り出すことで、フィルターの$y, x$要素に対応する入力データの要素を一度に抽出できます。またout_h
は行方向に、out_w
は列方向にストライドする回数とも言えます。
このことを図7-7を使って(無理くり)説明します。この例ではストライドが2です。
最初のy
とx
は0(Pythonの添字は0から数えることに注意)ですね。フィルターの$0, 0$要素とは、1回目の範囲(上の図)では、左上の1の位置(要素)を示しています。2回目の範囲(下の図)では、(グレーの範囲の)左上の3に対応します。(図はないですが)3回目では、更に2つ隣の1に対応します。ただし4回目はここから2つ右に移動するのではなく、最初の1から2つ下に移動します。よって右上の3は、フィルター$0, 0$要素としては抽出しません。
4回目は、(最初の1から2つ下の)3なので、5,6回目も同様にそこから右に1,3です。そして更に2つ隣の1は含めません。
同様に7,8,9回目は、1,3,1ですね。
これが入力データの$0, 0$要素からstride
間隔で取り出したい要素です。図7-7の例だと7列目の要素を含めないために、y
にストライド(間隔)とout_h
・out_w
(行・列方向へのストライド回数)の積を加えた値を最大値y_max
・x_max
として、input_data_pad
からスライスで抽出します。
次のループ処理では$y = 0,\ x = 1$となり、それぞれ1つ右の要素になります。これをfilter_h * filter_w
回繰り返します。
抽出した要素は、一旦まとめて格納しておきます。最後に.transpose()
と.reshape()
で形状を整えます。.transpose()
は、軸(次元)を入れ替えます。reshape()
は、要素の順序を保ったまま各次元の要素数を変更します。
# 行方向のインデックス for y in range(filter_h): # 行方向の最大値を計算 y_max = y + stride * out_h # 列方向のインデックス for x in range(filter_w): # 列方向の最大値を計算 x_max = x + stride * out_w # フィルターのy,x要素に対応する入力データの要素を抽出 tmp_input_data_col[:, :, y, x, :, :] = input_data_pad[:, :, y:y_max:stride, x:x_max:stride] # 出力サイズに整形 input_data_col = tmp_input_data_col.transpose(0, 4, 5, 1, 2, 3).reshape(N * out_h * out_w, -1) print(input_data_col)
[[ 0. 0. 0. 1. 0. 0. 0. 5.]
[ 0. 0. 1. 2. 0. 0. 5. 6.]
[ 0. 0. 2. 0. 0. 0. 6. 0.]
[ 0. 1. 0. 3. 0. 5. 0. 7.]
[ 1. 2. 3. 4. 5. 6. 7. 8.]
[ 2. 0. 4. 0. 6. 0. 8. 0.]
[ 0. 3. 0. 0. 0. 7. 0. 0.]
[ 3. 4. 0. 0. 7. 8. 0. 0.]
[ 4. 0. 0. 0. 8. 0. 0. 0.]
[ 0. 0. 0. 9. 0. 0. 0. 13.]
[ 0. 0. 9. 10. 0. 0. 13. 14.]
[ 0. 0. 10. 0. 0. 0. 14. 0.]
[ 0. 9. 0. 11. 0. 13. 0. 15.]
[ 9. 10. 11. 12. 13. 14. 15. 16.]
[10. 0. 12. 0. 14. 0. 16. 0.]
[ 0. 11. 0. 0. 0. 15. 0. 0.]
[11. 12. 0. 0. 15. 16. 0. 0.]
[12. 0. 0. 0. 16. 0. 0. 0.]]
なるほど分かりませんね。
分かりやすいように0から整数を順番に並べた同じ形状のオブジェクトを作成して試してみましょう。
# 整数が並んだ同じ形状の変数を作成 input_data_pad = np.arange(input_data_pad.size).reshape(input_data_pad.shape) print(input_data_pad)
[[[[ 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]]]]
これはパディングされた入力データに対応します。
ループ処理の途中経過を表示して、処理の中身を確認しましょう。
# 出力データに関する変数を初期化 tmp_input_data_col = np.zeros((N, C, filter_h, filter_w, out_h, out_w)) input_data_col = tmp_input_data_col.transpose(0, 4, 5, 1, 2, 3).reshape(N * out_h * out_w, -1) # 行方向のインデックス for y in range(filter_h): # 行方向の最大値を計算 y_max = y + stride * out_h # 列方向のインデックス for x in range(filter_w): # 列方向の最大値を計算 x_max = x + stride * out_w # フィルターのy,x要素に対応する入力データの要素を抽出 tmp_input_data_col[:, :, y, x, :, :] = input_data_pad[:, :, y:y_max:stride, x:x_max:stride] # 途中経過を表示 # 出力サイズに整形 input_data_col = tmp_input_data_col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1) print("========== y:" + str(y) + ", x:" + str(x) + " ==========") print(input_data_col) print(input_data_col.shape)
・出力(クリックで展開)
========== y:0, x:0 ==========
[[ 0. 0. 0. 0. 16. 0. 0. 0.]
[ 1. 0. 0. 0. 17. 0. 0. 0.]
[ 2. 0. 0. 0. 18. 0. 0. 0.]
[ 4. 0. 0. 0. 20. 0. 0. 0.]
[ 5. 0. 0. 0. 21. 0. 0. 0.]
[ 6. 0. 0. 0. 22. 0. 0. 0.]
[ 8. 0. 0. 0. 24. 0. 0. 0.]
[ 9. 0. 0. 0. 25. 0. 0. 0.]
[10. 0. 0. 0. 26. 0. 0. 0.]
[32. 0. 0. 0. 48. 0. 0. 0.]
[33. 0. 0. 0. 49. 0. 0. 0.]
[34. 0. 0. 0. 50. 0. 0. 0.]
[36. 0. 0. 0. 52. 0. 0. 0.]
[37. 0. 0. 0. 53. 0. 0. 0.]
[38. 0. 0. 0. 54. 0. 0. 0.]
[40. 0. 0. 0. 56. 0. 0. 0.]
[41. 0. 0. 0. 57. 0. 0. 0.]
[42. 0. 0. 0. 58. 0. 0. 0.]]
========== y:0, x:1 ==========
[[ 0. 1. 0. 0. 16. 17. 0. 0.]
[ 1. 2. 0. 0. 17. 18. 0. 0.]
[ 2. 3. 0. 0. 18. 19. 0. 0.]
[ 4. 5. 0. 0. 20. 21. 0. 0.]
[ 5. 6. 0. 0. 21. 22. 0. 0.]
[ 6. 7. 0. 0. 22. 23. 0. 0.]
[ 8. 9. 0. 0. 24. 25. 0. 0.]
[ 9. 10. 0. 0. 25. 26. 0. 0.]
[10. 11. 0. 0. 26. 27. 0. 0.]
[32. 33. 0. 0. 48. 49. 0. 0.]
[33. 34. 0. 0. 49. 50. 0. 0.]
[34. 35. 0. 0. 50. 51. 0. 0.]
[36. 37. 0. 0. 52. 53. 0. 0.]
[37. 38. 0. 0. 53. 54. 0. 0.]
[38. 39. 0. 0. 54. 55. 0. 0.]
[40. 41. 0. 0. 56. 57. 0. 0.]
[41. 42. 0. 0. 57. 58. 0. 0.]
[42. 43. 0. 0. 58. 59. 0. 0.]]
========== y:1, x:0 ==========
[[ 0. 1. 4. 0. 16. 17. 20. 0.]
[ 1. 2. 5. 0. 17. 18. 21. 0.]
[ 2. 3. 6. 0. 18. 19. 22. 0.]
[ 4. 5. 8. 0. 20. 21. 24. 0.]
[ 5. 6. 9. 0. 21. 22. 25. 0.]
[ 6. 7. 10. 0. 22. 23. 26. 0.]
[ 8. 9. 12. 0. 24. 25. 28. 0.]
[ 9. 10. 13. 0. 25. 26. 29. 0.]
[10. 11. 14. 0. 26. 27. 30. 0.]
[32. 33. 36. 0. 48. 49. 52. 0.]
[33. 34. 37. 0. 49. 50. 53. 0.]
[34. 35. 38. 0. 50. 51. 54. 0.]
[36. 37. 40. 0. 52. 53. 56. 0.]
[37. 38. 41. 0. 53. 54. 57. 0.]
[38. 39. 42. 0. 54. 55. 58. 0.]
[40. 41. 44. 0. 56. 57. 60. 0.]
[41. 42. 45. 0. 57. 58. 61. 0.]
[42. 43. 46. 0. 58. 59. 62. 0.]]
========== y:1, x:1 ==========
[[ 0. 1. 4. 5. 16. 17. 20. 21.]
[ 1. 2. 5. 6. 17. 18. 21. 22.]
[ 2. 3. 6. 7. 18. 19. 22. 23.]
[ 4. 5. 8. 9. 20. 21. 24. 25.]
[ 5. 6. 9. 10. 21. 22. 25. 26.]
[ 6. 7. 10. 11. 22. 23. 26. 27.]
[ 8. 9. 12. 13. 24. 25. 28. 29.]
[ 9. 10. 13. 14. 25. 26. 29. 30.]
[10. 11. 14. 15. 26. 27. 30. 31.]
[32. 33. 36. 37. 48. 49. 52. 53.]
[33. 34. 37. 38. 49. 50. 53. 54.]
[34. 35. 38. 39. 50. 51. 54. 55.]
[36. 37. 40. 41. 52. 53. 56. 57.]
[37. 38. 41. 42. 53. 54. 57. 58.]
[38. 39. 42. 43. 54. 55. 58. 59.]
[40. 41. 44. 45. 56. 57. 60. 61.]
[41. 42. 45. 46. 57. 58. 61. 62.]
[42. 43. 46. 47. 58. 59. 62. 63.]]
(18, 8)
この例では$2 \times 2$のフィルターなので、4回の処理で全ての要素を抽出できます。1回目の処理で埋まった2つの列が、全てのデータ$n = 1, \cdots, N$のそれぞれ1・2チャンネルの1行1列目の要素になります。
最終的な出力の1行目の1から21の要素に注目します。これは1回目のフィルターの範囲の要素になります。つまり変換前のデータの1行1列目の要素0
から$2 \times 2$の範囲の要素と、2チャンネル目の1行1列目の要素16
からの要素を並べたものになります。
同様に2行目はフィルターをスライドさせたときの要素を横に並べたものになります。
またこの変数は展開された出力データ$\mathbf{A}^{\mathrm{col}}$に対応し、サイズは$(N H_o W_o, C W_f W_f)$となります。
・数式で確認
im2col()
の処理を(一応)数式からも確認してみます。
変換前のデータ$\mathbf{X}$を
とします。$\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$とします。
これはパディング後のデータになります(入力データがこの形状でパディングが0としてもいいです)。
$\mathbf{X}$から$2 \times 2$のフィルターにかかる要素を取り出すと
です。何回目に取り出した要素なのかを括弧付きの指数で示すことにします。
取り出した要素を横一列に並べた
が、input_data_col
の1行目になります。またこれは展開後の入力データのことなので、右肩に$\mathrm{col}$として書き分けることにします。抽出回数が、展開後の行数に対応します。
ストライドを1とすると、次の範囲の要素は
なので、横に並べると
になります。1つ目のチャンネルの要素の右隣に2つ目のチャンネルの要素というように並びます。もう少し詳しく見ると、右の要素になるほど、添字の右の値から順番に大きくなることが分かります。これは、行番号y
のfor
ループの中にx
に関するループ処理が含まれていることによるものです。
これを繰り返すと、最終的に
となります。これがinput_data_col
に対応します。
・Convolutionレイヤの順伝播
(このままやった方が効率的だと思うので、続いて)畳み込み演算の(出力の)$1, 1$要素について考えます(参考:図7-9の1つ目)。
$2 \times 2$のフィルターの重みを
とします。ここでフィルターの高さを$H_f = 2$、横幅を$W_f = 2$と表すことにします。またこの例ではフィルター数$N_f$を1とします。
対応する入力データは先ほどの1回目のデータ
です。
Convolutionレイヤの出力データの$1, 1$要素は、対応する各要素の積$\odot$
の全ての要素の和で計算できます。
この計算は次のように行列の計算でも行えます。
2回目の範囲(出力データの$1, 2$要素)の計算は、フィルターの重みはそのまま$\mathbf{X}_2^{\mathrm{col}}$を入れ替えたものになります。また前の項を$\mathbf{X}_1^{\mathrm{col}}$と$\mathbf{X}_2^{\mathrm{col}}$の2行にすることで、$1, 1$要素と$1, 2$要素を一緒に計算することができます。
つまり2次元配列(行列)の(パディング後の)入力データ$\mathbf{X}^{\mathrm{col}}$に対応するように、フィルターの重みも展開することで、行列の積により畳み込み演算(Convolutionレイヤの順伝播)の計算を行えることが分かりました。
これを一般化すると次のように計算できます。
このときフィルター数は$N_f$です。(添字が合っているかはあまり自信ない、、)
ただしこの計算結果$\mathbf{A}^{\mathrm{col}}$も4次元データに戻す必要があります。
・im2col
の実装
これまでの処理をまとめて、im2col
関数を定義します。
# 入力データの展開関数を定義 def im2col(input_data, filter_h, filter_w, stride=1, pad=0): # 入力データのサイズを取得 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 # フィルターのy,x要素に対応する入力データの要素を抽出 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
早速実装した関数を使ってConvolutionレイヤの順伝播の処理を行ってみます。
・Convolutionレイヤの順伝播の処理の確認
im2col()
の効果を試すためにも、次項の内容をやってみます。
入力サイズとフィルターサイズを指定します。ただし横幅をW
にすると重みと変数名が被るので、この例では入力サイズに関する変数をinput_
とします。
# 入力データ数を指定 input_num = 10 # 入力データのチャンネルを指定 C = 3 # 入力データの高さを指定 input_h = 7 # 入力データの横幅を指定 input_w = 7 # フィルター数を指定 filter_num = 3 # フィルターの高さを指定 filter_h = 5 # フィルターの横幅を指定 filter_w = 5
指定したサイズに従い入力データを生成します。そのデータをim2col()
で展開します。
# 入力データをランダムに生成 input_data = np.random.rand(input_num, C, input_h, input_w) print(input_data.shape) # 2次元配列に変換 input_data_col = im2col(input_data, filter_h, filter_w, stride=1, pad=0) print(input_data_col.shape)
(10, 3, 7, 7)
(90, 75)
$(N, C, H, W)$の4次元配列から$(N H_o W_o, C H_f W_f)$の2次元配列に展開できました。出力サイズ$H_o,\ W_o$は関数内で計算されています。
続いてフィルターの重みを生成して、こちらも展開します。フィルターの重みは単に並び替えるだけでいいので、.reshape()
で行います。ただし縦方向に並べ替える必要があるため、.T
で転置します。
# フィルターの重みをランダムに生成 W = np.random.rand(filter_num, C, filter_h, filter_w) print(W.shape) # 2次元配列に変換 W_col = W.reshape(filter_num, -1).T print(W_col.shape)
(3, 3, 5, 5)
(75, 3)
$(N_f, C, H_f, W_f)$の4次元配列から$(C H_f W_f, N_f)$の2次元配列に展開できました。こちらの展開は次元を削減するように並べ替えただけなので、全体の要素数は変わりません。
np.dot()
で行列の積を計算します。本来はこの計算結果にバイアスを加えますがこの例では省略します。
最後に計算結果を4次元データに戻します。こちらも.reshape()
で次元(軸)を変換してから、.transpose()
で次元の順序を調整します。
# 行列の積を計算 output_data_col = np.dot(input_data_col, W_col) print(output_data_col.shape) # 4次元配列に再変換 output_data = output_data_col.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2) print(output_data.shape)
(90, 3)
(2, 15, 3, 3)
計算結果は$(N H_o W_o, N_f)$になります。それを.reshape()
で$(N, H_o, W_o, N_f)$の4次元配列に変形し、更に.transpose()
で$(N, N_f, H_o, W_o)$に軸(次元)の順序を入れ替えます。
これがConvolutionレイヤの順伝播の処理になります。
以上で、CNNの順伝播で用いるパディングやストライドを考慮して4次元配列を2次元配列に展開する関数を実装できました。続いて、逆伝播で用いる4次元配列から2次元配列に戻す関数を実装します。
・col2im
の実装
逆伝播で用いる2次元配列から4次元配列に変換する関数col2im()
を実装します。基本的にim2col()
の反対の処理を行います。
im2col()
では第1引数に渡した(順伝播の)入力データから入力サイズを取得しました。col2im()
では入力データ自体は必要ないため、入力サイズを第2引数に受け取ります。フィルターサイズの情報と共に出力サイズを計算します。(第1引数に渡した)展開された2次元配列の逆伝播データの本来のサイズを計算して変換します。
またim2col()
では、4次元データから2次元データに変換する際に、6次元の中間データにしました。col2im()
でも同様に、2次元データから4次元データに変換する際に、6次元の中間データを経由します。スライスや代入についても、設定はそのままで代入の方向を入れ替えます。
# 出力データの再変換関数の定義 def col2im(col, input_shape, filter_h, filter_w, stride=1, pad=0): # 入力データのサイズを取得 N, C, H, W = input_shape # 出力データのサイズを計算 out_h = (H + 2 * pad - filter_h) // stride + 1 out_w = (W + 2 * pad - filter_w) // stride + 1 # データとチャンネルに関する軸を分割 col = col.reshape(N, out_h, out_w, C, filter_h, filter_w).transpose(0, 3, 4, 5, 1, 2) # 出力データの受け皿を初期化 img = np.zeros((N, C, H + 2 * pad + stride - 1, W + 2 * pad + stride - 1)) # 行方向のインデックス for y in range(filter_h): # 行方向の最大値を計算 y_max = y + stride * out_h # 列方向のインデックス for x in range(filter_w): # 列方向の最大値を計算 x_max = x + stride * out_w # フィルターのy,x要素に対応する入力データの要素を抽出 img[:, :, y:y_max:stride, x:x_max:stride] += col[:, :, y, x, :, :] return img[:, :, pad:H + pad, pad:W + pad]
ここで実装した関数を用いることでCNNの処理を効率的に行うことができます。次項では、畳み込みレイヤを実装します。
参考文献
im2col - "I'm too cool"
— Koki Saitoh 斎藤康毅 (@SaitohKoki) 2020年7月10日
im2colは「俺まじイケてる」という意味😁 https://t.co/kUgoNeCSaL
おわりに
4次元配列の構造や変形についてもう少し理解を深めたいなぁと思いつつ先を進めます。
【次節の内容】