代碼倉庫: https://github.com/brandonlyg/cute-dl
(轉載請注明出處!)
目标
上個階段使用MLP模型在在MNIST資料集上實作了92%左右的準确率,達到了tensorflow同等模型的水準。這個階段要讓cute-dl架構支援最簡單的卷積神經網絡, 并在MNIST和CIFA10資料上驗證,具體來說要達到如下目标:
- 添加2D卷積層。
- 添加2D最大池化層。
- CNN模型在MNIST資料集上達到99%以上的準确率。
- CNN模型在CIFA10資料集上達到70%以上在準确率。
卷積層的設計和實作
卷積運算
卷積運算有兩個關鍵要素: 卷積核(過濾器), 卷積運算步長。如果卷積運算的目标是二維的那麼卷積核可以用矩陣表示,卷積運算步長可以用二維向量。例如用kernel_size=(3,3)表示卷積核的尺寸,strides=(1,1)表示卷積運算的步長, 假如卷積核是這樣的:
[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-FyIuVwhj-1589796084050)(https://img2020.cnblogs.com/blog/895062/202005/895062-20200518175425078-2061957728.png)]
可以把它看成 R 3 X 3 R^{3 X 3} R3X3矩陣。在步長strides=(1,1)的情況下卷積運算如下所示:
其中
128 ∗ 0 + 97 ∗ 1 + 53 ∗ 0 + 35 ∗ 1 + 22 ∗ 0 + 25 ∗ 1 + 37 ∗ 0 + 24 ∗ 1 + 28 ∗ 0 = 181 97 ∗ 0 + 53 ∗ 1 + 201 ∗ 0 + 22 ∗ 1 + 25 ∗ 0 + 200 ∗ 1 + 24 ∗ 0 + 28 ∗ 1 + 197 ∗ 0 = 303 . . . 28 ∗ 0 + 197 ∗ 1 + 182 ∗ 0 + 92 ∗ 1 + 195 ∗ 0 + 179 ∗ 1 + 100 ∗ 0 + 192 ∗ 1 + 177 ∗ 0 = 660 \begin{matrix} 128*0 + 97*1 + 53*0 + 35*1 + 22*0 + 25*1 + 37*0 + 24*1 + 28 * 0 = 181 \\ 97*0 + 53*1 + 201*0 + 22*1 + 25*0 + 200*1 + 24*0 + 28*1 + 197 * 0 = 303 \\ ... \\ \\ 28*0 + 197*1 + 182*0 + 92*1 + 195*0 + 179*1 + 100*0 + 192*1 + 177 * 0 = 660 \end{matrix} 128∗0+97∗1+53∗0+35∗1+22∗0+25∗1+37∗0+24∗1+28∗0=18197∗0+53∗1+201∗0+22∗1+25∗0+200∗1+24∗0+28∗1+197∗0=303...28∗0+197∗1+182∗0+92∗1+195∗0+179∗1+100∗0+192∗1+177∗0=660
(注意示意圖中最後次運算計算有誤應該是660)
這個卷積運算輸入的是一個高h=5, 寬w=5的特征圖, 卷積運算輸出的是一個高h_=3, 寬w_=3的特征圖。 如果已知w, h, kernel_size= ( h k , w k ) (h_k, w_k) (hk,wk), strides= ( h s , w s ) (h_s, w_s) (hs,ws), 那麼輸出特征圖的高和寬分别為:
h _ = h − h k h s + 1 w _ = w − w k w s + 1 h , w , h k , w k , h s , w s 都是正整數, , 1 < = h k < h , 1 < = w k < w , h _ , w _ 向 下 取 整 \begin{matrix} h\_ = \frac{h - h_k}{h_s} + 1 \\ w\_= \frac{w - w_k}{w_s} + 1 \\ \\ h, w, h_k, w_k, h_s, w_s \text{都是正整數,}, 1<=h_k<h, 1<=w_k<w, h\_, w\_向下取整 \end{matrix} h_=hsh−hk+1w_=wsw−wk+1h,w,hk,wk,hs,ws都是正整數,,1<=hk<h,1<=wk<w,h_,w_向下取整
由上面兩個等式可以得出: h_ <= h, w_ <= w。經過卷積運算後特征圖會變小。
卷積層設計
卷積層的設計主要考慮以下幾個問題:
- 輸入層的圖像資料可能有多個(顔色)通道, 卷積層要能夠處理多通道的輸入。
- 當疊加多個卷積層時,一般情況下特征圖會逐層變小, 可以疊加的層數很少, 進而限制了模型的表達能力。卷積層要能夠通過填充改變輸入特征圖的大小, 控制輸出特征圖的大小。
- 如果用循環實作卷積運算, 會導緻性能很低, 要把卷積運算轉換成矩陣運算。
- 卷積層輸出的特征圖最後會輸入到全連接配接層中, 而全連接配接層值隻支援矩陣輸入,是以需要一個過渡層把特征圖展平成矩陣。
初始化參數
卷積層輸入是特征圖,它的形狀為(m,c,h,w), 其中m是批次大小,c是通道數,h,w分别為特征圖的高、寬。輸出也是特征圖,形狀為(m,c_,h_,w_)。前面已經給出了h_和h, w_和w的關系,已知h,w情況下确定h_,w_還需要給出 h k , w k , h s , w s h_k,w_k, h_s, w_s hk,wk,hs,ws, c_是獨立的量,可以根據需要設定。是以卷積層初始化時需要給出的層參數有:卷積核大小kernel_size= ( h k , w k ) (h_k, w_k) (hk,wk), 卷積運算步長strides= ( h s , w s ) (h_s, w_s) (hs,ws), 輸出通道數c_。 另外還需要padding參數指定填充方式來控制輸出特征圖的大小。
填充
一般情況下, 特征圖經過卷積層時會縮小,當h=h_時, 則有:
h k = h ( 1 − h s ) + h s h_k = h(1-h_s) + hs hk=h(1−hs)+hs
其中 0 < h s h_s hs < h, 隻有當 h s h_s hs=1時這個等式才有意義, h k = 1 h_k=1 hk=1。同理可以得到當w=w_時, w k = w s = 1 w_k=w_s=1 wk=ws=1。是以在沒有填充的情況下如果要得到大小不變的輸出,必須把卷積核設定成kernel_size=(1,1), 步長設定成strides=(1,1), 這限制了卷積層的表達能力。
為了能夠比較自由地設定kernel_size和strides, 我們把特征圖輸入輸出大小關系略作調整:
h _ = h + 2 ∗ h p − h k h s + 1 w _ = w + 2 ∗ w p − w k w s + 1 \begin{matrix} h\_ = \frac{h + 2*h_p - h_k}{h_s} + 1 \\ w\_= \frac{w + 2*w_p - w_k}{w_s} + 1 \\ \\ \end{matrix} h_=hsh+2∗hp−hk+1w_=wsw+2∗wp−wk+1
其中 h p , w p h_p, w_p hp,wp分别是在高度和寬度上的填充。在高度上,需要在頂部和底部分别填充 h p h_p hp的高度。在寬度上也是一樣。
以高度方向的填充為例:
h p = h ( h s − 1 ) + h k − h s 2 h_p = \frac{h(h_s-1) + h_k - h_s}{2} hp=2h(hs−1)+hk−hs
如果 h s = 1 h_s=1 hs=1, h k h_k hk選擇[1, h]之内的任意一個奇數都等讓上式有意義。當 h s > 1 h_s > 1 hs>1時, h k h_k hk會有更多的選擇。然而在工程上,太多的選擇并不一定是好事。太多的選擇可能意味着有很多方法可以處理目标問題,也有肯能意味着很難找到有效的方法。
前面讨論了通過填充實作h_=h, w_=w的情況。 填充也可以實作h_>h, w_>h, 這種情況沒有多大意義,這裡不予考慮。是以padding隻支援兩種不同的參數: 'valid’不填充; 'same’填充,使輸入輸出特征圖同樣大。
把卷積運算轉換成矩陣運算
一個輸入輸出别為 ( m , c , h , w ) (m, c, h, w) (m,c,h,w), ( m , c _ , h _ , w _ ) (m, c\_, h\_, w\_) (m,c_,h_,w_)的卷積層, 可以把權重參數W的形狀設計成 ( c ∗ h k ∗ w k , c _ ) (c*h_k*w_k, c\_) (c∗hk∗wk,c_), 輸入特征圖轉換成卷積矩陣F, 形狀為 ( m ∗ w _ ∗ h _ , c ∗ h k ∗ w k ) (m*w\_*h\_, c*h_k*w_k) (m∗w_∗h_,c∗hk∗wk), W, F進行矩陣運算,轉換成輸出特征圖的步驟如下:
- W, F進行矩陣運算得到形狀為 ( m ∗ w _ ∗ h _ , c _ ) (m*w\_*h\_, c\_) (m∗w_∗h_,c_)矩陣。
- 轉換形狀 ( m ∗ w _ ∗ h _ , c _ ) (m*w\_*h\_, c\_) (m∗w_∗h_,c_)-> ( m , h _ , w _ , c _ ) (m, h\_, w\_, c\_) (m,h_,w_,c_)。
- 移動通道次元 ( m , h _ , w _ , c _ ) (m, h\_, w\_, c\_) (m,h_,w_,c_)-> ( m , c _ , h _ , w _ , ) (m, c\_, h\_, w\_,) (m,c_,h_,w_,)。
卷積層實作
卷積層代碼位于cutedl/cnn_layers.py中, 類名為Conv2D, 它支援2維特征圖。
初始化
'''
channels 輸出通道數 int
kernel_size 卷積核形狀 (kh, kw)
strids 卷積運算步長(sh, sw)
padding 填充方式 'valid': 步填充. 'same': 使輸出特征圖和輸入特征圖形狀相同
inshape 輸入形狀 (c, h, w)
c 輸入通道數
h 特征圖高度
w 特征圖寬度
kernel_initialier 卷積核初始化器
uniform 均勻分布
normal 正态分布
bias_initialier 偏移量初始化器
uniform 均勻分布
normal 正态分布
zeros 0
'''
def __init__(self, channels, kernel_size, strides=(1,1),
padding='same',
inshape=None,
activation='relu',
kernel_initializer='uniform',
bias_initializer='zeros'):
#pdb.set_trace()
self.__ks = kernel_size
self.__st = strides
self.__pad = (0, 0)
self.__padding = padding
#參數
self.__W = self.weight_initializers[kernel_initializer]
self.__b = self.bias_initializers[bias_initializer]
#輸入輸出形狀
self.__inshape = (-1, -1, -1)
self.__outshape = None
#輸出形狀
outshape = self.check_shape(channels)
if outshape is None or type(channels) != type(1):
raise Exception("invalid channels: "+str(channels))
self.__outshape = outshape
#輸入形狀
inshape = self.check_shape(inshape)
if self.valid_shape(inshape):
self.__inshape = self.check_shape(inshape)
if self.__inshape is None or len(self.__inshape) != 3:
raise Exception("invalid inshape: "+str(inshape))
outshape, self.__pad = compute_2D_outshape(self.__inshape, self.__ks, self.__st, self.__padding)
self.__outshape = self.__outshape + outshape
super().__init__(activation)
self.__in_batch_shape = None
self.__in_batch = None
如目前層是輸入層, 需要inshape參數,在初始類初始化時會調用compute_2D_outshape方法計算輸出形狀,如果目前層不是輸入層,會在set_prev方法中計算輸出形狀。下面是compute_2D_outshape函數的實作:
'''
計算2D卷積層的輸輸出和填充
'''
def compute_2D_outshape(inshape, kernel_size, strides, padding):
#pdb.set_trace()
_, h, w = inshape
kh, kw = kernel_size
sh, sw = strides
h_ = -1
w_ = -1
pad = (0, 0)
if 'same' == padding:
#填充, 使用輸入輸出形狀一緻
_, h_, w_ = inshape
pad = (((h_-1)*sh - h + kh )//2, ((w_-1)*sw - w + kw)//2)
elif 'valid' == padding:
#不填充
h_ = (h - kh)//sh + 1
w_ = (w - kw)//sw + 1
else:
raise Exception("invalid padding: "+padding)
#pdb.set_trace()
outshape = (h_, w_)
return outshape, pad
這個函數除了傳回輸出形狀還傳回填充值,這是因為,特征圖和矩陣之間進行轉換時需要知道填充的大小。
卷積運算
卷積運算會在向前傳播是執行,代碼如下:
'''
向前傳播
in_batch: 一批輸入資料
training: 是否正在訓練
'''
def forward(self, in_batch, training=False):
#pdb.set_trace()
W = self.__W.value
b = self.__b.value
self.__in_batch_shape = in_batch.shape
#把輸入特征圖展開成卷積運算的矩陣矩陣(m*h_*w_, c*kh*kw)
in_batch = img2D_mat(in_batch, self.__ks, self.__pad, self.__st)
#計算輸出值(m*h_*w_, c_) = (m*h_*w_, c*kh*kw) @ (c*kh*kw, c_) + (c_,)
out = in_batch @ W + b
#把(m*h_*w_, c_) 轉換成(m, h_, w_, c_)
c_, h_, w_ = self.__outshape
out = out.reshape((-1, h_, w_, c_))
#把輸出值還原成(m, c_, h_, w_)
out = np.moveaxis(out, -1, 1)
self.__in_batch = in_batch
return self.activation(out)
其中img2D_mat函數把輸入特征圖轉換成用于卷積運算的矩陣,這個函數的實作如下:
'''
把2D特征圖轉換成友善卷積運算的矩陣, 形狀(m*h_*w_, c*kh*kw)
img 特征圖 shape=(m,c,h,w)
kernel_size 核形狀 shape=(kh, kw)
pad 填充大小 shape=(ph, pw)
strides 步長 shape=(sh, sw)
'''
def img2D_mat(img, kernel_size, pad, strides):
#pdb.set_trace()
kh, kw = kernel_size
ph, pw = pad
sh, sw = strides
#pdb.set_trace()
m, c, h, w = img.shape
kh, kw = kernel_size
#得到填充的圖
pdshape = (m, c) + (h + 2*ph, w + 2*pw)
#得到輸出大小
h_ = (pdshape[2] - kh)//sh + 1
w_ = (pdshape[3] - kw)//sw + 1
#填充
padded = np.zeros(pdshape)
padded[:, :, ph:(ph+h), pw:(pw+w)] = img
#轉換成卷積矩陣(m, h_, w_, c, kh, kw)
#pdb.set_trace()
out = np.zeros((m, h_, w_, c, kh, kw))
for i in range(h_):
for j in range(w_):
#(m, c, kh, kw)
cov = padded[:, :, i*sh:i*sh+kh, j*sw:j*sw+kw]
out[:, i, j] = cov
#轉換成(m*h_*w_, c*kh*kw)
out = out.reshape((-1, c*kh*kw))
return out
反向傳播
方向方向傳播沒什麼特别的地方,主要把梯度矩陣還原到特征圖上, 代碼如下。
'''
矩陣形狀的梯度轉換成2D特征圖梯度
mat 矩陣梯度 shape=(m*h_*w_, c*kh*kw)
特征圖形狀 imgshape=(m, c, h, w)
'''
def matgrad_img2D(mat, imgshape, kernel_size, pad, strides):
#pdb.set_trace()
m, c, h, w = imgshape
kh, kw = kernel_size
sh, sw = strides
ph, pw = pad
#得到填充形狀
pdshape = (m, c) + (h + 2*ph, w + 2 * pw)
#得到輸出大小
h_ = (pdshape[2] - kh)//sh + 1
w_ = (pdshape[3] - kw)//sw + 1
#轉換(m*h_*w_, c*kh*kw)->(m, h_, w_, c, kh, kw)
mat = mat.reshape(m, h_, w_, c, kh, kw)
#還原成填充後的特征圖
padded = np.zeros(pdshape)
for i in range(h_):
for j in range(w_):
#(m, c, kh, kw)
padded[:, :, i*sh:i*sh+kh, j*sw:j*sw+kw] += mat[:, i, j]
#pdb.set_trace()
#得到原圖(m,c,h,w)
out = padded[:, :, ph:ph+h, pw:pw+w]
return out
最大池化層的設計和實作
最大池化運算
最大池化計算和卷積運算算的過程幾乎一樣,隻有一點不同,卷積運算是把一個卷積核矩形區域的元素、權重參數按元素相乘後取和,池化層沒有權重參數,它的運算結果是取池矩形區域内的最大元素值。下面是池化運算涉及到的概念:
- pool_size: 池大小, 形如 ( h p , w p ) (h_p, w_p) (hp,wp), 其中 h p , w p h_p, w_p hp,wp是池的高度和寬度。pool_size含義和卷積層的kernel_size類似.
- strides: 步長。和卷積層一樣。
- padding: 填充方式,和卷積層一樣。
假設最大池化層的參數為: pool_size=(2,2), strides=(1,1), padding=‘valid’.
輸入資料為:
池化運算之後的輸入為:
[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-mp4QiSoY-1589796084065)(https://img2020.cnblogs.com/blog/895062/202005/895062-20200518175536428-1325958594.jpg)]
最大池化層實作
最大池化層的代碼在cutedl/cnn_layers.py中,類名: MaxPool2D.
相比于Conv2D, MaxPool2D要簡單許多,其代碼主要集中在forward和backward方法中。
forward實作:
def forward(self, in_batch, training=False):
m, c, h, w = in_batch.shape
_, h_, w_ = self.outshape
kh, kw = self.__ks
#把特征圖轉換成矩陣(m, c, h, w)->(m*h_*w_, c*kh*kw)
in_batch = img2D_mat(in_batch, self.__ks, self.__pad, self.__st)
#轉換形狀(m*w_*h_, c*kh*kw)->(m*h_*w_*c,kh*kw)
in_batch = in_batch.reshape((m*h_*w_*c, kh*kw))
#得到最大最索引
idx = in_batch.argmax(axis=1).reshape(-1, 1)
#轉成in_batch相同的形狀
idx = idx @ np.ones((1, in_batch.shape[1]))
temp = np.ones((in_batch.shape[0], 1)) @ np.arange(in_batch.shape[1]).reshape(1, -1)
#得到boolean的标記
self.__mark = idx == temp
#得到最大值
max = in_batch[self.__mark]
max = max.reshape((m, h_, w_, c))
max = np.moveaxis(max, -1, 1)
return max
這個方法的關鍵是得到最大值索引__mark, 有了它,在反向傳播的時候就能知道梯度值和輸入元素的對應關系。
驗證
卷積層驗證代碼位于examples/cnn目錄下,mnis_recognize.py是手寫數字識别模型,cifar10_fit.py是圖檔分類模型,下面是兩個模型的訓練報告。
cifar10資料集下載下傳連結: https://pan.baidu.com/s/1FIBWvJ446ta7CI5_RHdeOw 密碼: mhni
mnist資料集上的分類模型
模型定義:
model = Model([
cnn.Conv2D(32, (5,5), inshape=inshape),
cnn.MaxPool2D((2,2), strides=(2,2)),
cnn.Conv2D(64, (5,5)),
cnn.MaxPool2D((2,2), strides=(2,2)),
nn.Flatten(),
nn.Dense(1024),
nn.Dropout(0.5),
nn.Dense(10)
])
訓練報告:
經過2.6小時的訓練,模型有了99.2%的準确率,達到預期目标。
cifar10資料集上的分類模型
模型定義:
model = Model([
cnn.Conv2D(32, (3,3), inshape=inshape),
cnn.MaxPool2D((2,2), strides=(2,2)),
cnn.Conv2D(64, (3,3)),
cnn.MaxPool2D((2,2), strides=(2,2)),
cnn.Conv2D(64, (3, 3)),
nn.Flatten(),
nn.Dense(64),
nn.Dropout(0.5),
nn.Dense(10)
])
訓練報告:
經過9小時的訓練,模型有了72.2%的準确率,達到預期目标。