zoukankan      html  css  js  c++  java
  • 自己动手实现深度学习框架-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)的情况下卷积运算如下所示:

            其中

    [egin{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)), 那么输出特征图的高和宽分别为:

    [ egin{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 ext{都是正整数,}, 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, 我们把特征图输入输出大小关系略作调整:

    [ egin{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%的准确率,达到预期目标。

  • 相关阅读:
    css grid 随笔
    网页“console”输出图文信息
    2017
    自适应css 框架 PURE
    获取去除参数url地址
    微信分享
    video 播放
    手机端 默认字体
    video 手机全屏自动播放
    jquery 获取元素背景图片backgroungImage的url
  • 原文地址:https://www.cnblogs.com/brandonli/p/12912135.html
Copyright © 2011-2022 走看看