PyTorchとTensorFlowのTransposed convolution layerの処理手続きの違い
状況
DCGANの実装について、DCGANの論文とPyTorchのTutorialを元に調べていた。
Tutorialでは、Generatorが 1024 x 4 x 4 のFeatureMapを 512 x 8 x 8 にUpsamplingする際、以下の層を用いる。
nn.ConvTranspose2d(in_channel=1024, out_channnel=512, kernel_size=4, stride=2, padding=1, bias=False),
実際、1 x 4 x 4 のテンソルをこの層に与えると、以下のように (1 x 8 x 8)の出力が得られる。
# transposed-convolution layer trans_conv = nn.ConvTranspose2d(in_channels=1, out_channels=1, kernel_size=4, stride=2, padding=1, bias=False) # initialize weights with 1 trans_conv.weight.data = trans_conv.weight.data.fill_(1) # input tensor input_tensor = torch.Tensor([[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]]) input_tensor = input_tensor.reshape((1, 1, 4, 4)) print(f"Input tensor shape: {input_tensor.shape}") print(f"Output tensor shape: {trans_conv(input_tensor).shape}") print(f"Output tensor: \n{trans_conv(input_tensor)}") # Input tensor shape: torch.Size([1, 1, 4, 4]) # Output tensor shape: torch.Size([1, 1, 10, 10]) # tensor([[[[1., 2., 2., 2., 2., 2., 2., 1.], # [2., 4., 4., 4., 4., 4., 4., 2.], # [2., 4., 4., 4., 4., 4., 4., 2.], # [2., 4., 4., 4., 4., 4., 4., 2.], # [2., 4., 4., 4., 4., 4., 4., 2.], # [2., 4., 4., 4., 4., 4., 4., 2.], # [2., 4., 4., 4., 4., 4., 4., 2.], # [1., 2., 2., 2., 2., 2., 2., 1.]]]], # grad_fn=<SlowConvTranspose2DBackward>)
一方、論文では以下の図のように、kernel size=5, stride=2と記述されている。
しかし、この設定を用いると(いかなるPaddingを設定しても)、PyTorchでは 1 x 8 x 8 の出力は得られない。
これはどういうことだ?というのが調査のスタート地点。
結論の概略
調査の結果、Transposed convolution layerは少なくとも2つの異なる計算方法があり、それによってkernel sizeやstrideを処理にどう用いるかが異なってくることが分かった。
DCGANの論文のパラメータは、TensorFlowが用いている処理手法を前提にしているようで、DCGANの論文を(TensorFlowをbackboneにした)Kerasで実装している記事では、kernel size=5, stride=2を用いていた。一方、PyTorchのTutorialではパラメータをkernel size=4, stride=1で計算しており、出力されるFeatureMapのShapeは同じなものの、計算手続きが異なるため出力テンソルの値も異なる。
以下、それぞれの処理手法を記載、比較する。
PyTorchが用いている処理手法
入力テンソルは (1 x 4 x 4)、kernel_size=4, stride=2, padding=1の設定で考える。
また、入力テンソルの値とレイヤーの重みは全て1とする。
処理手続きは以下。
0埋めされた出力テンソルを用意
1 x 10 x 10 の0埋めされた出力テンソルが作られる。
10 x 10 なのは次の工程の処理範囲が10 x 10に及ぶため。
Inputのテンソルを走査
各走査の処理としては、Inputのあるグリッドの値とカーネルの重みを掛け、結果をOutputのテンソルに加算する。
今回、入力テンソルは以下のように 1 x 4 x 4 のShapeの全て1の値を持つテンソルである。
そして、1回目の走査処理が行われるセルの値は1。
この1の値を、以下のカーネルの値と掛け合わせます。
結果、全てのセルに1を持つ(1 x 4 x 4)のShapeのテンソルが得られます。このテンソルを、出力テンソルの (1, 1) の位置を基準に加算すると、以下の出力テンソルが得られます。
これを、全ての入力テンソルのグリッドに対して行うと、以下の 1 x 10 x 10 の出力テンソルが得られます。
Paddingの適用
最後に、Paddingを適用します。Paddingは、指定された数だけ外周を削除する処理です。
ここではpadding=1が指定されているので、外周を1マス分削除すると以下の結果が得られます。
参考にしたサイト
このパターンの処理手続きについては、以下のサイトを参考にしました。
TensorFlowが用いている処理手法
入力テンソルは (1 x 4 x 4)、出力テンソルが(1 x 8 x 8)、kernel_size=5, stride=2, padding="same"の設定で考える。
また、入力テンソルの値とレイヤーの重みは全て1とする。Kerasを用いた実際の計算結果は以下。
from tensorflow.keras.layers import Conv2DTranspose from tensorflow.keras import initializers import numpy as np # Input input_array = np.array([[1,1,1,1],[1,1,1,1],[1,1,1,1],[1,1,1,1]], dtype=np.float32).reshape((1,4,4,1)) # c, w, h print(f"Input tensor shape: {input_array.shape}") # Transposed convolution layer layer = Conv2DTranspose(1, kernel_size=5, strides=2, padding='same', kernel_initializer=initializers.Ones()) # Calculation print(f"Output tensor shape: {layer(input_array).shape}") print(f"Output tensor: \n{np.array(layer(input_array)).reshape((8, 8))}") # Input tensor shape: (1, 4, 4, 1) # Output tensor shape: (1, 8, 8, 1) # Output tensor: # [[1. 2. 2. 3. 2. 3. 2. 2.] # [2. 4. 4. 6. 4. 6. 4. 4.] # [2. 4. 4. 6. 4. 6. 4. 4.] # [3. 6. 6. 9. 6. 9. 6. 6.] # [2. 4. 4. 6. 4. 6. 4. 4.] # [3. 6. 6. 9. 6. 9. 6. 6.] # [2. 4. 4. 6. 4. 6. 4. 4.] # [2. 4. 4. 6. 4. 6. 4. 4.]]
InputテンソルにStrideを反映
StrideがN+1の場合、Nの数の長さだけInputテンソルのグリッドを格子状に変形します。今回はN=1なので、各値の間を1つ分ゼロパディングします。
Inputのテンソルを「カーネルで」走査
PyTorchではInputテンソルの各セルを1つずつ走査し、Inputテンソルの1セル×カーネルをOuputテンソルに反映していた。
一方、Kerasではカーネルのサイズの窓を1セルずつずらして走査し、Inputテンソルの窓に含まれる値の総和をOutputテンソルの1セルに反映する。
なぜこの位置から走査が始まるのかは、padding="solid"の結果と比較すると分かりやすい。
padding="solid"にすると、入力テンソルの左上にから右下に掛けて走査され、結果、以下のように 1 x 11 x 11の結果が得られる。
この結果のサブセット(青いセル)が、padding="same"にした場合の出力である。
KerasのConv2DTranspose layerのドキュメントによると、validはpaddingなし、sameは入力テンソル(stride反映後)と同じShapeのテンソルが出力されるように調整されるとある。
つまり、validの設定より得られる出力テンソルから、Stride反映後の入力テンソルと同じサイズになるよう外周を取り除いた(paddingした)結果を返すのがpadding="same"の挙動のようだ。
PyTorch方式とTensorFlow方式の比較
どちらの計算方法にしても、ある領域の入力テンソルとカーネルの重みを掛けた総和を出力している。
今回の計算では入力テンソルの値も重みも1なので、出力テンソルのセルが考慮している入力テンソルのセルの数が値として表現される。
以下の設定で比較したとき、
# PyTorch trans_conv = nn.ConvTranspose2d(in_channels=1, out_channels=1, kernel_size=4, stride=2, padding=1, bias=False) # Keras(TensorFlow) layer = Conv2DTranspose(1, kernel_size=5, strides=2, padding='same')
出力されるテンソルはそれぞれ以下のようになる。
特徴を比べてみると以下のようになる。
PyTorchは、 * シンメトリック * 値の幅が1~4
TensorFlowは * シンメトリックではない * 値の幅が1~9
この違いが学習にどういった影響を与えるのか今のところ分からないが、特徴の処理の手法が異なることは間違いないので、利用するライブラリを変更したら学習結果が大きく変わるということが起きそうな気がしている。
同じ出力結果を得られるパラメータ設定やオプションはあるのだろうか。。
まとめ
長くなってしまったが、DCGANの論文で示されている「kernel size=5, stride=2」のパラメータを用いて、どうやったら 1024 x 64 x 64 の特徴マップを 512 x 32 x 32 に変換できるのかという当初の疑問については以下の結論を持って解消することができた。
- TensorFlowが用いている計算手法を利用すれば変換可能
- PyTorchを用いる場合はkernel_size=4, stride=2, padding=1を利用すれば変換可能
余談としては、任意のShapeの出力は異なる組み合わせのパラメータで出力することが可能なので、kernel_size=4, stride=2, padding=1だけでなく、kernel_size=5, stride=1, padding=0でもよい。