自己動手實現深度學習框架-6 卷積層和池化層


代碼倉庫: https://github.com/brandonlyg/cute-dl
(轉載請注明出處!)

目標

        上個階段使用MLP模型在在MNIST數據集上實現了92%左右的准確率,達到了tensorflow同等模型的水平。這個階段要讓cute-dl框架支持最簡單的卷積神經網絡, 並在MNIST和CIFA10數據上驗證,具體來說要達到如下目標:

  1. 添加2D卷積層。
  2. 添加2D最大池化層。
  3. CNN模型在MNIST數據集上達到99%以上的准確率。
  4. CNN模型在CIFA10數據集上達到70%以上在准確率。

卷積層的設計和實現

卷積運算

        卷積運算有兩個關鍵要素: 卷積核(過濾器), 卷積運算步長。如果卷積運算的目標是二維的那么卷積核可以用矩陣表示,卷積運算步長可以用二維向量。例如用kernel_size=(3,3)表示卷積核的尺寸,strides=(1,1)表示卷積運算的步長, 假如卷積核是這樣的:

        可以把它看成\(R^{3 X 3}\)矩陣。在步長strides=(1,1)的情況下卷積運算如下所示:

        其中

\[\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} \]

       (注意示意圖中最后次運算計算有誤應該是660)
        這個卷積運算輸入的是一個高h=5, 寬w=5的特征圖, 卷積運算輸出的是一個高h_=3, 寬w_=3的特征圖。 如果已知w, h, kernel_size=\((h_k, w_k)\), strides=\((h_s, w_s)\), 那么輸出特征圖的高和寬分別為:

\[ \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_ <= h, w_ <= w。經過卷積運算后特征圖會變小。

卷積層設計

        卷積層的設計主要考慮以下幾個問題:

  1. 輸入層的圖像數據可能有多個(顏色)通道, 卷積層要能夠處理多通道的輸入。
  2. 當疊加多個卷積層時,一般情況下特征圖會逐層變小, 可以疊加的層數很少, 從而限制了模型的表達能力。卷積層要能夠通過填充改變輸入特征圖的大小, 控制輸出特征圖的大小。
  3. 如果用循環實現卷積運算, 會導致性能很低, 要把卷積運算轉換成矩陣運算。
  4. 卷積層輸出的特征圖最后會輸入到全連接層中, 而全連接層值只支持矩陣輸入,因此需要一個過渡層把特征圖展平成矩陣。

初始化參數

        卷積層輸入是特征圖,它的形狀為(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\), c_是獨立的量,可以根據需要設置。因此卷積層初始化時需要給出的層參數有:卷積核大小kernel_size=\((h_k, w_k)\), 卷積運算步長strides=\((h_s, w_s)\), 輸出通道數c_。 另外還需要padding參數指定填充方式來控制輸出特征圖的大小。

填充

        一般情況下, 特征圖經過卷積層時會縮小,當h=h_時, 則有:

\[h_k = h(1-h_s) + hs \]

        其中 0 <\(h_s\) < h, 只有當\(h_s\)=1時這個等式才有意義, \(h_k=1\)。同理可以得到當w=w_時, \(w_k=w_s=1\)。因此在沒有填充的情況下如果要得到大小不變的輸出,必須把卷積核設置成kernel_size=(1,1), 步長設置成strides=(1,1), 這限制了卷積層的表達能力。
        為了能夠比較自由地設置kernel_size和strides, 我們把特征圖輸入輸出大小關系略作調整:

\[ \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_p, w_p\)分別是在高度和寬度上的填充。在高度上,需要在頂部和底部分別填充\(h_p\)的高度。在寬度上也是一樣。
        以高度方向的填充為例:

\[h_p = \frac{h(h_s-1) + h_k - h_s}{2} \]

        如果\(h_s=1\), \(h_k\)選擇[1, h]之內的任意一個奇數都等讓上式有意義。當\(h_s > 1\)時, \(h_k\)會有更多的選擇。然而在工程上,太多的選擇並不一定是好事。太多的選擇可能意味着有很多方法可以處理目標問題,也有肯能意味着很難找到有效的方法。
        前面討論了通過填充實現h_=h, w_=w的情況。 填充也可以實現h_>h, w_>h, 這種情況沒有多大意義,這里不予考慮。因此padding只支持兩種不同的參數: 'valid'不填充; 'same'填充,使輸入輸出特征圖同樣大。

把卷積運算轉換成矩陣運算

        一個輸入輸出別為\((m, c, h, w)\), \((m, c\_, h\_, w\_)\)的卷積層, 可以把權重參數W的形狀設計成\((c*h_k*w_k, c\_)\), 輸入特征圖轉換成卷積矩陣F, 形狀為\((m*w\_*h\_, c*h_k*w_k)\), W, F進行矩陣運算,轉換成輸出特征圖的步驟如下:

  • W, F進行矩陣運算得到形狀為\((m*w\_*h\_, c\_)\)矩陣。
  • 轉換形狀\((m*w\_*h\_, c\_)\)->\((m, h\_, w\_, c\_)\)
  • 移動通道維度\((m, h\_, w\_, c\_)\)->\((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

最大池化層的設計和實現

最大池化運算

        最大池化計算和卷積運算算的過程幾乎一樣,只有一點不同,卷積運算是把一個卷積核矩形區域的元素、權重參數按元素相乘后取和,池化層沒有權重參數,它的運算結果是取池矩形區域內的最大元素值。下面是池化運算涉及到的概念:

  1. pool_size: 池大小, 形如\((h_p, w_p)\), 其中\(h_p, w_p\)是池的高度和寬度。pool_size含義和卷積層的kernel_size類似.
  2. strides: 步長。和卷積層一樣。
  3. padding: 填充方式,和卷積層一樣。

        假設最大池化層的參數為: pool_size=(2,2), strides=(1,1), padding='valid'.

        輸入數據為:

        池化運算之后的輸入為:

最大池化層實現

        最大池化層的代碼在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%的准確率,達到預期目標。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM