【深度学习系列】卷积神经网络详解(二)——自己手写一个卷积神经网络

上篇文章中我们讲解了卷积神经网络的基本原理,包括几个基本层的定义、运算规则等。本文主要写卷积神经网络如何进行一次完整的训练,包括前向传播和反向传播,并自己手写一个卷积神经网络。如果不了解基本原理的,可以先看看上篇文章:【深度学习系列】卷积神经网络CNN原理详解(一)——基本原理 卷积神经网络的前向传播 首先我…

排版迁移勘误(2026-09-07):修正了原文的输入梯度索引、权重梯度中的输入因子、3×3 输出的共享偏置九项求和,以及一处重复的输出下标。区分相邻两层的误差项,补充重叠最大池化的梯度累加说明;公式转义与矩阵列数同时修复。下文先用无偏置手算,与图中 b=1 的数值例子分开。

上篇文章中我们讲解了卷积神经网络的基本原理,包括几个基本层的定义、运算规则等。本文主要写卷积神经网络如何进行一次完整的训练,包括前向传播和反向传播,并自己手写一个卷积神经网络。如果不了解基本原理的,可以先看看上篇文章:【深度学习系列】卷积神经网络CNN原理详解(一)——基本原理


卷积神经网络的前向传播

  首先我们来看一个最简单的卷积神经网络:

卷积网络七个前向传播阶段
卷积网络七个前向传播阶段 打开全图 ↗

1.输入层---->卷积层

  以上一节的例子为例,输入是一个4*4 的image,经过两个2*2的卷积核进行卷积运算后,变成两个3*3的feature_map

两个 2 × 2 卷积核的计算过程
勘误:原图第二张输出误复制第一张,且漏加 b=1;现按图示输入、卷积核和偏置复算激活前输出。下文手算忽略偏置,o11=1,属于另一简化情形。 打开全图 ↗

   以卷积核filter1为例(stride = 1 ):

[i11i12i13i14i21i22i23i24i31i32i33i34i41i42i43i44]input filter [h11h12h21h22] [o11o12o13o21o22o23o31o32o33]output\underbrace{\begin{bmatrix}i_{11}&i_{12}&i_{13}&i_{14}\\i_{21}&i_{22}&i_{23}&i_{24}\\i_{31}&i_{32}&i_{33}&i_{34}\\i_{41}&i_{42}&i_{43}&i_{44}\end{bmatrix}}_{\text{input}} \quad\xrightarrow{\ \text{filter }\begin{bmatrix}h_{11}&h_{12}\\h_{21}&h_{22}\end{bmatrix}\ }\quad \underbrace{\begin{bmatrix}o_{11}&o_{12}&o_{13}\\o_{21}&o_{22}&o_{23}\\o_{31}&o_{32}&o_{33}\end{bmatrix}}_{\text{output}}

  计算第一个卷积层神经元o11的输入: 

 neto11=conv(input,filter)=i11×h11+i12×h12+i21×h21+i22×h22=1×1+0×(1)+1×1+1×(1)=1\begin{aligned} \ net_{o_{11}}&= conv (input,filter)\\ &= i_{11} \times h_{11} + i_{12} \times h_{12} +i_{21} \times h_{21} + i_{22} \times h_{22}\\ &=1 \times 1 + 0 \times (-1) +1 \times 1 + 1 \times (-1)=1 \end{aligned}

   神经元o11的输出:(此处使用Relu激活函数)

outo11=activators(neto11)=max(0,neto11)=1\begin{aligned} out_{o_{11}} &= activators(net_{o_{11}}) \\ &=max(0,net_{o_{11}}) = 1 \end{aligned}

  其他神经元计算方式相同

2.卷积层---->池化层

[o11o12o13o21o22o23o31o32o33]conv-output max pooling [m11m12m21m22]max pooling\underbrace{\begin{bmatrix}o_{11}&o_{12}&o_{13}\\o_{21}&o_{22}&o_{23}\\o_{31}&o_{32}&o_{33}\end{bmatrix}}_{\text{conv-output}} \quad\xrightarrow{\ \text{max pooling}\ }\quad \underbrace{\begin{bmatrix}m_{11}&m_{12}\\m_{21}&m_{22}\end{bmatrix}}_{\text{max pooling}}

  计算池化层m11 的输入(取窗口为 2 * 2),池化层没有激活函数  

netm11=max(o11,o12,o21,o22)=1outm11=netm11=1\begin{aligned} net_{m_{11}} &= max(o_{11},o_{12},o_{21},o_{22}) = 1\\ &out_{m_{11}} = net_{m_{11}} = 1 \end{aligned}

3.池化层---->全连接层

  池化层的输出到flatten层把所有元素“拍平”,然后到全连接层。

4.全连接层---->输出层

  全连接层到输出层就是正常的神经元与神经元之间的邻接相连,通过softmax函数计算后输出到output,得到不同类别的概率值,输出概率值最大的即为该图片的类别。


卷积神经网络的反向传播

  传统的神经网络是全连接形式的,如果进行反向传播,只需要由下一层对前一层不断的求偏导,即求链式偏导就可以求出每一层的误差敏感项,然后求出权重和偏置项的梯度,即可更新权重。而卷积神经网络有两个特殊的层:卷积层和池化层。最大池化直接输出窗口中的最大值,不额外经过激活函数。当最大值唯一时,对被选中的输入求偏导为 1,对其他输入求偏导为 0;有并列最大值时,需要约定梯度的分配方式。池化层相当于对上层图片做了一个压缩,这个反向求误差敏感项时与传统的反向传播方式不同。从卷积后的feature_map反向传播到前一层时,由于前向传播时是通过卷积核做卷积运算得到的feature_map,所以反向传播与传统的也不一样,需要更新卷积核的参数。下面我们介绍一下池化层和卷积层是如何做反向传播的。

  在介绍之前,首先回顾一下传统的反向传播方法:

  1.通过前向传播计算每一层的输入值neti,jnet_{i,j}  (如卷积后的feature_map的第一个神经元的输入:neti11net_{i_{11}})

  2.反向传播计算每个神经元的误差项δi,j\delta_{i,j} ,δi,j=Eneti,j\delta_{i,j} = \frac{\partial E}{\partial net_{i,j}},其中E为损失函数计算得到的总体误差,可以用平方差,交叉熵等表示。

  1. 计算每个连接权重的梯度。若 wj,kw_{j,k} 将上一层输出 aka_k 连接到本层神经元 jj,且 netj=kwj,kak+bjnet_j=\sum_k w_{j,k}a_k+b_j,则 ηj,k=E/wj,k=δjak\eta_{j,k}=\partial E/\partial w_{j,k}=\delta_j a_k,其中 δj=E/netj\delta_j=\partial E/\partial net_j

  2. 更新权重 wj,k=wj,kληj,kw_{j,k}=w_{j,k}-\lambda\eta_{j,k},其中 λ\lambda 为学习率。

