「何をやってるんか、わからへんかった」の続き...
ややこしい畳込み
「やりたいことと、やってることが、照合しやすい」 PrimitiveConvLayer
11月12日に書いたように畳込みについて、「やりたいことと、やってることが、照合しやすいコード」として PrimitiveConvLayer を作ってufiesia0のNeuronに加えました。
そこで書いたように、PrimitiveConvLayerと、pyainoのNeuronのConv2dLayerとは、同じ設定で同じ入力ならば同じ結果になることを確認しています。
でもやっぱり、pyainoのNeuronの Conv2dLayer はわからないよね?
pyainoのNeuronに実装した Conv2dLayer は、ufiesia0のPrimitiveConvLayer とは隔たりがあって、PrimitiveConvLayerでやっていることが分かったとしても、pyainoのNeuronのConv2dLayer はやっぱりわからない、ということになるのではないかと思います。
がんばって説明してみます。
というわけで、人のためというのは嘘で、なんでも忘れてしまう自分のために、説明しておこうと思います。
PrimitiveConvLayerの核となる処理
PrimitiveConvLayerのforwardメソッドの核となるのはループの部分です。この部分を取り出すと、
for ih in range(0, Ih-Fh+2*pad+1, Sh): # ih+FhがIhからはみ出さないように
for iw in range(0, Iw-Fw+2*pad+1, Sw): # iw+FwがIwからはみ出さないように
xij = self.x[:,:,ih:ih+Fh, iw:iw+Fw] # xのFh*Fwの領域を取出す
xij = xij.reshape(B, -1)
uij = np.dot(xij, self.w) + self.b # 取出した領域を共通のwとbでaffine変換 ⇒これ!
u[:,ih//Sh,iw//Sw,:] = uij # uの該当箇所に値を設定
このループの中では、パディング付きで入力を設定したself.xから、Fh*Fwの領域を取り出して、それをaffine変換し、その結果をあらかじめ領域確保したuの該当箇所に埋めていきます。
つまり、取出し⇒変換⇒埋めです。
この中で変換にあたっては、取り出したFh*Fwの狭い領域を xij = xij.reshape(B, -1)でB(バッチ軸)×ベクトルの形状にしています。
細かいことを言うと、この時に xij = self.x[:,:,ih:ih+Fh, iw:iw+Fw] で上位2次元分は : で全部取り出していて、これらは順にB,Cの軸となっているから、C(チャネル軸)もFh,Fwとともにベクトルにされています。
ともかくも、取り出したものをベクトルにして、共通の重み(self.w)とバイアス(self.b)でaffine変換しているのだから、このaffine変換は、毎回ループの中でやらなくても、取り出したものを並べておいて、並べ終わってから、まとめて行うことができる、ということになります。
つまりこんな感じです。👇
for ih in range(0, Ih-Fh+2*pad+1, Sh): # ih+FhがIhからはみ出さないように
for iw in range(0, Iw-Fw+2*pad+1, Sw): # iw+FwがIwからはみ出さないように
xij = self.x[:,:,ih:ih+Fh, iw:iw+Fw] # xのFh*Fwの領域を取出す
col[:,ih//Sh,iw//Sw,:] = xij.reshape(B, -1) # colの該当箇所に値を設定
u = np.dot(col, self.w) + self.b # まとめてaffine変換
ループの中はaffine変換に都合が良いように並べ替えているだけ。
そうすると、ループの中でやっていることは、結局のところ、データを並べ替えて形を変え、affine変換が一発で出来るようにしているだけ、ということになります。
並べ替えて形を変える部分だけをConvLayerのforwardメソッドからIm2colとして切り出す。
先にaffine変換をループの外に取り出しましたが、そうすると全体の流れがすっきりします。
つまり、
入力データを並べ替えて形を変える⇒affine変換
ということになります。そこで、この入力データを並べ替えて形を変えるところを、Im2colとして別のクラスや関数で定義してしまえば、
Im2col⇒affine変換
ということになります。
PrimitiveIm2col
この基本通りに行うIm2colをPrimitiveIm2colとして実装しました。これを以下に示します。
class PrimitiveIm2col:
def __init__(self, C, Ih, Iw, Fh, Fw, Sh, Sw, Oh, Ow):
self.config = C, Ih, Iw, Fh, Fw, Sh, Sw, Oh, Ow
def __call__(self, img):
C, Ih, Iw, Fh, Fw, Sh, Sw, Oh, Ow = self.config
B = img.shape[0]
col = np.empty((B,Oh,Ow,C*Fh*Fw), dtype=Config.dtype)
for ih in range(0, Ih-Fh+1, Sh):
for iw in range(0, Iw-Fw+1, Sw):
xij = img[:,:,ih:ih+Fh, iw:iw+Fw]
col[:,ih//Sh,iw//Sw,:] = xij.reshape(B, -1)
return col.reshape(B*Oh*Ow, C*Fh*Fw)
ConvLayer
Im2colを外だししたらConvLayerはすっきりとします。
そしてこれはPrimitiveをやめて、
ConvLayer
という名前にします。以下にそのforwardメソッドを示します。
def forward(self, x):
if None in self.config:
self.fix_configuration(x.shape)
C, Ih, Iw, M, Fh, Fw, Sh, Sw, pad, Oh, Ow = self.config
if self.w is None:
self.init_parameter(C*Fh*Fw, M)
B = x.size // (C*Ih*Iw) # B = x.shape[0] = len(x)
# 画像調整 (C,Ih*Iw)にも対応 B C Ih上 Ih下 Iw左 Iw右 ゼロパディング
img_pad = np.pad(x.reshape(B,C,Ih,Iw), [(0,0), (0,0), (pad, pad), (pad, pad)], 'constant')
self.cols = PrimitiveIm2col(C, Ih+2*pad, Iw+2*pad, Fh, Fw, Sh, Sw, Oh, Ow)(img_pad)
# 出力の計算: 行列積、バイアスの加算、活性化関数
u = np.dot(self.cols, self.w) +self.b
u = u.reshape(B, Oh, Ow, M).transpose(0, 3, 1, 2)
y = self.activator.forward(u)
return y
すっきりと言いましたが、ほんとうにすっきりなのかどうかは、なんとも言えません。
しかし、やっている手順が、以下のように整理されます。
パディング⇒Im2col変換⇒affine変換⇒形状調整⇒活性化関数
そして、改めてこれを見ると、
affine変換⇒活性化関数
という普通のニューロン層の亜種であることが分かります。
すっきりするけど、わからなくなる。
ニューロン層の亜種なのだということはわかるのですが、ここまで来てしまうと、フィルター(カーネル)を動かしながら画像をなめて特徴抽出する、というのが「ほんまかいな?」になってしまいます。
そうなんです。「すっきりするけれど、わからなくなる」のです。
もっとわからなくなる。
PrimitiveIm2colクラスでは、わかりやすさを優先して、元のPrimitiveConvLayerの処理をそのまま引き継いでいます。
しかしながら、この__call__の中のループは、入力の画像の大きさに沿って回すので、ループ回数が多くなっています。
元のPrimitiveConvLayerの処理を一旦、忘れてしまえば、Im2col変換をどういう手順で行うのが良いかを考えることができます。
そして、結論から言うならば、画像に代えてフィルター(カーネル)に沿ってループを回すことで、ループ回数を大幅に減らすことができます。
これをPrimitiveを名前から外して、
Im2col
とし、以下に示します。
Im2col
class Im2col:
def __init__(self, C, Ih, Iw, Fh, Fw, Sh, Sw, Oh, Ow):
self.config = C, Ih, Iw, Fh, Fw, Sh, Sw, Oh, Ow
def __call__(self, img):
C, Ih, Iw, Fh, Fw, Sh, Sw, Oh, Ow = self.config
B = img.size // (C*Ih*Iw)
cols = np.empty((B, C, Fh, Fw, Oh, Ow), dtype=Config.dtype) # メモリ節約のためzerosでなくempty
for h in range(Fh):
h_lim = h + Sh*Oh
for w in range(Fw):
w_lim = w + Sw*Ow
cols[:, :, h, w, :, :] = img[:, :, h:h_lim:Sh, w:w_lim:Sw]
# 軸の入替と変形 B Oh Ow C Fh Fw
cols = cols.transpose(0, 4, 5, 1, 2, 3).reshape(B*Oh*Ow, C*Fh*Fw)
return cols
Im2colは、もちろん、先のPrimitiveIm2colと互換なのですが、ますます、元のPrimitiveConvLayerの処理とは隔たりが大きくなっています。もはや片鱗すら残っていないというのは言いすぎでしょうか?
「何をやってるんか、わからへんかった」
というのは当然です。
私自身も時間が経つと「なんやこれ?」となってしまいます。
おまけ
ややこしついでに、おまけです。PrimitiveIm2colもIm2colも同じですが、
少々ややこしいのは、コントラクタ__init__()で渡す、変換前後の大きさに関するパラメタです。
self.config = C, Ih, Iw, Fh, Fw, Sh, Sw, Oh, Ow
としていますが、Im2col変換にパディングの事情は持ち込みたくありません。
だから、ConvLayerからIm2colを呼ぶときにパディング後の大きさを渡すようにしなければなりません。
おまけのついで。
説明はまったくしませんでしたが、畳込みの逆伝播(backward)ではIm2colの逆の関係の変換をします。この変換をCol2imと呼びます。
そして畳込みの逆に、画像を拡張していくようなときに使われるConvTransposeと呼ばれる処理があり、その処理ではforwardメソッドでCol2imを使います。
この話は書くと長くなるので、今日はここまでにします。
ブログのご感想やAIフレームワーク「ufiesia」「pyaino」に関するお問い合わせは、
問い合わせフォームからお送りください。
- お問い合わせ内容によりましては、ご期待に添えない場合やご回答が出来ない事が有ります。