一文搞定卷积神经网络——从原理到应用

1. 前言

​ 我之前写过一篇关于BP神经网络原理的文章,主要内容包括:BP神经网络的反向传播原理,以及简单的代码实现。本文和《BP 神经网络入门:从原理到应用》一文一脉相承,因此,我希望读者务必先看看这篇文章(链接在这里:BP 神经网络入门:从原理到应用,

​ 好多读者都说,之前那篇BP神经网络的文章,公式太多,阅读体验不好,所以本文我将尽量减少公式,加入更多的图解和通俗解释。

​ 本文主要包括以下内容:

  • 卷积神经网络结构

  • 前向传播原理

  • 反向传播原理

  • 代码实现

  • 模型测试

  • 简单的优化设计方法;优化设计方法有很多,本文不做过多介绍,如果有必要,我会后面会写个有关优化设计的课程。

2. 全连接BP神经网络的缺点

​ 像全连接BP神经网络一样,卷积神经网络,也有一定的生物学基础(虽然,有时候确实感觉它的生物学解释有些牵强)。

​ 我在BP 神经网络入门:从原理到应用一文,实现了一个,识别图片是不是猫的程序。在这个程序里,我们把输入的图片“展平”了,“展平”是什么意思呢?我们都知道,计算机存储图片,实际是存储了一个W×H×DW\times H\times D 的数组(W,H,D分别表示宽,高和维数,彩色图片包含RGB三维),如下图所示,“展平”就是,把这个数组变成一列,然后,输入神经网络进行训练:

在这里插入图片描述

​ 这种方法,在图片尺寸较小的时候,还是可以接受的;但是,当图片尺寸比较大的时候,由于图像的像素点很多,神经网络的 层与层之间 的连接数量,会爆炸式增长,大大降低网络的性能。

​ 除此之外,把图片数据展开成一列,破坏了图片的空间信息,试想,如果把上图中的猫图片变成一列,再把这一列数据,以图片的形式展示给你,你还能看出这表示一只猫吗?并且,有相关研究表明,人类大脑,在理解图片信息的过程中,并不是同时观察整个图片,而是更倾向于,观察部分特征,然后根据特征匹配、组合,得出整图信息。以下图为例:

在这里插入图片描述

​ 在这张图片中,你只能看到,猫的鼻子,1只眼睛,2只耳朵和嘴,虽然没有尾巴,爪子等其它信息,但是,你依然可以判断,这里有一只猫。所以说,人在理解图像信息的过程中,更专注于,局部特征,以及这些特征之间的组合。

​ 换句话说,在BP全连接神经网络中,隐含层每一个神经元,都对输入图片 每个像素点 做出反应。这种机制包含了太多冗余连接。为了减少这些冗余,只需要每个隐含神经元,对图片的一小部分区域,做出反应就好了!卷积神经网络,正是基于这种想法而实现的。

3. 卷积神经网络基本单元

3.1 卷积(Convolution)

​ 如果,你学过信号处理,或者有相关专业的数学基础,可能需要注意一下,这里的“卷积”,和信号处理里的卷积,不太一样,虽然,在数学表达式形式上有一点类似。

​ 在卷积神经网络中,“卷积”更像是一个,特征提取算子。什么是特征提取算子呢?简单来说就是,提取图片纹理、边缘等特征信息的滤波器。下面,举个简单的例子,解释一下 边缘 特征提取算子是怎么工作的:

在这里插入图片描述

​ 比如有一张猫图片,人类在理解这张图片的时候,可能观察到圆圆的眼睛,可爱的耳朵,于是,判断这是一只猫。但是,机器怎么处理这个问题呢?传统的计算机视觉方法,通常设计一些算子(特征提取滤波器),来找到比如眼睛的边界,耳朵的边界,等信息,然后综合这些特征,得出结论——这是一只猫。

​ 具体是怎么做的呢:

在这里插入图片描述

​ 如上图所示,假设,猫的上眼皮部分(框出部分),这部分的数据,展开后如图中数组所示**(这里,为了叙述简便,忽略了rgb三维通道,把图像当成一个二维数组[类比灰度图片]),我们使用一个算子,来检测横向边沿**——这个算子,让第一行的值减去第三行的值,得出结果。具体计算过程如下,比如,原图左上角数据,经过这个算子:

200×1+199×1+189×1+203×0+23×0+34×0+177×(1)+12×(1)+22×(1)=328200\times1+199\times1+189\times1+203\times0+23\times0+34\times0+177\times(-1)+12\times(-1)+22\times(-1)=328

​ 然后,算子向右滑动一个像素,得到第二个输出:533,依次向右滑动,最终得到第一行输出;

​ 然后,向下滑动一个像素,到达第二行;在第二行,从左向右滑动,得到第二行输出……

​ 动图演示过程如下(图片来自csdn):

在这里插入图片描述

​ 从输出结果可以看出,在上眼皮处颜色较深,而眼皮上方和下方,无论是眼皮上方的毛发,还是眼皮下方的眼白,颜色都很浅,因此,这样做之后,如果滤波结果中有大值出现,就意味着,这里出现了横向边沿。不断在原图上滑动这个算子,我们就可以,检测到图像中所有的横向边沿。传统的机器视觉方法,就是设计更多更复杂的算子,检测更多的特征,最后,组合这些特征,得出判断结果。

​ 卷积神经网络,做了类似的事情,所谓卷积,就是把一个算子在原图上不断滑动,得出滤波结果——这个结果,我们叫做“特征图”(Feature Map),这些算子被称为“卷积核”(Convolution Kernel)。不同的是,我们不必人工设计这些算子,而是使用随机初始化,来得到很多卷积核(算子),然后通过反向传播,优化这些卷积核,以期望得到更好的识别结果。

3.2 填充(Padding)和步长(Stride)

​ 你可能注意到了,刚刚,我们“滑动”卷积核之后,输出变小了;具体来说,猫眼睛部分数据,尺寸是6×86\times 8,但是,经过一个3×33\times 3的卷积核之后,变成了4×64\times 6,这是因为卷积核有尺寸,如下图所示,当卷积核(橙色)滑动到边界时,就无法向右继续滑动了:

在这里插入图片描述

​ 可想而知,如果经过很多层卷积的话,输出尺寸会变的很小,同时图像边缘信息,会迅速流失,这对模型的性能,有着不可忽视的影响。为了减少卷积操作导致的,边缘信息丢失,我们需要进行填充(Padding),即在原图周围,添加一圈值为“0”的像素点(zero padding),这样的话,输出维度就和输入维度一致了。

在这里插入图片描述

​ 还有一个很重要的因素是,步长(Stride),即卷积核每次滑动几个像素。前面,我们默认,卷积核每次滑动一个像素,其实也可以,每次滑动两个像素。其中,每次滑动的像素数,称为“步长”。

​ 你可能会有疑问,如果步长大于 1 ,那不也会造成输出尺寸变小吗?是的!但是这种情况下,不会“故意”丢失边缘信息,即使有信息丢失,也是在整张图片上,比较“温和”地,舍弃了一些无关紧要的信息(反向传播调节卷积核参数,使之“智能”地舍弃无关信息)。但是,不能频繁使用步长为2。因为,如果输出尺寸变得,过小的话,即使卷积核参数优化的再好,也会必可避免地丢失大量信息!所以,你可以看到,在应用卷积网络的时候,通常使用步长为 1 的卷积核。

​ **卷积核大小(f×ff\times f)**也可以变化,比如 1×11\times15×55\times5 等,此时,需要根据卷积核大小,来调节填充尺寸(Padding size)。一般来说,卷积核尺寸取奇数(因为我们希望卷积核有一个中心,便于处理输出)。卷积核尺寸为奇数时,填充尺寸可以根据以下公式确定:

padding_size=f12padding\_size=\frac{f-1}{2}

​ 其中,ff 表示卷积核大小。

​ 如果用ss 表示步长,ww 表示图片宽度,hh 表示图片高度,那么输出尺寸可以表示为:

w_out=w+2×padding_sizefs+1w\_out=\frac{w+2\times padding\_size-f}{s}+1

h_out=h+2×padding_sizefs+1h\_out=\frac{h+2\times padding\_size-f}{s}+1

​ 下面提供一些动图帮助大家理解(图片来自CSDN):

类别 动图
no padding, s=1, f=3 no padding, s=2, f=3
在这里插入图片描述 在这里插入图片描述
with padding, s=1, f=3 with padding, s=2, f=3
在这里插入图片描述 在这里插入图片描述

3.3 完整的卷积过程

​ 上面的卷积过程,没有考虑,彩色图片有rgb三维通道(Channel),如果考虑rgb通道,那么,每个通道,都需要一个卷积核:

在这里插入图片描述

​ 当输入有多个通道时,我们的卷积核也需要,有同样数量的通道。以上图为例,输入有RGB三个通道,我们的就卷积核,也有三个通道,只不过计算的时候,卷积核的每个通道,在对应通道滑动(卷积核最前面的通道在输入图片的红色通道滑动,卷积核中间的通道在输入图片的绿色通道滑动,卷积核最后面的通道在输入图片的蓝色通道滑动),三个通道的计算结果相加得到输出。注意,输出只有一个通道,因为这里我们只用了一个卷积核(只不过这个卷积核有三个通道)。用下面的动图加深理解(图片来自网络,这里我们用了 2 个卷积核,每个卷积核 3 个通道,输出有 2 个通道):

图片过大,上传失败

​ 三维展示如下(如果视频无法加载可以点击此链接查看):

3.4 池化(Pooling)

​ 池化(Pooling),有的地方也称汇聚,实际是一个下采样(Down-sample)过程。由于输入的图片尺寸可能比较大,这时候,我们需要下采样,减小图片尺寸。池化层,可以减小模型规模,提高运算速度,同时,提高所提取特征的鲁棒性。池化操作也有核大小 ff 和步长 ss 参数,参数意义和卷积相同。

​ 本文主要介绍最大池化(Max Pooling)和平均池化(Average Pooling)。

​ 所谓最大池化,就是对于f×ff\times f大小的池化核,选取原图中数值最大的那个保留下来。比如,池化核 2×22\times2 大小,步长为2的池化过程如下(左边是池化前,右边是池化后),对于每个池化区域都取最大值:

在这里插入图片描述

​ 最大池化最为常用,并且一般都取2×22\times2的池化核代大小且步长为2。

​ 平均池化是取每个区域的均值(平均池化现在很少使用):

在这里插入图片描述

3.5 激活函数

  本文主要介绍 2 种激活函数,分别是sigmoidsigmoidrelurelu函数,函数公式如下:
sigmoid(z)=11+ezsigmoid(z)=\frac{1}{1+e^{-z}}
relu(z)={zz>00z0relu(z)= \left\{ \begin{array}{rcl} z & z>0\\ 0&z\leq0\end{array} \right.
  函数图如下:

在这里插入图片描述

sigmoid(z)sigmoid(z)
在这里插入图片描述
relu(z)relu(z)

补充说明

  引入激活函数的目的是,在模型中引入非线性。如果没有激活函数,那么,无论你的神经网络有多少层,最终都是一个线性映射,单纯的线性映射,无法解决线性不可分问题。引入非线性可以让模型解决线性不可分问题。

  一般来说,在神经网络的中间层,更加建议使用relurelu函数,两个原因:

  • relurelu函数计算简单,可以加快模型速度;
  • 由于反向传播过程中,需要计算偏导数,通过求导可以得到,sigmoidsigmoid函数导数的最大值为0.25,如果使用sigmoidsigmoid函数的话,每一层的反向传播,都会使梯度最少变为原来的四分之一,当层数比较多的时候可能会造成梯度消失,从而模型无法收敛。

4. 卷积神经网络的前向传播过程

​ 本节主要介绍,典型的卷积神经网络模型结构。

在这里插入图片描述

​ 典型的卷积网络结构,如上图所示(注意观察图中颜色对应关系)。首先输入图像,然后卷积。卷积过程采用n个卷积核,每个卷积核都有三个通道(卷积核通道数,和输入图片的通道数目相同),输出结果尺寸为W×H×nW^{'}\times H^{'}\times n,这里的输出有n个通道,所以,下一层卷积,每个卷积核都要有n个通道;重复卷积-池化过程;模型的最后一层或两层,一般为全连接结构,输出最终结果。全连接结构部分和BP神经网络一样。最后,我们需要设计合适的损失函数,反向传播就是通过最小化损失函数,来优化网络参数,使模型达到预期结果。

​ 我们把每次卷积之后,得到输出称为“特征图(Feature map)”。

​ 我们定义以下符号,表示卷积网络结构(这里我尽可能模仿Tensorflow的API):

  • 卷积 conv2d(input, filter, strides, padding)
    • input:卷积输入(H×W×CinH \times W \times C_{in}CinC_{in}指的是输入的通道数)
    • filter:卷积核W (fheight×fwidth×Cin×Coutf_{height}\times f_{width}\times C_{in}\times C_{out}
    • strides:步长,一般为1×1×1×11\times1\times1\times1
    • padding:指明padding的算法,‘SAME’或者‘VALID’
  • 激活 relu(conv)
    • conv:卷积结果
  • 偏置相加 add(conv, b)
    • conv:卷积的激活输出
    • b:偏置系数
  • 池化 max_pool(value, ksize, strides, padding)
    • value:卷积输出
    • ksize:池化尺寸,一般2×22\times2
    • strides:步长
    • padding:指明padding的算法,‘SAME’或者‘VALID’

5. 卷积神经网络的反向传播过程

​ 终于讲到反向传播了。现在大多数深度学习框架,都帮我们做好了误差反传的工作。大部分工程师,仅仅需要构建前向传播过程,就可以了。但是,对于研究人员,有必要了解一下误差反传的原理。

​ 在BP神经网络中,我们已经介绍过,全连接神经网络的反向传播过程。很多教程,在介绍卷积网络反向传播的时候,总是说,类比全连接神经网络,然后一笔带过。其实,卷积神经网络的误差反传过程,要复杂得多。原因有以下几点:

  • 卷积神经网络的卷积核,每次滑动只与部分输入相连,具有局部映射的特点,在误差反传的时候,需要确定卷积核的局部连接方式;

  • 卷积神经网络的池化过程,丢失了大量信息,误差反传,需要恢复这些丢失的信息;

  • ……

      总之,卷积神经网络的误差反传过程,很复杂。之前,在写BP神经网络那篇文章的时候,推导的公式过多,导致很多读者反应,文章晦涩难懂;所以,这次我尽可能用通俗的语言来表述。
    
本小节的符号定义,和BP神经网络一文相同,如果出现了新的符号,会在文中对应位置说明。

5.1 卷积层的误差反传

​ 假设损失函数为LossLoss, 我们希望求解,损失函数对参数 W 和偏置 b 的梯度。

​ 首先,来看损失函数对卷积核 W 的梯度:

lthl^{th}层的卷积输出为:

Z[l]=conv2d(Zpool[l1],(W,b),strides=1,padding=SAME)      =mnWm,n[l1]Zpool_m,n[l1]+b[l]Z^{[l]}=conv2d(Z^{[l-1]}_{pool}, (W, b) , strides = 1, padding='SAME')\\ \ \ \ \ \ \ = \sum_m\sum_nW^{[l-1]}_{m^{'},n^{'}}Z^{[l-1]}_{pool\_m, n}+b^{[l]}

​ 其中,Zpool_m,n[l1]Z^{[l-1]}_{pool\_m, n}表示上一层池化的输出;mm^{'}nn^{'}表示卷积核尺寸;mmnn 表示输入的长和宽。

​ 卷积层反向传播的基础,依旧是链式法则,所以,我们必须搞清楚,权重W的局部连接方式。

​ 考虑前向传播过程:

在这里插入图片描述

​ 可以发现,由于卷积核在输入的特征图上滑动,所以,特征图的所有像素都参与了卷积核运算,与此对应,输出特征图的每个像素,都和卷积核 W 直接相连。

​ 损失函数对参数W的梯度,根据链式法则:

LossWm,n[l]=i=0Hi=0HLossZi,j[l]Zi,j[l]Wm,n[l] =i=0Hi=0HdZi,j[l]Zi,j[l]Wm,n[l]\frac{\partial Loss}{\partial W^{[l]}_{m^{'}, n^{'}}}=\sum_{i=0}^{H}\sum_{i=0}^{H}\frac{\partial Loss}{\partial Z^{[l]}_{i, j}}\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}\\ \qquad \ =\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}

​ 其中,Z表示没有激活的输出,dZ[l]dZ^{[l]}表示lthl^{th}层的误差。现在我们来求解Zi,j[l]Wm,n[l]\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m, n}}部分:

Zi,j[l]Wm,n[l]=Wm,n[l](mnWm,n[l1]Zpool_m,n[l1]+b[l]) =Wm,n[l](W0,0[l]Zpool_i+0,j+0[l1]+...+Wm,n[l]Zpool_i+m,j+n[l1]) =Zpool_i+m,j+n[l1]\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}=\frac{\partial}{\partial W^{[l]}_{m^{'}, n^{'}}}(\sum_m\sum_nW^{[l-1]}_{m^{'},n^{'}}Z^{[l-1]}_{pool\_m, n}+b^{[l]})\\ \qquad \ = \frac{\partial}{\partial W^{[l]}_{m^{'}, n^{'}}}(W^{[l]}_{0,0}Z^{[l-1]}_{pool\_i+0, j+0}+ . . . +W^{[l]}_{m^{'},n^{'}}Z^{[l-1]}_{pool\_i+m^{'},j+n^{'}})\\ \qquad \ =Z^{[l-1]}_{pool\_i+m^{'},j+n^{'}}

​ 综上,

LossWm,n[l]=i=0Hi=0HdZi,j[l]Zi,j[l]Wm,n[l]=i=0Hi=0HdZi,j[l]Zpool_i+m,j+n[l1]\frac{\partial Loss}{\partial W^{[l]}_{m^{'}, n^{'}}}=\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}\\ \qquad \quad=\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}Z^{[l-1]}_{pool\_i+m^{'},j+n^{'}}\\ \qquad \quad

​ 同理,可得:

Lossb[l]=i=0Hi=0HdZi,j[l]\frac{\partial Loss}{\partial b^{[l]}}=\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}

​ 下面来看看dZ[l]dZ^{[l]}如何求解:

dZi,j[l]=LossZi,j[l]=LossZ[l+1]Z[l+1]Z[l]=dZ[l+1]Z[l+1]Z[l]dZ^{[l]}_{i, j}=\frac{\partial Loss}{\partial Z^{[l]}_{i, j}}=\frac{\partial Loss}{\partial Z^{[l+1]}}\frac{\partial Z^{[l+1]}}{\partial Z^{[l]}}=dZ^{[l+1]}\frac{\partial Z^{[l+1]}}{\partial Z^{[l]}}

​ 也就是说我们需要求解Z[l+1]Z[l]\frac{\partial Z^{[l+1]}}{\partial Z^{[l]}},所以我们需要知道在lthl^{th}层的一个像素值如何影响卷积输出:

在这里插入图片描述

​ 上图说明,一个像素点,通过卷积运算,会影响输出的一个区域(输入特征图的蓝色像素点,影响了输出特征图的蓝色虚线框出的部分)。所以反向传播就是要找到,卷积核滑动过程中,一个像素点,如何通过 W 影响输出,然后逐步应用链式法则即可。

5.2 池化层的误差反传

​ 池化层没有参数需要学习,但是,由于池化层的下采样操作损失了大量信息,因此,在反向传播过程中,我们需要恢复这些信息。

​ 对于最大池化而言,我们需要知道在池化之前最大元素的位置:

在这里插入图片描述

​ 然后,在反向传播的时候将对应值填入最大位置,这可以用池化后误差乘以M得到,这样一个数就可以恢复为2×22\times 2的矩阵,对池化层误差矩阵的每个元素都应用这样的上采样,我们就可以恢复维度信息,从而误差可以顺利反传。

​ 对于平均池化而言,我们保存的是均值信息:

在这里插入图片描述

​ 反传过程中依旧是用池化层误差乘以dZ,将一个数恢复为2×22\times 2的矩阵即,可恢复维度信息。

6. 代码实现

​ 本小节,给出了Numpy实现卷积网络的核心代码。另外,因为卷积神经网络的计算过程很复杂,因此,使用自己实现的核心代码效率很低。所以,完整模型的训练采用TensorFlow库来实现。

6.1 Numpy实现

6.1.1 卷积层前向传播

​ 首先实现一个0填充的函数:

def zero_pad(X, pad):
    """零填充。
    
    Args:
    X: python numpy array of shape (m, n_H, n_W, n_C) representing a batch of m images
    pad: integer, amount of padding around each image on vertical and horizontal dimensions
    
    Returns:
    X_pad -- padded image of shape (m, n_H + 2*pad, n_W + 2*pad, n_C)
    """
    X_pad = np.pad(X, ((0,0),(pad,pad),(pad,pad),(0,0)), 'constant')
    return X_pad

​ 接下来实现一个单步卷积,即考虑全图一个切片的卷积:

def conv_single_step(a_slice_prev, W, b):
    """"
    Args:
    a_slice_prev: slice of input data of shape (f, f, n_C_prev)
    W: Weight parameters contained in a window - matrix of shape (f, f, n_C_prev)
    b: Bias parameters contained in a window - matrix of shape (1, 1, 1)
    
    Returns:
    Z: a float value.
    """
	s = a_slice_prev * W
    Z = np.sum(s)
    Z = Z + b
    return Z

​ 完整的一步卷积,即卷积核在输入特征图滑动:

def conv_forward(A_prev, W, b, hparameters):
    """前向传播
    
    Args:
    A_prev: output activations of the previous layer, numpy array of shape (m, n_H_prev, n_W_prev, n_C_prev)
    W: Weights, numpy array of shape (f, f, n_C_prev, n_C)
    b: Biases, numpy array of shape (1, 1, 1, n_C)
    hparameters: a python dictionary containing "stride" and "pad"
        
    Returns:
    Z: conv output, numpy array of shape (m, n_H, n_W, n_C)
    cache: cache of values needed for the conv_backward() function
    """
    (m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
    (f, f, n_C_prev, n_C) = W.shape
    stride = hparameters['stride']
    pad = hparameters['pad']
   	
    n_H = int((n_H_prev+2*pad-f)/stride) + 1
    n_W = int((n_W_prev+2*pad-f)/stride) + 1
    
    Z = np.zeros((m,n_H,n_W,n_C))
    
    A_prev_pad = zero_pad(A_prev, pad)
    
    for i in range(m):                              
        a_prev_pad = A_prev_pad[i,:,:,:]
        for h in range(n_H):  
            for w in range(n_W):  
                
                for c in range(n_C):
                    vert_start = h * stride
                    vert_end = vert_start+f
                    horiz_start = w * stride
                    horiz_end = horiz_start+f
                    
                    a_slice_prev = a_prev_pad[vert_start:vert_end,horiz_start:horiz_end, :]
                    Z[i, h, w, c] = conv_single_step(a_slice_prev, W[:,:,:,c], b[:,:,:,c])                       
    
    cache = (A_prev, W, b, hparameters)
    
    return Z, cache

6.1.2 卷积层反向传播

def conv_backward(dZ, cache):
    """卷积层反向传播
    
    Args:
    dZ: gradient of the cost with respect to the output of the conv layer (Z), numpy array of shape (m, n_H, n_W, n_C)
    cache: cache of values needed for the conv_backward(), output of conv_forward()
    
    Returns:
    dA_prev: gradient of the cost with respect to the input of the conv layer (A_prev), numpy array of shape (m, n_H_prev, n_W_prev, n_C_prev)
    dW: gradient of the cost with respect to the weights of the conv layer (W)
          numpy array of shape (f, f, n_C_prev, n_C)
    db: gradient of the cost with respect to the biases of the conv layer (b)
          numpy array of shape (1, 1, 1, n_C)
    """
    (A_prev, W, b, hparameters) = cache
    (m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
    (f, f, n_C_prev, n_C) = W.shape
    stride = hparameters['stride']
    pad = hparameters['pad']
    (m, n_H, n_W, n_C) = dZ.shape
    
    dA_prev = np.zeros(A_prev.shape)                          
    dW = np.zeros(W.shape)
    db = np.zeros(b.shape)
    
    A_prev_pad = zero_pad(A_prev, pad)
    dA_prev_pad = zero_pad(dA_prev, pad)
    
    for i in range(m):    
        a_prev_pad = A_prev_pad[i]
        da_prev_pad = dA_prev_pad[i]
        
        for h in range(n_H):                   
            for w in range(n_W):               
                for c in range(n_C): 
                    vert_start = h * stride
                    vert_end = vert_start + f
                    horiz_start = w * stride
                    horiz_end = horiz_start + f
                    
                    a_slice = a_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :]
                    
                    da_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] += W[:, :, :, c] * dZ[i, h, w, c]
                    dW[:,:,:,c] += a_slice * dZ[i, h, w, c]
                    db[:,:,:,c] += dZ[i, h, w, c]
                    
        
        dA_prev[i, :, :, :] = da_prev_pad[pad:-pad, pad:-pad, :]
    
    return dA_prev, dW, db

6.1.3 池化前向传播

​ 池化前向传播:

def pool_forward(A_prev, hparameters, mode = "max"):
    """
    Args:
    A_prev: Input data, numpy array of shape (m, n_H_prev, n_W_prev, n_C_prev)
    hparameters: python dictionary containing "f" and "stride"
    mode: "max" or "average"
    
    Returns:
    A: output of the pool layer, a numpy array of shape (m, n_H, n_W, n_C)
    cache: cache used in the backward pass of the pooling layer, contains the input and hparameters 
    """
    (m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
    
    f = hparameters["f"]
    stride = hparameters["stride"]
    
    n_H = int(1 + (n_H_prev - f) / stride)
    n_W = int(1 + (n_W_prev - f) / stride)
    n_C = n_C_prev
    
    A = np.zeros((m, n_H, n_W, n_C))              
    
    
    for i in range(m):                         
        for h in range(n_H):                     
            for w in range(n_W):                 
                for c in range (n_C):            
                    vert_start = h*stride
                    vert_end = vert_start+f
                    horiz_start = w*stride
                    horiz_end = horiz_start+f
                    
                    a_prev_slice = A_prev[i, vert_start:vert_end, horiz_start:horiz_end, c]
                    
                    if mode == "max":
                        A[i, h, w, c] = np.max(a_prev_slice)
                    elif mode == "average":
                        A[i, h, w, c] = np.mean(a_prev_slice)
    
    cache = (A_prev, hparameters)
    
    return A, cache

6.1.4 池化反向传播

​ 首先,对于最大池化,我们需要记录前向传播过程中最大值在哪个位置:

def create_mask_from_window(x):
    """
    Args:
    x: Array of shape (f, f)
    
    Returns:
    maskL: Array of the same shape as window, contains a True at the position corresponding to the max entry of x.
    """
    mask = x == np.max(x)
    
    return mask

​ 对于平均池化,我们需要把误差平均分到每个位置:

def distribute_value(dz, shape):
    """
    Args:
    dz: input scalar
    shape: the shape (n_H, n_W) of the output matrix for which we want to distribute the value of dz
    
    Returns:
    a: Array of size (n_H, n_W) for which we distributed the value of dz
    """
    (n_H, n_W) = shape
    average = dz / (n_H * n_W)
    a = np.ones((n_H, n_W)) * average
    return a

​ 接下来,我们实现完整的池化层反向传播过程:

def pool_backward(dA, cache, mode = "max"):
    """
    Args:
    dA: gradient of cost with respect to the output of the pooling layer, same shape as A
    cache: cache output from the forward pass of the pooling layer, contains the layer's input and hparameters 
    mode: the pooling mode you would like to use, defined as a string ("max" or "average")
    
    Returns:
    dA_prev: gradient of cost with respect to the input of the pooling layer, same shape as A_prev
    """
    (A_prev, hparameters) = cache
    stride = hparameters['stride']
    f = hparameters['f']
    m, n_H_prev, n_W_prev, n_C_prev = A_prev.shape
    m, n_H, n_W, n_C = dA.shape
    
    dA_prev = np.zeros(A_prev.shape)
    
    for i in range(m):                       
        a_prev = A_prev[i]
        for h in range(n_H):                   
            for w in range(n_W):               
                for c in range(n_C):           
                    vert_start = h * stride
                    vert_end = vert_start + f
                    horiz_start = w * stride
                    horiz_end = horiz_start + f
                    
                    if mode == "max":
                        a_prev_slice = a_prev[vert_start:vert_end, horiz_start:horiz_end, c]
                        mask = create_mask_from_window(a_prev_slice)
                        dA_prev[i, vert_start: vert_end, horiz_start: horiz_end, c] += np.multiply(mask, dA[i, h, w, c])
                        
                    elif mode == "average":
                        da = dA[i, h, w, c]
                        shape = (f, f)
                        dA_prev[i, vert_start:vert_end, horiz_start:horiz_end, c] += distribute_value(da, shape)
                        
    return dA_prev

6.2 Tensorflow框架实现

​ 由于自己实现的卷积网络核心代码,计算速度过慢(只能在CPU运行,且包含太多for循环,效率过低),所以最终决定用TensorFlow来完成一个测试模型。

​ 我们选用TensorFlow实现一个CIFAR-10数据集分类的例子。CIFAR-10是一个用于图片分类的数据集。此数据集由60000张图片组成,包涵10类。其中50000张图片用于训练,10000张图片用于测试。

​ 本文不会具体介绍TensorFlow的详细用法,如果需要,可以参考TensoFlow官网教程,或者gitchat也有人写过一些入门文章。TensorFlow实现深度学习模型一般包涵以下步骤:

  • 定义占位符

  • 构建模型的前向传播过程

  • 定义损失函数

  • 指定优化方法

  • 运行优化方法最小化损失函数

    我使用jupyter notebook编程环境。首先,导入必须的包,导入数据文件:

import os
import tensorflow as tf
import numpy as np
import math
import timeit
import matplotlib.pyplot as plt
gpu_no = '0'  # or '1'

os.environ["CUDA_VISIBLE_DEVICES"] = gpu_no
config = tf.ConfigProto()

config.gpu_options.allow_growth = True

%matplotlib inline

def load_cifar10(num_training=49000, num_validation=1000, num_test=10000):
   	"""
   	导入CIFAR数据集。
   	"""
    cifar10 = tf.keras.datasets.cifar10.load_data()
    (X_train, y_train), (X_test, y_test) = cifar10
    X_train = np.asarray(X_train, dtype=np.float32)
    y_train = np.asarray(y_train, dtype=np.int32).flatten()
    X_test = np.asarray(X_test, dtype=np.float32)
    y_test = np.asarray(y_test, dtype=np.int32).flatten()

    
    mask = range(num_training, num_training + num_validation)
    X_val = X_train[mask]
    y_val = y_train[mask]
    mask = range(num_training)
    X_train = X_train[mask]
    y_train = y_train[mask]
    mask = range(num_test)
    X_test = X_test[mask]
    y_test = y_test[mask]
    
    mean_pixel = X_train.mean(axis=(0, 1, 2), keepdims=True)
    std_pixel = X_train.std(axis=(0, 1, 2), keepdims=True)
    X_train = (X_train - mean_pixel) / std_pixel
    X_val = (X_val - mean_pixel) / std_pixel
    X_test = (X_test - mean_pixel) / std_pixel

    return X_train, y_train, X_val, y_val, X_test, y_test



X_train, y_train, X_val, y_val, X_test, y_test = load_cifar10()

class Dataset(object):
    def __init__(self, X, y, batch_size, shuffle=False):
        assert X.shape[0] == y.shape[0], 'Got different numbers of data and labels'
        self.X, self.y = X, y
        self.batch_size, self.shuffle = batch_size, shuffle

    def __iter__(self):
        N, B = self.X.shape[0], self.batch_size
        idxs = np.arange(N)
        if self.shuffle:
            np.random.shuffle(idxs)
        return iter((self.X[i:i+B], self.y[i:i+B]) for i in range(0, N, B))


train_dset = Dataset(X_train, y_train, batch_size=64, shuffle=True)
val_dset = Dataset(X_val, y_val, batch_size=64, shuffle=False)
test_dset = Dataset(X_test, y_test, batch_size=64)

​ 构建计算图(前向传播过程):

def conv2d(x, scope, W_shape, b_shape, stddev = 5e-2, stride = [1, 1, 1, 1], lambda_ = 0.005):
    """
    定义卷积层
    """
    with tf.variable_scope(scope, reuse = tf.AUTO_REUSE) as scope:
        
        initializer = tf.truncated_normal_initializer(stddev=stddev, dtype=tf.float32)
        
        W = tf.get_variable('W', W_shape, initializer=initializer, dtype=tf.float32)
        b = tf.get_variable('b', b_shape, initializer=tf.constant_initializer(0.0), dtype=tf.float32)
        
        tf.add_to_collection("l2_loss", tf.nn.l2_loss(W)*lambda_)
        conv = tf.nn.conv2d(x, W, stride, padding='SAME')
        conv = tf.add(tf.nn.relu(conv), b)
    return conv
    
def max_pool(x, ksize = [1, 2, 2,1], stride = [1, 2, 2, 1]):
    """
    池化层
    """
    out = tf.nn.max_pool(x, ksize, stride, padding='SAME')
    return out

def fully_connect(x, scope, indim, outdim, stddev=5e-2):
    """
    全连接层
    """
    with tf.variable_scope(scope, reuse=tf.AUTO_REUSE) as scope:
        reshape = tf.reshape(x, [tf.shape(x)[0], -1])
        
        initializer = tf.truncated_normal_initializer(stddev=stddev, dtype=tf.float32)
        
        W = tf.get_variable('W', [indim, outdim], initializer=initializer, dtype=tf.float32)
        b = tf.get_variable('b', [outdim], initializer=tf.constant_initializer(0.0), dtype=tf.float32)
        tf.add_to_collection("l2_loss", tf.nn.l2_loss(W)*0.004)
        fc_out = tf.nn.relu(tf.matmul(reshape, W) + b)
    return fc_out

def build_model(images):
	"""
	构建完整模型 
	conv1->relu->pool->norm->conv2->pool->norm->fully1->relu->fully2->relu->softmax
	"""
    temp_conv = conv2d(images, "conv1", [5, 5, 3, 64], [64], stddev = 5e-2)
    temp_pool = max_pool(temp_conv, [1, 3, 3, 1])
    temp_norm = tf.nn.lrn(temp_pool, 4, bias=1.0, alpha=0.001/9, beta=0.75)
    temp_conv = conv2d(temp_norm, "conv2", [5, 5, 64, 64], [64], stddev = 5e-2)
    temp_pool = max_pool(temp_conv)
    temp_norm = tf.nn.lrn(temp_pool, 4, bias=1.0, alpha=0.001/9, beta=0.75)
    
    fc = fully_connect(temp_pool, 'fully1', 8*8*64, 256)
    fc = fully_connect(fc, 'fully2', 256, 10)
    out = tf.nn.softmax(fc)
    
    return out

def loss(y_pred, y):
    """
    损失函数
    """
    labels = tf.reshape(tf.one_hot(tf.reshape(tf.cast(y, dtype=tf.int64),[1, -1]), 10), [-1, 10])
    softmax_loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(logits=y_pred, 
                                                                             labels=labels))
    loss = tf.reduce_sum(tf.get_collection("l2_loss")) + softmax_loss
    #loss = softmax_loss
    return loss

def accuracy(y_pred, y):
    """
    准确率计算
    """
    y_pred = tf.argmax(y_pred, axis=1)
    y = tf.reshape(tf.cast(y,tf.int64),[1, -1])
    acc = tf.reduce_mean(tf.cast(tf.equal(y_pred,y), tf.float32))
    return acc

def train(train_dst, sess, epochs=100):
    """
    训练图
    """
    train_accs = []
    val_accs = []
    
    #占位符
    images = tf.placeholder(tf.float32, [None, 32, 32, 3])
    y_label = tf.placeholder(tf.float32, [None, 1])
    
    #模型
    model = build_model(images)
    y_pred = model
    #准确率
    acc = accuracy(y_pred, y_label)
    #损失函数
    losses = loss(y_pred, y_label)
    
    #优化器
    with tf.variable_scope('opt', reuse = tf.AUTO_REUSE):
        opt = tf.train.AdamOptimizer(learning_rate=0.0001, use_locking=True)
        train_step = opt.minimize(losses)
    
    #参数初始化
    init = tf.global_variables_initializer()
    sess.run(init)
    
    for epoch in range(epochs):
        #迭代训练
        print("****************epoch "+str(epoch))
        l = 0
        for (X, y) in train_dst:
            y_pred_ = sess.run(model, feed_dict={images:X})
            #print(y_pred.shape,type(y_pred))
            l = sess.run(losses,  feed_dict={images:X, y_label: y.reshape((-1, 1))} )
            
            train_acc = sess.run(acc, feed_dict={images:X, y_label: y.reshape((-1, 1))})
            train_accs.append(train_acc)
            
            sess.run(train_step, feed_dict={images:X, y_label:y.reshape((-1, 1))})
            
        print("loss: " +str(l))
        
        if epoch%1==0:
            
            print("train_acc: "+str(train_acc))
            val_acc = 0
            for i, (X_v, y_v) in enumerate(val_dset):
                
                y_pred_val = sess.run(model,feed_dict={images: X_v})
                val_acc += sess.run(acc, feed_dict={images: X_v, y_label: y_v.reshape((-1, 1))})
            val_acc /= (i+1)
            val_accs.append(val_acc)
            print("val_acc: "+ str(val_acc))
    #测试集
    test_acc = 0
    for i, (X_t, y_t) in enumerate(test_dset):
        y_pred_test = sess.run(model, feed_dict={images: X_t})
        test_acc += sess.run(acc, feed_dict={images: X_t, y_label:y_t.reshape((-1, 1))})
    test_acc /= (i+1) 
    print("%%%%%%%%%%%%% Test accuracy")
    print(test_acc)
    return train_accs, val_accs, test_acc

​ 运行计算图:

train_acc, val_acc, test = [], [], 0.0
with tf.Session(config=config) as sess:
    train_acc, val_acc, tes=train(train_dset, sess )

​ 训练过程中,模型在训练集的准确率变化如下:

在这里插入图片描述​ 在测试集的准确率为69.7%。

​ 可以看出,模型表现还是可以的(我们仅仅迭代了100次),但是距离完美还有很大差距。如果想要更好的结果,需要设计更复杂的网络结构,以及更加精细的调参过程。考虑到本文主要目的不是如何优化网络,而是为大家展示卷积网络原理,所以没有实现很复杂的网络。

7. 一些优化方法

​ 本部分内容主要是作者个人理解,仅仅是为了给读者说一下优化方法的概念,不必深究。如果需要深入了解,可以查阅相关论文。

​ 常用的优化方法有正则化(Regularization),批规范化(Batch Normalization),动量法(Momentum),Adam梯度下降法等。这里主要提一下正则化和规范化。

​ 所谓正则化,就是在损失函数里,考虑网络参数的复杂度(事实上,在我们刚刚的TensorFlow模型中已经用到了正则化)。举个简单的例子,如果你想要拟合一条曲线(图片来自网络):

在这里插入图片描述​ 假设你有数据集(X, Y),如上图蓝色圈所示(共N个数据)。你希望对这些数据,拟合一条曲线,如果你把拟合公式定义为:

y^=f(x)=w1x+w2x2+w3x3+w4x4+w5x5+b\hat y=f(x)=w_1x+w_2x^2+w_3x^3+w_4x^4+w_5x^5+b

​ 损失函数定义为:

loss=1Ni=1N(y^iyi)2loss=\frac{1}{N}\sum_{i=1}^N(\hat y_i-y_i)^2

​ 经过梯度下降法之后,得到红色的拟合曲线。这时候,曲线经过所有的样本点,loss为0,可以说,完美地达到了你的要求。但是,可达鸭眉头一皱,发现事情并不简单。

在这里插入图片描述

​ 即使没有机器学习基础,只学过初等数学的人,也知道“过拟合”(Over fit)的概念。没错!这里显然过拟合了!这些参数w1w5w_1-w_5过于“肆意妄为”,以至于得到一个很不靠谱的模型。

​ 正则化要做的,就是“约束”这些参数,防止模型过拟合。具体来说,加入正则化之后,损失函数变为:

loss=1Ni=1N(y^iyi)2+λj=15wj2loss = \frac{1}{N}\sum_{i=1}^N(\hat y_i-y_i)^2+\lambda \sum_{j=1}^5w_j^2

​ 上述公式的最后一项被称为L2正则化项(可以看作是对模型参数复杂度的度量),λ\lambda 需要人工选取。用新的损失函数,我们可以得到绿色的拟合曲线,这条曲线显然看起来更合理。

​ 那么,你可能会问,为什么正则化项的加入,会产生这么神奇的效果呢?先来看看,我们那条“过拟合”的红色曲线,这条曲线弯弯曲曲的,曲线各点的斜率,剧烈变化(斜率是导数):

f(x)=w1+2w2x+3w3x2+4w4x3+5w5x4f^{'}(x)=w_1+2w_2x+3w_3x^2+4w_4x^3+5w_5x^4

​ 在x相同的时候,w的值越大,斜率变化越复杂。因此,当加入正则化项之后,参数w趋向变小,从而拟合曲线的斜率变化趋于平缓,换言之,拟合曲线变得更加“平滑”,因此在很大程度上解决了过拟合问题。总结一下,正则化主要是,让参数W趋向于小值,让模型趋向于简单

​ 在深度神经网络中,正则化依旧是有用的。在深度神经网络中,除了上面提到的优点,正则化还趋向于让W参数趋于“分散”,即,不会让 W 矩阵的某个元素的绝对值过大或过小,而是趋向于,让W矩阵的每个元素,都对模型有差不多的贡献,从而可以在一定程度上,防止模型过拟合。

​ 下面,再来说一说批规范化(Batch Normalization)。批规范化,即每次输入一批数据后,先对数据进行处理,使数据均值为0,方差为1。在卷积神经网络中,批规范化一般用在激活函数之前(也可以在之后)。现在,一些论文研究了各种各样的规范化,除了批规范化(Batch Normalization)还有层规范化(Layer Normalization),组规范化(Group Normalization)等。虽然目前对规范化的作用机理有各种解释,但是从个人角度来看,大多数解释都有些牵强。个人角度看,规范化的主要作用是,强行稳定各层特征图的分布,使模型的训练更加容易,并且,批规范化解决了模型对参数初始化过于敏感的问题。如下图所示,蓝色表示“等高线”(损失函数值相等的线),这些等高线的中心表示最低点(损失函数最小的地方)。规范化之前,你的特征空间可能“又细又长”,梯度下降法的路径,可能如下图左所示,震荡幅度很大;规范化之后,由于各个方向的特征比较均衡,因此梯度下降更加稳定。

在这里插入图片描述

​ 关于深度神经网络的优化方法还有很多,比如Adam变步长算法,学习率衰减算法以及各种巧妙的初始化方法等。目前来看,深度神经网络的可解释性还比较差,并且,神经网络的损失函数是非凸的,非凸优化的求解难度很大,因为可能存在无数个局部最优点,而通常求解全局最优的复杂度是指数级。目前有研究表明,深度神经网络模型很复杂,因此存在足够多的局部最优点,而这些局部最优点接近全局最优,并且,由于深度神经网络的损失函数,维度很高,因此存在大量鞍点,所以总有一个梯度方向,能朝着一个合理的局部最优点前进。因此,只要模型结构设计地合理,训练方法得当,我们总能得到一个可接受的结果。

​ 总之,目前来看,深度神经网络,黑箱的成分很大,不仅没有足够的数学理论支撑,而且,即使得到了一个优秀的模型,也很难说清楚模型为什么表现的好。所以,如果你读论文比较多的话,就会发现,好多论文更像是得到好的实验结果之后,再去尝试为模型添加理论解释,而不是以理论突破来指导模型创新。

​ 添加各种优化算法之后的误差反传更加繁琐,感兴趣的读者可以自行推导(强烈不建议,我自己推过,还是很麻烦的),本文不再赘述。

8. 后记

​ 卷积神经网络,在图像领域有着广泛的应用,但是,目前可解释性较差,就像一个“黑箱”一样,虽然不断进化,人们却很难说清楚模型为什么表现地好。

​ 误差反传是深度神经网络的核心,也是最难推理的部分。虽然误差反传的基础是链式法则,但是当模型变得复杂的时候,试图推导出每一层的误差反传解析式极其困难。现在,大多数深度学习框架都用到了一种叫做“自动微分”(Automatic Differentiation)的技术,来进行误差反传的工作。对于大多数工程师而言,不需要关心误差反传的过程。自动微分的知识可以参见书籍Evaluating Derivatives: Principles and Techniques of Algorithmic Differentiation, Second Edition

​ 另外,关于深度学习框架,我也有一点想说。目前流行的深度学习框架,主推TensorFlow(Google)和PyTorch(Facebook)。TensorFlow大而全,但是对新手不是很友好,应该说,由于其使用静态计算图(static graph),先构建计算图,再计算,和普通的编程思路有所不同,大部分新手在学习TensorFlow的时候,会懵逼好一阵子。PyTorch对新手友好,使用动态计算图(dynamic graph),使用PyTorch编写代码和普通的python代码几乎没有区别,并且和numpy无缝结合(主要指张量和numpy.ndarray可以相互转换),特别方便。我的建议是,如果你之前没有用过任何深度学习框架,推荐你从PyTorch入手,当然,如果你是老手的话,就根据自己的需要选择吧。

​ 好了,本次内容分享就到这里了,谢谢大家。


参考资料:

[1] cs231n http://cs231n.stanford.edu/
[2] Coursera Deep learning https://www.coursera.org/specializations/deep-learning
[3] Deep Learning http://www.deeplearningbook.org/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章