卷积层的反向传播

本节将上一层输出记为 ip,q=outip,qi_{p,q}=out_{i_{p,q}},将卷积输出的预激活误差项记为 δr,s=E/netor,s\delta_{r,s}=\partial E/\partial net_{o_{r,s}}。上一层的预激活误差项另记为 δp,qprev\delta^{\mathrm{prev}}_{p,q}。索引从 1 开始,超出 3×3 输出范围的 δr,s\delta_{r,s} 取 0。以下先省略偏置;求偏置梯度时恢复各输出共享的 bb

  由前向传播可得:

  每一个神经元的值都是上一个神经元的输入作为这个神经元的输入,经过激活函数激活之后输出,作为下一个神经元的输入,在这里我用i11i_{11}表示前一层,o11o_{11}表示i11i_{11}的下一层。那么neti11net_{i_{11}}就是i11这个神经元的输入,outi11out_{i_{11}}就是i11这个神经元的输出,同理,neto11net_{o_{11}}就是o11这个神经元的输入,outo11out_{o_{11}}就是o11o_{11}这个神经元的输出,上一层的输出作为下一层的输入特征,但下一层的净输入是感受野内多个输入的加权和。这里为简化记号,把 outi11out_{i_{11}} 记为 i11i_{11},并不令它等于 neto11net_{o_{11}}

 i11=outi11=activators(neti11) neto11=conv(input,filter)=i11×h11+i12×h12+i21×h21+i22×h22outo11=activators(neto11)=max(0,neto11)\begin{aligned} \ i_{11} &=out_{i_{11}} \\ &= activators(net_{i_{11}})\\ \ net_{o_{11}}&= conv (input,filter)\\ &= i_{11} \times h_{11} + i_{12} \times h_{12} +i_{21} \times h_{21} + i_{22} \times h_{22}\\ out_{o_{11}} &= activators(net_{o_{11}}) \\ &=max(0,net_{o_{11}}) \end{aligned}

  neti11net_{i_{11}}表示上一层的输入,outi11out_{i_{11}}表示上一层的输出

  首先计算卷积的上一层的第一个元素i11i_{11}的误差项 δ11prev\delta^{\mathrm{prev}}_{11}

δ11prev=Eneti11=Eouti11outi11neti11=Ei11i11neti11\delta^{\mathrm{prev}}_{11} = \frac{\partial E}{\partial net_{i_{11}}} =\frac{\partial E}{\partial out_{i_{11}}} \cdot \frac{\partial out_{i_{11}}}{\partial net_{i_{11}}} = \frac{\partial E}{\partial i_{11}} \cdot \frac{\partial i_{11}}{\partial net_{i_{11}}}

  先计算Ei11\frac{\partial E}{\partial i_{11}}

  此处我们并不清楚Ei11\frac{\partial E}{\partial i_{11}}怎么算,那可以先把input层通过卷积核做完卷积运算后的输出feature_map写出来:

neto11=i11×h11+i12×h12+i21×h21+i22×h22neto12=i12×h11+i13×h12+i22×h21+i23×h22neto13=i13×h11+i14×h12+i23×h21+i24×h22neto21=i21×h11+i22×h12+i31×h21+i32×h22neto22=i22×h11+i23×h12+i32×h21+i33×h22neto23=i23×h11+i24×h12+i33×h21+i34×h22neto31=i31×h11+i32×h12+i41×h21+i42×h22neto32=i32×h11+i33×h12+i42×h21+i43×h22neto33=i33×h11+i34×h12+i43×h21+i44×h22\begin{aligned} net_{o_{11}} = i_{11} \times h_{11} + i_{12} \times h_{12} +i_{21} \times h_{21} + i_{22} \times h_{22} \\ net_{o_{12}} = i_{12} \times h_{11} + i_{13} \times h_{12} +i_{22} \times h_{21} + i_{23} \times h_{22} \\ net_{o_{13}} = i_{13} \times h_{11} + i_{14} \times h_{12} +i_{23} \times h_{21} + i_{24} \times h_{22} \\ net_{o_{21}} = i_{21} \times h_{11} + i_{22} \times h_{12} +i_{31} \times h_{21} + i_{32} \times h_{22} \\ net_{o_{22}} = i_{22} \times h_{11} + i_{23} \times h_{12} +i_{32} \times h_{21} + i_{33} \times h_{22} \\ net_{o_{23}} = i_{23} \times h_{11} + i_{24} \times h_{12} +i_{33} \times h_{21} + i_{34} \times h_{22} \\ net_{o_{31}} = i_{31} \times h_{11} + i_{32} \times h_{12} +i_{41} \times h_{21} + i_{42} \times h_{22} \\ net_{o_{32}} = i_{32} \times h_{11} + i_{33} \times h_{12} +i_{42} \times h_{21} + i_{43} \times h_{22} \\ net_{o_{33}} = i_{33} \times h_{11} + i_{34} \times h_{12} +i_{43} \times h_{21} + i_{44} \times h_{22} \\ \end{aligned}

  然后依次对输入元素ii,ji_{i,j}求偏导

  i11i_{11}的偏导: 

Ei11=Eneto11neto11i11=δ11h11\begin{aligned} \frac{\partial E}{\partial i_{11}}&=\frac{\partial E}{\partial net_{o_{11}}} \cdot \frac{\partial net_{o_{11}}}{\partial i_{11}}\\ &=\delta_{11} \cdot h_{11} \end{aligned}

  i12i_{12}的偏导:

Ei12=Eneto11neto11i12+Eneto12neto12i12=δ11h12+δ12h11\begin{aligned} \frac{\partial E}{\partial i_{12}}&=\frac{\partial E}{\partial net_{o_{11}}} \cdot \frac{\partial net_{o_{11}}}{\partial i_{12}} +\frac{\partial E}{\partial net_{o_{12}}} \cdot \frac{\partial net_{o_{12}}}{\partial i_{12}}\\ &=\delta_{11} \cdot h_{12}+\delta_{12} \cdot h_{11} \end{aligned}

  i13i_{13}的偏导:

Ei13=Eneto12neto12i13+Eneto13neto13i13=δ12h12+δ13h11\begin{aligned} \frac{\partial E}{\partial i_{13}}&=\frac{\partial E}{\partial net_{o_{12}}} \cdot \frac{\partial net_{o_{12}}}{\partial i_{13}} +\frac{\partial E}{\partial net_{o_{13}}} \cdot \frac{\partial net_{o_{13}}}{\partial i_{13}}\\ &=\delta_{12} \cdot h_{12}+\delta_{13} \cdot h_{11} \end{aligned}

  i21i_{21}的偏导:

Ei21=Eneto11neto11i21+Eneto21neto21i21=δ11h21+δ21h11\begin{aligned} \frac{\partial E}{\partial i_{21}}&=\frac{\partial E}{\partial net_{o_{11}}} \cdot \frac{\partial net_{o_{11}}}{\partial i_{21}} +\frac{\partial E}{\partial net_{o_{21}}} \cdot \frac{\partial net_{o_{21}}}{\partial i_{21}}\\ &=\delta_{11} \cdot h_{21}+\delta_{21} \cdot h_{11} \end{aligned}

  i22i_{22}的偏导:

Ei22=Eneto11neto11i22+Eneto12neto12i22+Eneto21neto21i22+Eneto22neto22i22=δ11h22+δ12h21+δ21h12+δ22h11\begin{aligned} \frac{\partial E}{\partial i_{22}}&=\frac{\partial E}{\partial net_{o_{11}}} \cdot \frac{\partial net_{o_{11}}}{\partial i_{22}} +\frac{\partial E}{\partial net_{o_{12}}} \cdot \frac{\partial net_{o_{12}}}{\partial i_{22}}\\ &+\frac{\partial E}{\partial net_{o_{21}}} \cdot \frac{\partial net_{o_{21}}}{\partial i_{22}}+\frac{\partial E}{\partial net_{o_{22}}} \cdot \frac{\partial net_{o_{22}}}{\partial i_{22}}\\ &=\delta_{11} \cdot h_{22}+\delta_{12} \cdot h_{21}+\delta_{21} \cdot h_{12}+\delta_{22} \cdot h_{11} \end{aligned}

  观察一下上面几个式子的规律,归纳一下,可以得到如下表达式:

[000000δ11δ12δ1300δ21δ22δ2300δ31δ32δ33000000][h22h21h12h11]=[Ei11Ei12Ei13Ei14Ei21Ei22Ei23Ei24Ei31Ei32Ei33Ei34Ei41Ei42Ei43Ei44]{ \left[ \begin{matrix} 0& 0& 0& 0& 0 \\ 0& \delta_{11} & \delta_{12} & \delta_{13}&0\\ 0&\delta_{21} & \delta_{22} & \delta_{23} &0\\ 0&\delta_{31} & \delta_{32} & \delta_{33} &0\\ 0& 0& 0& 0& 0 \\ \end{matrix} \right ] \star \left[ \begin{matrix} h_{22}& h_{21} \\ h_{12}& h_{11} \\ \end{matrix} \right]}= \left[ \begin{matrix} \frac{\partial E}{\partial i_{11}}& \frac{\partial E}{\partial i_{12}}& \frac{\partial E}{\partial i_{13}}& \frac{\partial E}{\partial i_{14}} \\ \frac{\partial E}{\partial i_{21}}& \frac{\partial E}{\partial i_{22}}& \frac{\partial E}{\partial i_{23}}& \frac{\partial E}{\partial i_{24}} \\ \frac{\partial E}{\partial i_{31}}& \frac{\partial E}{\partial i_{32}}& \frac{\partial E}{\partial i_{33}}& \frac{\partial E}{\partial i_{34}} \\ \frac{\partial E}{\partial i_{41}}& \frac{\partial E}{\partial i_{42}}& \frac{\partial E}{\partial i_{43}}& \frac{\partial E}{\partial i_{44}} \\ \end{matrix} \right]

δ\delta 矩阵四周补一圈 0,再与旋转 180° 的卷积核做步长为 1 的有效滑动互相关,得到完整的 4×4 输入梯度矩阵。这里 \star 表示滑动互相关,不是矩阵乘法。于是:

Eip,q=a=12c=12ha,cδpa+1,qc+1,1p,q4.\frac{\partial E}{\partial i_{p,q}} =\sum_{a=1}^{2}\sum_{c=1}^{2} h_{a,c}\,\delta_{p-a+1,q-c+1}, \qquad 1\le p,q\le4.

  第一项求完后,我们来求第二项i11neti11\frac{\partial i_{11}}{\partial net_{i_{11}}}

δp,qprev=Enetip,q=Eip,qf(netip,q)=(a=12c=12ha,cδpa+1,qc+1)f(netip,q).\begin{aligned} \delta^{\mathrm{prev}}_{p,q} &=\frac{\partial E}{\partial net_{i_{p,q}}}\\ &=\frac{\partial E}{\partial i_{p,q}}\,f'(net_{i_{p,q}})\\ &=\left(\sum_{a=1}^{2}\sum_{c=1}^{2} h_{a,c}\,\delta_{p-a+1,q-c+1}\right)f'(net_{i_{p,q}}). \end{aligned}

  此时我们的误差敏感矩阵就求完了,得到误差敏感矩阵后,即可求权重的梯度。

  由于上面已经写出了卷积层的输入neto11net_{o_{11}}与权重hi,jh_{i,j}之间的表达式,所以可以直接求出:

Eh11=Eneto11neto11h11+...+Eneto33neto33h11=δ11i11++δ33i33\begin{aligned} \frac{\partial E}{\partial h_{11}}&=\frac{\partial E}{\partial net_{o_{11}}} \cdot \frac{\partial net_{o_{11}}}{\partial h_{11}}+...\\ &+\frac{\partial E}{\partial net_{o_{33}}} \cdot \frac{\partial net_{o_{33}}}{\partial h_{11}}\\ &=\delta_{11}\,i_{11}+\cdots+\delta_{33}\,i_{33} \end{aligned}

  推论出权重的梯度

Eha,c=r=13s=13δr,sir+a1,s+c1,1a,c2.\frac{\partial E}{\partial h_{a,c}} =\sum_{r=1}^{3}\sum_{s=1}^{3}\delta_{r,s}\,i_{r+a-1,s+c-1}, \qquad 1\le a,c\le2.

  偏置项的梯度

此处恢复前面省略的共享偏置,即 netor,s=a=12c=12ha,cir+a1,s+c1+bnet_{o_{r,s}}=\sum_{a=1}^{2}\sum_{c=1}^{2}h_{a,c}i_{r+a-1,s+c-1}+b。每个位置对偏置的偏导均为 1。

Eb=r=13s=13Enetor,snetor,sb=r=13s=13δr,s=δ11+δ12+δ13+δ21+δ22+δ23+δ31+δ32+δ33.\begin{aligned} \frac{\partial E}{\partial b} &=\sum_{r=1}^{3}\sum_{s=1}^{3} \frac{\partial E}{\partial net_{o_{r,s}}} \frac{\partial net_{o_{r,s}}}{\partial b}\\ &=\sum_{r=1}^{3}\sum_{s=1}^{3}\delta_{r,s}\\ &=\delta_{11}+\delta_{12}+\delta_{13} +\delta_{21}+\delta_{22}+\delta_{23} +\delta_{31}+\delta_{32}+\delta_{33}. \end{aligned}

  可以看出,偏置项的偏导等于这一层所有误差敏感项之和。得到了权重和偏置项的梯度后,就可以根据梯度下降法更新权重和偏置了。 

池化层的反向传播

    池化层的反向传播就比较好求了,看着下面的图,左边是上一层的输出,也就是卷积层的输出feature_map,右边是池化层的输入,还是先根据前向传播,把式子都写出来,方便计算:

[o11o12o13o21o22o23o31o32o33]conv-output max pooling [m11m12m21m22]max pooling\underbrace{\begin{bmatrix}o_{11}&o_{12}&o_{13}\\o_{21}&o_{22}&o_{23}\\o_{31}&o_{32}&o_{33}\end{bmatrix}}_{\text{conv-output}} \quad\xrightarrow{\ \text{max pooling}\ }\quad \underbrace{\begin{bmatrix}m_{11}&m_{12}\\m_{21}&m_{22}\end{bmatrix}}_{\text{max pooling}}

以下只计算 m11m_{11} 这个窗口对池化输入梯度的局部贡献,记为 g(11)g^{(11)}。假设窗口唯一最大值是 outo11out_{o_{11}},并记 δ11pool=E/netm11\delta^{\mathrm{pool}}_{11}=\partial E/\partial net_{m_{11}}

netm11=max(outo11,outo12,outo21,outo22),netm11outo11=1,netm11outo12=netm11outo21=netm11outo22=0,g11(11)=δ11poolnetm11outo11=δ11pool,g12(11)=g21(11)=g22(11)=0.\begin{aligned} net_{m_{11}}&=\max(out_{o_{11}},out_{o_{12}},out_{o_{21}},out_{o_{22}}),\\ \frac{\partial net_{m_{11}}}{\partial out_{o_{11}}}&=1,\\ \frac{\partial net_{m_{11}}}{\partial out_{o_{12}}} =\frac{\partial net_{m_{11}}}{\partial out_{o_{21}}} =\frac{\partial net_{m_{11}}}{\partial out_{o_{22}}}&=0,\\ g^{(11)}_{11}&=\delta^{\mathrm{pool}}_{11} \frac{\partial net_{m_{11}}}{\partial out_{o_{11}}} =\delta^{\mathrm{pool}}_{11},\\ g^{(11)}_{12}=g^{(11)}_{21}=g^{(11)}_{22}&=0. \end{aligned}

对所有池化窗口分别计算这样的贡献,并在重叠位置相加,得到 gp,q=E/outop,qg_{p,q}=\partial E/\partial out_{o_{p,q}}。再通过卷积层的激活函数,得到 δp,q=gp,qf(netop,q)\delta_{p,q}=g_{p,q}f'(net_{o_{p,q}})。最大池化本身没有可训练的权重或偏置,只负责将梯度传回前层。


手写一个卷积神经网络

  1.定义一个卷积层

   首先我们通过ConvLayer来实现一个卷积层,定义卷积层的超参数

class ConvLayer(object):
    '''
    参数含义:
    input_width:输入图片尺寸——宽度
    input_height:输入图片尺寸——长度
    channel_number:通道数,彩色为3,灰色为1
    filter_width:卷积核的宽
    filter_height:卷积核的长
    filter_number:卷积核数量
    zero_padding:补零长度
    stride:步长
    activator:激活函数
    learning_rate:学习率
    '''
    def __init__(self, input_width, input_height,
                 channel_number, filter_width,
                 filter_height, filter_number,
                 zero_padding, stride, activator,
                 learning_rate):
        self.input_width = input_width
        self.input_height = input_height
        self.channel_number = channel_number
        self.filter_width = filter_width
        self.filter_height = filter_height
        self.filter_number = filter_number
        self.zero_padding = zero_padding
        self.stride = stride
        self.output_width = \
            ConvLayer.calculate_output_size(
            self.input_width, filter_width, zero_padding,
            stride)
        self.output_height = \
            ConvLayer.calculate_output_size(
            self.input_height, filter_height, zero_padding,
            stride)
        self.output_array = np.zeros((self.filter_number,
            self.output_height, self.output_width))
        self.filters = []
        for i in range(filter_number):
            self.filters.append(Filter(filter_width,
                filter_height, self.channel_number))
        self.activator = activator
        self.learning_rate = learning_rate

  其中calculate_output_size用来计算通过卷积运算后输出的feature_map大小

@staticmethod
    def calculate_output_size(input_size,
            filter_size, zero_padding, stride):
        return (input_size - filter_size +
            2 * zero_padding) / stride + 1

  2.构造一个激活函数

  此处用的是RELU激活函数,因此我们在activators.py里定义,forward是前向计算,backforward是计算公式的导数:

class ReluActivator(object):
    def forward(self, weighted_input):
        #return weighted_input
        return max(0, weighted_input)

    def backward(self, output):
        return 1 if output > 0 else 0

  其他常见的激活函数我们也可以放到activators里,如sigmoid函数,我们可以做如下定义:

class SigmoidActivator(object):
    def forward(self, weighted_input):
        return 1.0 / (1.0 + np.exp(-weighted_input))
    #the partial of sigmoid
    def backward(self, output):
        return output * (1 - output)

  如果我们需要自动以其他的激活函数,都可以在activator.py定义一个类即可。

  3.定义一个类,保存卷积层的参数和梯度

class Filter(object):
    def __init__(self, width, height, depth):
        #初始权重
        self.weights = np.random.uniform(-1e-4, 1e-4,
            (depth, height, width))
        #初始偏置
        self.bias = 0
        self.weights_grad = np.zeros(
            self.weights.shape)
        self.bias_grad = 0

    def __repr__(self):
        return 'filter weights:\n%s\nbias:\n%s' % (
            repr(self.weights), repr(self.bias))

    def get_weights(self):
        return self.weights

    def get_bias(self):
        return self.bias

    def update(self, learning_rate):
        self.weights -= learning_rate * self.weights_grad
        self.bias -= learning_rate * self.bias_grad

  4.卷积层的前向传播

  1).获取卷积区域

# 获取卷积区域
def get_patch(input_array, i, j, filter_width,
              filter_height, stride):
    '''
    从输入数组中获取本次卷积的区域,
    自动适配输入为2D和3D的情况
    '''
    start_i = i * stride
    start_j = j * stride
    if input_array.ndim == 2:
        input_array_conv = input_array[
            start_i : start_i + filter_height,
            start_j : start_j + filter_width]
        print "input_array_conv:",input_array_conv
        return input_array_conv

    elif input_array.ndim == 3:
        input_array_conv = input_array[:,
            start_i : start_i + filter_height,
            start_j : start_j + filter_width]
        print "input_array_conv:",input_array_conv
        return input_array_conv

  2).进行卷积运算

def conv(input_array,
         kernel_array,
         output_array,
         stride, bias):
    '''
    计算卷积,自动适配输入为2D和3D的情况
    '''
    channel_number = input_array.ndim
    output_width = output_array.shape[1]
    output_height = output_array.shape[0]
    kernel_width = kernel_array.shape[-1]
    kernel_height = kernel_array.shape[-2]
    for i in range(output_height):
        for j in range(output_width):
            output_array[i][j] = (
                get_patch(input_array, i, j, kernel_width,
                    kernel_height, stride) * kernel_array
                ).sum() + bias

  3).增加zero_padding

#增加Zero padding
def padding(input_array, zp):
    '''
    为数组增加Zero padding,自动适配输入为2D和3D的情况
    '''
    if zp == 0:
        return input_array
    else:
        if input_array.ndim == 3:
            input_width = input_array.shape[2]
            input_height = input_array.shape[1]
            input_depth = input_array.shape[0]
            padded_array = np.zeros((
                input_depth,
                input_height + 2 * zp,
                input_width + 2 * zp))
            padded_array[:,
                zp : zp + input_height,
                zp : zp + input_width] = input_array
            return padded_array
        elif input_array.ndim == 2:
            input_width = input_array.shape[1]
            input_height = input_array.shape[0]
            padded_array = np.zeros((
                input_height + 2 * zp,
                input_width + 2 * zp))
            padded_array[zp : zp + input_height,
                zp : zp + input_width] = input_array
            return padded_array

  4).进行前向传播

def forward(self, input_array):
        '''
        计算卷积层的输出
        输出结果保存在self.output_array
        '''
        self.input_array = input_array
        self.padded_input_array = padding(input_array,
            self.zero_padding)
        for f in range(self.filter_number):
            filter = self.filters[f]
            conv(self.padded_input_array,
                filter.get_weights(), self.output_array[f],
                self.stride, filter.get_bias())
        element_wise_op(self.output_array,
                        self.activator.forward)

  其中element_wise_op函数是将每个组的元素对应相乘

# 对numpy数组进行element wise操作,将矩阵中的每个元素对应相乘
def element_wise_op(array, op):
    for i in np.nditer(array,
                       op_flags=['readwrite']):
        i[...] = op(i)

  5.卷积层的反向传播

  1).将误差传递到上一层

def bp_sensitivity_map(self, sensitivity_array,
                           activator):
        '''
        计算传递到上一层的sensitivity map
        sensitivity_array: 本层的sensitivity map
        activator: 上一层的激活函数
        '''
        # 处理卷积步长,对原始sensitivity map进行扩展
        expanded_array = self.expand_sensitivity_map(
            sensitivity_array)
        # full卷积,对sensitivitiy map进行zero padding
        # 虽然原始输入的zero padding单元也会获得残差
        # 但这个残差不需要继续向上传递,因此就不计算了
        expanded_width = expanded_array.shape[2]
        zp = (self.input_width +
              self.filter_width - 1 - expanded_width) / 2
        padded_array = padding(expanded_array, zp)
        # 初始化delta_array,用于保存传递到上一层的
        # sensitivity map
        self.delta_array = self.create_delta_array()
        # 对于具有多个filter的卷积层来说,最终传递到上一层的
        # sensitivity map相当于所有的filter的
        # sensitivity map之和
        for f in range(self.filter_number):
            filter = self.filters[f]
            # 将filter权重翻转180度
            flipped_weights = np.array(map(
                lambda i: np.rot90(i, 2),
                filter.get_weights()))
            # 计算与一个filter对应的delta_array
            delta_array = self.create_delta_array()
            for d in range(delta_array.shape[0]):
                conv(padded_array[f], flipped_weights[d],
                    delta_array[d], 1, 0)
            self.delta_array += delta_array
        # 将计算结果与激活函数的偏导数做element-wise乘法操作
        derivative_array = np.array(self.input_array)
        element_wise_op(derivative_array,
                        activator.backward)
        self.delta_array *= derivative_array

  2).保存传递到上一层的sensitivity map的数组

def create_delta_array(self):
        return np.zeros((self.channel_number,
            self.input_height, self.input_width))

  3).计算代码梯度

def bp_gradient(self, sensitivity_array):
        # 处理卷积步长,对原始sensitivity map进行扩展
        expanded_array = self.expand_sensitivity_map(
            sensitivity_array)
        for f in range(self.filter_number):
            # 计算每个权重的梯度
            filter = self.filters[f]
            for d in range(filter.weights.shape[0]):
                conv(self.padded_input_array[d],
                     expanded_array[f],
                     filter.weights_grad[d], 1, 0)
            # 计算偏置项的梯度
            filter.bias_grad = expanded_array[f].sum()

   4).按照梯度下降法更新参数

def update(self):
        '''
        按照梯度下降,更新权重
        '''
        for filter in self.filters:
            filter.update(self.learning_rate)

  6.MaxPooling层的训练

  1).定义MaxPooling类

class MaxPoolingLayer(object):
    def __init__(self, input_width, input_height,
                 channel_number, filter_width,
                 filter_height, stride):
        self.input_width = input_width
        self.input_height = input_height
        self.channel_number = channel_number
        self.filter_width = filter_width
        self.filter_height = filter_height
        self.stride = stride
        self.output_width = (input_width -
            filter_width) / self.stride + 1
        self.output_height = (input_height -
            filter_height) / self.stride + 1
        self.output_array = np.zeros((self.channel_number,
            self.output_height, self.output_width))

  2).前向传播计算

# 前向传播
    def forward(self, input_array):
        for d in range(self.channel_number):
            for i in range(self.output_height):
                for j in range(self.output_width):
                    self.output_array[d,i,j] = (
                        get_patch(input_array[d], i, j,
                            self.filter_width,
                            self.filter_height,
                            self.stride).max())

  3).反向传播计算

#反向传播
    def backward(self, input_array, sensitivity_array):
        self.delta_array = np.zeros(input_array.shape)
        for d in range(self.channel_number):
            for i in range(self.output_height):
                for j in range(self.output_width):
                    patch_array = get_patch(
                        input_array[d], i, j,
                        self.filter_width,
                        self.filter_height,
                        self.stride)
                    k, l = get_max_index(patch_array)
                    self.delta_array[d,
                        i * self.stride + k,
                        j * self.stride + l] = \
                        sensitivity_array[d,i,j]

  完整代码请见:cnn.py (https://github.com/huxiaoman7/PaddlePaddle\_code/blob/master/1.mnist/cnn.py)

#coding:utf-8
'''
Created by huxiaoman 2017.11.22

'''

import numpy as np
from activators import ReluActivator,IdentityActivator

class ConvLayer(object):
    def __init__(self,input_width,input_weight,
             channel_number,filter_width,
             filter_height,filter_number,
             zero_padding,stride,activator,
             learning_rate):
        self.input_width = input_width
        self.input_height = input_height
        self.channel_number = channel_number
        self.filter_width = filter_width
        self.filter_height = filter_height
        self.filter_number = filter_number
        self.zero_padding = zero_padding
        self.stride = stride #此处可以加上stride_x, stride_y
        self.output_width = ConvLayer.calculate_output_size(
                self.input_width,filter_width,zero_padding,
                stride)
        self.output_height = ConvLayer.calculate_output_size(
                self.input_height,filter_height,zero_padding,
                stride)
        self.output_array = np.zeros((self.filter_number,
                self.output_height,self.output_width))
        self.filters = []
        for i in range(filter_number):    
            self.filters.append(Filter(filter_width,
                filter_height,self.channel_number))
        self.activator = activator
        self.learning_rate = learning_rate
    def forward(self,input_array):
        '''
        计算卷积层的输出
        输出结果保存在self.output_array
        '''
        self.input_array = input_array
        self.padded_input_array = padding(input_array,
            self.zero_padding)
        for i in range(self.filter_number):
            filter = self.filters[f]
            conv(self.padded_input_array,
                 filter.get_weights(), self.output_array[f],
                 self.stride, filter.get_bias())
            element_wise_op(self.output_array,
                    self.activator.forward)

def get_batch(input_array, i, j, filter_width,filter_height,stride):
    '''
    从输入数组中获取本次卷积的区域,
    自动适配输入为2D和3D的情况
    '''
    start_i = i * stride
    start_j = j * stride
    if input_array.ndim == 2:
        return input_array[
            start_i : start_i + filter_height,
            start_j : start_j + filter_width]
    elif input_array.ndim == 3:
        return input_array[
            start_i : start_i + filter_height,
                        start_j : start_j + filter_width]

# 获取一个2D区域的最大值所在的索引
def get_max_index(array):
    max_i = 0
    max_j = 0
    max_value = array[0,0]
    for i in range(array.shape[0]):
        for j in range(array.shape[1]):
            if array[i,j] > max_value:
                max_value = array[i,j]
                max_i, max_j = i, j
    return max_i, max_j

def conv(input_array,kernal_array,
    output_array,stride,bias):
    '''
    计算卷积,自动适配输入2D,3D的情况
    '''
    channel_number = input_array.ndim
    output_width = output_array.shape[1]
    output_height = output_array.shape[0]
    kernel_width = kernel_array.shape[-1]
    kernel_height = kernel_array.shape[-2]
    for i in range(output_height):
        for j in range(output_width):
            output_array[i][j] = (
                get_patch(input_array, i, j, kernel_width,
                    kernel_height,stride) * kernel_array).sum() +bias


def element_wise_op(array, op):
    for i in np.nditer(array,
               op_flags = ['readwrite']):
        i[...] = op(i)


class ReluActivators(object):
    def forward(self, weighted_input):
        # Relu计算公式 = max(0,input)
        return max(0, weighted_input)

    def backward(self,output):
        return 1 if output > 0 else 0

class SigmoidActivator(object):
        
    def forward(self,weighted_input):
        return 1 / (1 + math.exp(- weighted_input))
    
    def backward(self,output):
        return output * (1 - output)

View Code

   最后,我们用之前的4 * 4的image数据检验一下通过一次卷积神经网络进行前向传播和反向传播后的输出结果:

def init_test():
    a = np.array(
        [[[0,1,1,0,2],
          [2,2,2,2,1],
          [1,0,0,2,0],
          [0,1,1,0,0],
          [1,2,0,0,2]],
         [[1,0,2,2,0],
          [0,0,0,2,0],
          [1,2,1,2,1],
          [1,0,0,0,0],
          [1,2,1,1,1]],
         [[2,1,2,0,0],
          [1,0,0,1,0],
          [0,2,1,0,1],
          [0,1,2,2,2],
          [2,1,0,0,1]]])
    b = np.array(
        [[[0,1,1],
          [2,2,2],
          [1,0,0]],
         [[1,0,2],
          [0,0,0],
          [1,2,1]]])
    cl = ConvLayer(5,5,3,3,3,2,1,2,IdentityActivator(),0.001)
    cl.filters[0].weights = np.array(
        [[[-1,1,0],
          [0,1,0],
          [0,1,1]],
         [[-1,-1,0],
          [0,0,0],
          [0,-1,0]],
         [[0,0,-1],
          [0,1,0],
          [1,-1,-1]]], dtype=np.float64)
    cl.filters[0].bias=1
    cl.filters[1].weights = np.array(
        [[[1,1,-1],
          [-1,-1,1],
          [0,-1,1]],
         [[0,1,0],
         [-1,0,-1],
          [-1,1,0]],
         [[-1,0,0],
          [-1,0,1],
          [-1,0,0]]], dtype=np.float64)
    return a, b, cl

  运行一下:

def test():
    a, b, cl = init_test()
    cl.forward(a)
    print "前向传播结果:", cl.output_array
    cl.backward(a, b, IdentityActivator())
    cl.update()
    print "反向传播后更新得到的filter1:",cl.filters[0]
    print "反向传播后更新得到的filter2:",cl.filters[1]

if __name__ == "__main__":
        test()

  运行结果: 

前向传播结果: [[[ 6.  7.  5.]
  [ 3. -1. -1.]
  [ 2. -1.  4.]]

 [[ 2. -5. -8.]
  [ 1. -4. -4.]
  [ 0. -5. -5.]]]
反向传播后更新得到的filter1: filter weights:
array([[[-1.008,  0.99 , -0.009],
        [-0.005,  0.994, -0.006],
        [-0.006,  0.995,  0.996]],

       [[-1.004, -1.001, -0.004],
        [-0.01 , -0.009, -0.012],
        [-0.002, -1.002, -0.002]],

       [[-0.002, -0.002, -1.003],
        [-0.005,  0.992, -0.005],
        [ 0.993, -1.008, -1.007]]])
bias:
0.99099999999999999
反向传播后更新得到的filter2: filter weights:
array([[[  9.98000000e-01,   9.98000000e-01,  -1.00100000e+00],
        [ -1.00400000e+00,  -1.00700000e+00,   9.97000000e-01],
        [ -4.00000000e-03,  -1.00400000e+00,   9.98000000e-01]],

       [[  0.00000000e+00,   9.99000000e-01,   0.00000000e+00],
        [ -1.00900000e+00,  -5.00000000e-03,  -1.00400000e+00],
        [ -1.00400000e+00,   1.00000000e+00,   0.00000000e+00]],

       [[ -1.00400000e+00,  -6.00000000e-03,  -5.00000000e-03],
        [ -1.00200000e+00,  -5.00000000e-03,   9.98000000e-01],
        [ -1.00200000e+00,  -1.00000000e-03,   0.00000000e+00]]])
bias:
-0.0070000000000000001

PaddlePaddle卷积神经网络源码解析

卷积层

   在上篇文章中,我们对paddlepaddle实现卷积神经网络的的函数简单介绍了一下。在手写数字识别中,我们设计CNN的网络结构时,调用了一个函数simple_img_conv_pool(上篇文章的链接已失效,因为已经把framework--->fluid,更新速度太快了 = =)使用方式如下:

conv_pool_1 = paddle.networks.simple_img_conv_pool(
        input=img,
        filter_size=5,
        num_filters=20,
        num_channel=1,
        pool_size=2,
        pool_stride=2,
        act=paddle.activation.Relu())

  这个函数把卷积层和池化层两个部分封装在一起,只用调用一个函数就可以搞定,非常方便。如果只需要单独使用卷积层,可以调用这个函数img_conv_layer,使用方式如下:

conv = img_conv_layer(input=data, filter_size=1, filter_size_y=1,
                              num_channels=8,
                              num_filters=16, stride=1,
                              bias_attr=False,
                              act=ReluActivation())

  我们来看一下这个函数具体有哪些参数(注释写明了参数的含义和怎么使用)

def img_conv_layer(input,
                   filter_size,
                   num_filters,
                   name=None,
                   num_channels=None,
                   act=None,
                   groups=1,
                   stride=1,
                   padding=0,
                   dilation=1,
                   bias_attr=None,
                   param_attr=None,
                   shared_biases=True,
                   layer_attr=None,
                   filter_size_y=None,
                   stride_y=None,
                   padding_y=None,
                   dilation_y=None,
                   trans=False,
                   layer_type=None):
    """
    适合图像的卷积层。Paddle可以支持正方形和长方形两种图片尺寸的输入
    
    也可适用于图像的反卷积(Convolutional Transpose,即deconv)。
    同样可支持正方形和长方形两种尺寸输入。

    num_channel:输入图片的通道数。可以是1或者3,或者是上一层的通道数(卷积核数目 * 组的数量)
    每一个组都会处理图片的一些通道。举个例子,如果一个输入如偏的num_channel是256,设置4个group,
    32个卷积核,那么会创建32*4 = 128个卷积核来处理输入图片。通道会被分成四块,32个卷积核会先
    处理64(256/4=64)个通道。剩下的卷积核组会处理剩下的通道。

    name:层的名字。可选,自定义。
    type:basestring

    input:这个层的输入
    type:LayerOutPut

    filter_size:卷积核的x维,可以理解为width。
                如果是正方形,可以直接输入一个元祖组表示图片的尺寸
    type:int/ tuple/ list

    filter_size_y:卷积核的y维,可以理解为height。
                PaddlePaddle支持长方形的图片尺寸,所以卷积核的尺寸为(filter_size,filter_size_y)

    type:int/ None

    act: 激活函数类型。默认选Relu
    type:BaseActivation

    groups:卷积核的组数量
    type:int
    

    stride: 水平方向的滑动步长。或者世界输入一个元祖,代表水平数值滑动步长相同。
    type:int/ tuple/ list

    stride_y:垂直滑动步长。
    type:int 
    
    padding: 补零的水平维度,也可以直接输入一个元祖,水平和垂直方向上补零的维度相同。
    type:int/ tuple/ list

    padding_y:垂直方向补零的维度
    type:int

    dilation:水平方向的扩展维度。同样可以输入一个元祖表示水平和初值上扩展维度相同
    :type:int/ tuple/ list

    dilation_y:垂直方向的扩展维度
    type:int

    bias_attr:偏置属性
              False:不定义bias   True:bias初始化为0
    type: ParameterAttribute/ None/ bool/ Any

    num_channel:输入图片的通道channel。如果设置为None,自动生成为上层输出的通道数
    type: int

    param_attr:卷积参数属性。设置为None表示默认属性
    param_attr:ParameterAttribute

    shared_bias:设置偏置项是否会在卷积核中共享
    type:bool

    layer_attr: Layer的 Extra Attribute
    type:ExtraLayerAttribute

    param trans:如果是convTransLayer,设置为True,如果是convlayer设置为conv
    type:bool

    layer_type:明确layer_type,默认为None。
               如果trans= True,必须是exconvt或者cudnn_convt,否则的话要么是exconv,要么是cudnn_conv
               ps:如果是默认的话,paddle会自动选择适合cpu的ExpandConvLayer和适合GPU的CudnnConvLayer
               当然,我们自己也可以明确选择哪种类型
    type:string
    return:LayerOutput object
    rtype:LayerOutput

    """


def img_conv_layer(input,
                   filter_size,
                   num_filters,
                   name=None,
                   num_channels=None,
                   act=None,
                   groups=1,
                   stride=1,
                   padding=0,
                   dilation=1,
                   bias_attr=None,
                   param_attr=None,
                   shared_biases=True,
                   layer_attr=None,
                   filter_size_y=None,
                   stride_y=None,
                   padding_y=None,
                   dilation_y=None,
                   trans=False,
                   layer_type=None):

    if num_channels is None:
        assert input.num_filters is not None
        num_channels = input.num_filters

    if filter_size_y is None:
        if isinstance(filter_size, collections.Sequence):
            assert len(filter_size) == 2
            filter_size, filter_size_y = filter_size
        else:
            filter_size_y = filter_size

    if stride_y is None:
        if isinstance(stride, collections.Sequence):
            assert len(stride) == 2
            stride, stride_y = stride
        else:
            stride_y = stride

    if padding_y is None:
        if isinstance(padding, collections.Sequence):
            assert len(padding) == 2
            padding, padding_y = padding
        else:
            padding_y = padding

    if dilation_y is None:
        if isinstance(dilation, collections.Sequence):
            assert len(dilation) == 2
            dilation, dilation_y = dilation
        else:
            dilation_y = dilation

    if param_attr.attr.get('initial_smart'):
        # special initial for conv layers.
        init_w = (2.0 / (filter_size**2 * num_channels))**0.5
        param_attr.attr["initial_mean"] = 0.0
        param_attr.attr["initial_std"] = init_w
        param_attr.attr["initial_strategy"] = 0
        param_attr.attr["initial_smart"] = False

    if layer_type:
        if dilation > 1 or dilation_y > 1:
            assert layer_type in [
                "cudnn_conv", "cudnn_convt", "exconv", "exconvt"
            ]
        if trans:
            assert layer_type in ["exconvt", "cudnn_convt"]
        else:
            assert layer_type in ["exconv", "cudnn_conv"]
        lt = layer_type
    else:
        lt = LayerType.CONVTRANS_LAYER if trans else LayerType.CONV_LAYER

    l = Layer(
        name=name,
        inputs=Input(
            input.name,
            conv=Conv(
                filter_size=filter_size,
                padding=padding,
                dilation=dilation,
                stride=stride,
                channels=num_channels,
                groups=groups,
                filter_size_y=filter_size_y,
                padding_y=padding_y,
                dilation_y=dilation_y,
                stride_y=stride_y),
            **param_attr.attr),
        active_type=act.name,
        num_filters=num_filters,
        bias=ParamAttr.to_bias(bias_attr),
        shared_biases=shared_biases,
        type=lt,
        **ExtraLayerAttribute.to_kwargs(layer_attr))
    return LayerOutput(
        name,
        lt,
        parents=[input],
        activation=act,
        num_filters=num_filters,
        size=l.config.size)

   我们了解这些参数的含义后,对比我们之前自己手写的CNN,可以看出paddlepaddle有几个优点:

  • 支持长方形和正方形的图片尺寸
  • 支持滑动步长stride、补零zero_padding、扩展dilation在水平和垂直方向上设置不同的值
  • 支持偏置项卷积核中能够共享
  • 自动适配cpu和gpu的卷积网络

   在我们自己写的CNN中,只支持正方形的图片长度,如果是长方形会报错。滑动步长,补零的维度等也只支持水平和垂直方向上的维度相同。了解卷积层的参数含义后,我们来看一下底层的源码是如何实现的:ConvBaseLayer.py 有兴趣的同学可以在这个链接下看看底层是如何用C++写的ConvLayer

  池化层同理,可以按照之前的思路分析,有兴趣的可以一直顺延看到底层的实现,下次有机会再详细分析。(占坑明天补一下tensorflow的源码实现)


总结

  本文主要讲解了卷积神经网络中反向传播的一些技巧,包括卷积层和池化层的反向传播与传统的反向传播的区别,并实现了一个完整的CNN,后续大家可以自己修改一些代码,譬如当水平滑动长度与垂直滑动长度不同时需要怎么调整等等,最后研究了一下paddlepaddle中CNN中的卷积层的实现过程,对比自己写的CNN,总结了4个优点,底层是C++实现的,有兴趣的可以自己再去深入研究。写的比较粗糙,如果有问题欢迎留言:)

参考文章:

1.https://www.cnblogs.com/pinard/p/6494810.html

2.https://www.zybuluo.com/hanbingtao/note/476663

本文迁移自博客园,保留原始发表日期。查看原文 ↗

搜索文章

输入关键词,直接在这里查找。可用 ⌘ K / Ctrl K 唤起搜索。