一、概述
之前我们已经了解了普通的神经网络——使用前向传播和反向传播来进行训练。以MNIST数据集为例,在该网址中已经进行了推导,并得到了超过96%的准确率。但是由于其自身的缺陷,想要更进一步提高准确率很困难。这是因为虽然三层的神经网络可以逼近任意的函数,但是我们的输入不能表征样本的全部特征——因此无法在“任意函数”中找到最好的,只能在“任意函数”中找到最适合输入的。最适合输入的不一定是最好的,那怎么办?
我们来看一下普通的神经网络的输入,还是以MNIST为例,没法子,用的数据集太少了,所以例子也显得匮乏:普通神经网络是将28*28的图片变成784*1的一维向量,然后作为输入。现在问题来了:这个784*1的向量,其含有的信息量,和28*28的一样大么?当然不一样大。784*1的向量损失了空间信息。这就类似把一篇文章进行进行分词操作,得到的结果是一大堆词语——失去了情节和逻辑。而这是很重要的信息。
这就是三层神经网络的先天缺陷。如何在一定程度上弥补这种缺陷呢?保留“空间信息”咯,怎么保存呢?这就轮到CNN登场了。
阅读本文,你可以了解到:
常用的numpy函数的使用;
CNN的前向传播原理及实现;
CNN的反向传播原理及实现。
二、卷积、张量与池化
1、卷积
首先来想一想:什么叫空间信息?对于一个28*28的矩阵,空间信息可以是我M[i,j]=1,它四周八个元素为1,或者为0,这种状态可以称之为信息。如果把矩阵中的一个元素称之为一个像素,那么3*3的矩阵可以保存一个像素周围一小块的空间信息。要是空间信息大一点呢?用4*4,5*5?好吧,就算我保存3*3,4*4,5*5......这只是一个像素周围的二维空间信息,一共784个像素呢。我保存的怕不是个天文数字。退一万步说,即使我保存了这些像素由小块到大块的空间信息,有足够的空间去储存,那下一个问题:参数有多少?对于一个28*28的图片,用几万甚至几十万个参数去训练,那可能家里是挖矿的,算力溢出了。
现在可以看出来,如何表达空间信息,是一个大问题。CNN的核心,就是解决这个问题。怎么解决的呢?用卷积。我特想吐槽卷积这个名字,本科的时候学那个信号处理,那个卷积我理解了好久。然后现在又遇到了卷积,有点PTSD。在学了之后,发现完全不是那么回事。这里面的卷积,说叫卷积,实际叫加权求和也没什么问题(尽管我知道离散卷积就是加权求和)。使用这种卷积操作就能够保存一定量的空间信息。
卷积时,我们需要使用卷积核,这个卷积核,常见的形式为n*n的矩阵。卷积操作,就是让这个n*n的卷积核在m*m的图片上一点一点滑动,每滑动一次,就让m*m的图片上的n*n区域的元素和卷积核上的元素对应相乘,得到一个新的n*n的矩阵,然后将这个新的n*n的矩阵中的所有值相加,作为结果矩阵中的一个值。那么结果矩阵多大?结果矩阵的大小为(m-n+1)*(m-n+1)。
这个结果矩阵就保存着这张图片所有区域上的某个图像(空间)特征。结果矩阵中的元素越大,说明卷积核划过的对应区域该特征越明显,反之越不明显。
现在我们可以保存一个特征了,但是一个特征哪里够用啊,我想要更多的特征——那么就需要更多的卷积核。每个卷积核对应一个特征,卷积核越多,记录的空间特征就越多,效果可能就越好。
2、张量
假设我们有32个卷积核,每个卷积核都在输入的矩阵上游走一遍,那么就能得到32个结果矩阵,通常我们称结果矩阵为特征矩阵。也就是说,我们输入一个矩阵,得到32个特征矩阵。对这32个矩阵,我们有两种选择:第一种,32种特征够用了,我就把这个当做全连接的输入吧;这样当然可以。但是还有另外一种选择:32种特征不够,我想要更多。
更多?那你去增加卷积核啊,32变64,变128,特征就多了。可以这样做,但是我们要注意,这里增加卷积核,都是一个矩阵和一个矩阵做卷积,也就是说,采集的都是二维平面的信息,形象来说就是“报告,检测到该区域有强烈的曲线反应!”“报告,检测到该区域有微弱的直线反应!”“报告,检测到该区域有强烈的三西格玛形状反应!”——别去纠结着三西格玛是啥。我就想说一点,二维空间信息很有限,有限在哪里呢?我只能得到一种特征,因此输入和卷积核都是二维的。想综合得到两种或以上的信息,比如说“报告,检测到该区域有强烈的三角形反应,超强烈的毛茸茸反应,微弱的曲线反应!”那我可能就会想到这是一个小猫的耳朵。如何实现呢?
继续卷积。之前不是已经得到了32个特征矩阵么?把这32个特征矩阵合起来,当做一个大长方体,继续卷积。这怎么卷积啊?卷积核多大啊?
这样卷积:假设我们第一次的32个卷积核都是5*5,那么特征矩阵大小为(28-5+1)*(28-5+1)=24*24。32个特征矩阵组成的大长方体大小就是32*24*24,那么我的卷积核的高度也是32,长和宽可以再商量,但是高度一定是32。然后就让第一层的24*24的矩阵和第一层的n*n的卷积核进行卷积,进行一次卷积后,一层都会得到一个值,一共得到32个值,把这32个值加起来,再加上bias(如果有的话),作为新的特征矩阵的一个元素。
这个元素包含了32种特征的强弱,当然不能分辨出哪种更强——毕竟都加到一起了。但是已经够用了。这下子,形象来说就是“报告,‘长度,曲线,三角形,毛茸茸’检测器出现较强反应”,尽管我不能完全确定这是一个猫耳朵,因为三角形特征可能很弱,但是也比原来要好了。
现在我很贪心,我想还要32个卷积核,那么特征向量仍然是一个长方体。叫长方体太low了,不专业,我们连加权平均都改名叫卷积了,给长方体也取个牛逼的名字吧:张量。英文名?tensor。哦,狗家的TensorFlow就是张量流啊。张量的高,也有一个名字,就是通道。
现在我们知道了,第一层卷积,是二维张量和二维张量卷积,得到一个三维张量;第二层卷积,是三维张量和三维张量卷积,得到一个三维张量。之后还可以再卷积再卷积,都是三维张量。每次卷积后的张量,即卷积层的输出张量和卷积前的张量,即输入张量,各自有什么变化呢?
通常来讲,是越来越厚了。时刻记住一点:输出张量的“高”,仅与本层的卷积核数量有关;本层卷积核的高,仅与与输入张量的高有关。那么,我们会发现张量在走过一层一层之后,越来越细,越来越长;越来越细,越来越长。
为什么会变长?第一个是因为卷积核可能越来越多,第二个则是因为,卷积核的高度可能和输入张量的高不一致——我仅仅需要其中几个特征做卷积,那么就会有更多的排列组合,得到的特征矩阵就更多。
为什么会变细?正常来讲,卷积之后肯定会变细的,因为输出矩阵的大小是(m-n+1)*(m-n+1)嘛。这种变细有的时候是好事,因为减少了参数,有时候不是好事,因为丢失了边缘信息。如何防止张量变细呢?padding。什么意思?就是在输入的张量周围包上一圈0,比如说我输入的张量是一个28*28的矩阵,卷积核3*3,本来我的输出矩阵是26*26,但我还想要28*28,怎么办?在28*28四周包上一圈0,变成30*30,那么再卷积,得到的就是28*28了。
3、池化
经过第二层,假设第二层卷积有32个3*3卷积核,那么得到的就是一个32*22*22的张量。得到这个张量好费劲啊,每个元素都是32*3*3的张量和32*24*24的张量卷积得到的。算起来太慢了。能不能减少数据啊,CPU要累死了,我心疼电费。
首先纠正一个错误,用CPU跑CNN不是一个好主意,用GPU更好,老黄家的卡有cuda,对张量计算有奇效。但是才开始学CNN,可能没那么好的显卡,所以暂时就用CPU吧。这么慢那就肯定要减少数据量了。怎么减少?池化。
池化是啥?池化就是选一个池子,用一个元素代表池子中的所有元素。如果是最大池化,那就是养蛊,选择的元素是池子中最大的;如果是平均池化,那就是众生平等,选择池子中所有元素的平均值作为代表元素。选出代表元素之后呢?用所有代表元素组成的新张量代替原来的张量啊。
不是,等会,车开的有点快。怎么就代替了呢?这样来看,我现在有一个32*24*24的张量,是第二层的输入。现在我设置池化的池子为2*2大,那么对于每个24*24的矩阵,有12*12个池子,每个池子选一个代表元素,就把24*24减小到了12*12——原来的张量就从32*24*24变成了32*12*12,数据量缩小了足足四倍,太好用了。
为什么可以这么选呢?按我的理解,张量储存的也是空间信息,相邻的张量储存的空间信息,对应到原来的图片上,是两小片区域,而且离得很近,那么从更广的视野看,它们间的特征也应该有联系,在大体上相差应该不大。那么储存四个,不如储存一个。这就是池化了。
三、前向传播和反向传播
本文代码主要来自该网址。目前纯python,不用pytorch或者tf来写CNN的是在太少了,找了好久才找到一份。然后看代码看了一晚上才全看懂。从上述网址可以得到CNN的结构:卷积层→池化层→卷积层→卷积层→池化层→全连接层→softmax。
1、前向传播
相比于反向传播,前向传播简直人畜无害的简单。前向传播有三种情况:
输入、池化→卷积,卷积→池化,池化→全连接。
①输入、池化→卷积
CNN核心之一。主要有两种情况,其一是二维张量和二维张量卷积,得到二维张量;其二是二维张量和三维张量卷积,得到二维张量。我们设输入的张量为X,卷积核为,大小为n*n(三维张量为h*n*n),输出的张量为Z。
则对于二维张量情况,
,
三维张量情况,
。
②卷积→池化
本文所参考的使用maxpooling,即最大池化。从张量的每一层中划分池子,分别取最大值即可。
③池化→全连接
也很简单,先把池化层输出的张量展开为一维向量,然后输入全连接层即可。
2、反向传播
建议这部分配合该网址食用。
这里我有必要重申一下反向传播的特点。我们反向传播,是为了更新参数。参数更新的幅度怎么算?
这里,是学习率,我们事先指定,是与相连的输入值。关键就是后面那个偏导数。上面的E是loss function,也就是损失函数,因此,某个参数的更新量,就等于学习率*损失函数对该参数的偏导,也就等于学习率*损失函数对该层对应输出的偏导*输出对参数的偏导。
由于偏导的链式传播的特点,无论l层和l-1层是卷积啊,池化啊,什么的乱七八糟的,只要我们设为l+1层的输出,为l层的输出,那么就有
这意味着什么呢?意味着我求任意一个参数的更新量,都可以通过求该层输出的偏导,乘以该层输出对该参数的偏导得到。这就很方便。由于这个偏导是在是太重要,因此就将误差函数对某节点输出值的偏导称之为该节点的“敏感度”,记为。
总结起来,我想更新参数,就得知道参数的更新量,想知道参数的更新量,就得知道偏导,误差函数对本层的输出的偏导。然后,得知道上一层的偏导......输出层的偏导。
因此,在反向传播时,对第l层,有两件事要做:
第一件,根据l层输出的偏导,计算出l层参数的更新量;
第二件,根据l层输出的偏导,计算出l-1层输出的偏导。
也有三种情况:全连接→全连接,全连接→池化,池化→卷积,卷积→池化。
①全连接→全连接
太简单了不说了。
②全连接→池化
同上。
③池化→卷积
得坐起来了,这个是反向传播的核心之一。来看:假设我们的池化层为32*12*12,卷积层为32*24*24,我们怎么得到卷积层的偏导?为什么不问池化层的参数更新?来来来,话筒给你,我问你池化层有什么参数?池子大小?那是超参数,反向传播改不了,得交叉验证的时候才可能改,也没见改这个的。也就是说,问题就是怎么更新池化层前一层的偏导。
怎么更新呢?这个不是个数学问题,这是一种规定:
我们考察一下就可知,池化层前一层的偏导,个数比池化层要多,相当于一次反池化操作,由小扩大。由于池化又称为下采样,那么反池化就称为上采样。怎么采?根据池化方式的不同,有两种手段:
对于maxpooling,记录下maxpooling时候池子中的最大值,在上采样的时候,一个元素自己展开为一个池子,在这个池子中,maxpool时候的最大值的位置,此时是池化层输出的偏导,其余为0。
对于averagepooling,同样是一个元素a展开为一个池子,池子中每个元素都为a/(n*n),也就是把元素平均分到池子的每个元素中。
④卷积→池化
坐着也说不明白了,我站起来吧。这是整个CNN中最难的一步。来看:假设我们卷积层的输出是一个52*10*10的张量,卷积核为52*26*3*3的张量,输入为26*12*12的张量,怎么求?
说实话,我看见这几个张量我都晕了:这啥啊?这怎么还有四维张量啊?怎么卷积啊?看上面正向传播那个单薄的式子也看不出啊。
那就先来看正向怎么算的:
输入为26*12*12的张量,卷积核为52*26*3*3的张量,别这么看卷积核,这么看难理解。你把卷积核看成一个个小盒子(三维张量),那么就是52个高(通道数)为26,长和宽都为3的小盒子。然后每个小盒子和输入的张量做一次卷积,得到一张大小为10*10的纸,52个卷积核就做52次卷积,得到52张纸。这52张纸叠起来,组成一个52*10*10的张量。
这下清楚多了吧。
挺清楚的。那反过来怎么做呢?
这可得慢慢说。我们挑出l层输出的这个张量,52*10*10,它的每一张纸,10*10,对应一个卷积核和输入张量的卷积。那么卷积层的第一个任务:更新参数就很简单了。我现在已经有了卷积层输出的值,也就是这些张纸,我更新第h个卷积核,就需要找到第h张纸,让它对第h个卷积核中的每个值求偏导即可。
那就求一下吧:
根据前向传播,我们直接看第一层坐标为(1,1)这个地方的输出:
差点给我写吐了。
计算一个输出值,就这么麻烦。那么让E对第一个卷积核的(1,1,1)求偏导,即对求偏导,就需要E对z的第一层的每个值求偏导,然后第一层的每个z对求偏导。从上式可以得到 ,即,但这不够,我们还需要......乃至。这些偏导乘上对应的“E对z”求偏导,才是我们要的“E对第一个卷积核的(1,1,1)求偏导”。
这麻烦炸了。这一个卷积核的偏导,要求10*10个偏导,能不能弄出一个直观的形式啊?
试试吧。第一个是,第二个是,然后是...;接下来...;......;...。这是第一层。那么还得乘上对应的“E对z”的偏导呢:
,.........。这不就是输入张量,那个52*12*12的第一张纸对输出偏导张量,那个52*10*10,的第一张纸,求卷积的第一个值么。
你要这么说我可就不困了。那按这么说,卷积核第二层的第一个值是两个张量的第二张纸求卷积的第一个值......第二十六层也一样。哦,那,“E对第一个卷积核的坐标为(h,i,j)处的参数求偏导”,等于输入张量的第h层与偏导张量的第1层求卷积的(i,j)处的值。
好起来了。那么整体的第一个卷积核的偏导,它是一个张量,应该等于输入张量每层分别与偏导张量求卷积!来看看尺寸对不对:
输入张量一层是12*12,偏导张量一层是10*10,求卷积得到3*3,正好是卷积核一层的大小。没错了。
这样我们就知道了卷积核的偏导该如何求。
接下来看卷积层的第二个任务:求上一层的偏导。简而言之就是求,根据链式法则,我们要求,就要求出所有的,这个元素中含有。又需要用到上面那个巨长无比的式子了:
我们现在想求,那么,就得找到所有用到的z。都有哪个z用到了呢?回想一下前向传播的过程:a的第一层乘以卷积核的第一层...a的第二十六层乘以卷积核的第二十六层,相加,得到一层输出z。也就是说,每一层的z,都有a的第一层的参与。那究竟是每层z的哪一个值呢?,只有它在计算的时候用到了。这有点难想,z每层的(1,1),是由a所有层的(1,1)...(3,3)和卷积核所有层的(1,1)...(3,3)卷积出来的,因此用到了,它不但用到了,还有......。那么我们就要求,也就是......。
好麻烦啊!!!!!!
,...,。
然后也得乘,,...,。这些玩意再相加。
等一下,好像有点眼熟。这操作,好像是某种卷积的一部分?是偏导张量(52*10*10)的第一层(10*10)分别与每个卷积核的第一层(3*3)卷积,得到52个张量,它们全加起来,得到的就是我们要的“E对上一层偏导”的第一层么?不对,应该在偏导张量外面包上0,否则尺寸不对了。来看一下,偏导张量是(10*10),卷积核是(3*3),那么(10*10)与(3*3)卷积,得到的不可能是(12*12),要想得到(12*12),得在(10*10)外面包上两圈0,变成(14*14)。但是在外面包上0,那就没法和对应了啊,对应的是。这可怎么办。真令人头大。
接着往下走一步吧。我们可以简单想一下:用到了,对于第h层,只有用到了,这很好。但是呢?用到了,也用到了,而且,也就是说,想找用到的z,要比多一倍:
,,......,,。
然后也得乘,,...诶?又不对劲了啊。按我之前说的,偏导张量先padding,然后分别与每个卷积核卷积,那么应该是,,和我们上面推的完全对不上。
我们得相信我们的推导:
,这俩搭配求卷积是对的;
,,这搭配求卷积也是对的。
那么问题来了。这个(14*14)与(3*3)卷积,怎么能让上面这个相乘呢?你第一个卷积,(14*14)就左下角有个值,难不成你跑到左下角去了?诶?如果真是跑到左下角,那第二个卷积中,正好和相乘啊,那就转到第三行的第二个去了?
也就是说,这个(3*3)它转了180°,就是原来(1,1)变成(3,3),(1,2)变成(3,2),(1,3)变成(3,1)。然后再卷积。
成了,破案了。那么整体对卷积层的上一层求偏导,应该是“上一层的第h层=偏导矩阵的第i层与第i个卷积核的第h层转180°后求卷积,再对所有卷积后的结果求和”。
再用大小看一看:上一层的第h层是12*12,偏导矩阵的第i层是10*10,padding后是14*14,第i个卷积核的第h层是3*3,卷积后是12*12,偏导矩阵共52层,一共52个卷积核,都对应得上。没问题了。
大功告成。
四、代码实现
代码来自该网址,本章主要对其代码进行分析。
为了使流程清晰,将CNN的各层分开为卷积层、sigmoid层、ReLu层、池化层、全连接层、softmax层。使用CNN类总体掌控网络结构和传播流程。各层分别有对应的初始化方法、前向传播方法和反向传播方法。
1、卷积层
卷积层类是最核心也是最复杂的一个类了。
初始化方法:
class Conv_2D():
#卷积层类
def __init__(self, input_dim, output_dim, ksize=3,
stride=1, padding=(0,0), dilataion=None):
self.input_dim = input_dim
self.output_dim = output_dim
self.ksize = ksize
self.stride = stride
self.padding = padding #(1,2) 左边 和 上边 填充一列 右边和下边填充2列
self.dilatation = dilataion
self.output_h = None
self.output_w = None
self.patial_w = None
# 产生服从正态分布的多维随机随机矩阵作为初始卷积核
# OCHW
# self.conv_kernel = np.random.randn(self.output_dim, self.input_dim, self.kernelsize, self.kernelsize) # O*I*k*k
self.grad = np.zeros((self.output_dim, self.ksize, self.ksize, self.input_dim), dtype=np.float64)
# 产生服从正态分布的多维随机随机矩阵作为初始卷积核
self.input = None
# OCh,w
self.weights = np.random.normal(scale=0.1,size= (output_dim, input_dim, ksize, ksize))
self.weights.dtype =np.float64
self.bias = np.random.normal(scale=0.1,size = output_dim)
self.bias.dtype = np.float64
self.weights_grad = np.zeros(self.weights.shape) # 回传到权重的梯度
self.bias_grad = np.zeros(self.bias.shape) # 回传到bias的梯度
self.Jacobi = None # 反传到输入的梯度
输入参数中,input_dim为输入矩阵的维数;output_dim为输出矩阵的维数;ksize为卷积核的大小,缺省则为3;stride为卷积核一次滑动的长度,缺省则为1,;padding为在左右和上下分别添加几行元素;dilataion没有用到。
该方法主要负责初始化类内的各个变量。主要关注以下几个变量即可:self.weights,为卷积核,是一个张量,维度为(输出维数,输入维数,卷积核大小,卷积核大小);self.bias,偏移量。这两组值均使用正态分布进行初始化。self.weights_grad和self.bias_grad用来保存损失函数对卷积核元素和偏移量的偏导数。self.Jacobi用来保存上一层的偏导数。
前向传播方法:
def forward(self, input):
'''
:param input: (N,C,H,W)
:return:
'''
assert len(np.shape(input)) == 4
input = np.pad(input, ((0, 0), (0, 0), (self.padding[0], self.padding[1]),
(self.padding[0], self.padding[1])), mode='constant', constant_values=0)
#np.pad填充函数,在张量周围填充数值。
#第一个参数为input,表述待填充的矩阵,之后会有n个整数对。
#因为这里是四维张量,需要在最后两个维度上添加0,因此n=4
#第一、二个整数对(0,0),表示在两个维度上不加。第三个(a,b)表示在纸张的上方添加a行,在纸张的下方添加b行;第四个整数对(m,n)表示在纸张的左边添加m列,纸张的右边添加n列
#mode表示添加的行列中元素如何指定,这里是添加常数,常数通过后面的参数constant_values指定,可以是一个参数,那么就是全填充这一个
#也可以是一个参数对(x,y),那么就在上方和左边填充x,在下方和右边填充y
self.input = input
self.Jacobi = np.zeros(input.shape)
N, C, H, W = input.shape#N为纸张的数量,C为纸张的厚度,H为纸张的长度,W为纸张的宽度
# 输出大小
self.output_h = (H - self.ksize) / self.stride + 1#卷积结果,长度为(纸张长度-卷积核长度)/移动步长+1,宽度同理
self.output_w = (W - self.ksize ) / self.stride + 1
# 检查是否是整数
assert self.output_h % 1 == 0
assert self.output_w % 1 == 0
self.output_h = int(self.output_h)
self.output_w = int(self.output_w)
imgcol = self.im2col(input, self.ksize, self.stride) # (N*X,C*H*W)
#该函数用于把张量转为二维矩阵,便于计算,input为50*1*28*28的张量,而imgcol为28800*25的矩阵,矩阵每一行为卷积所需的元素
#每张28*28的图片要卷积24*24次,50张图片要卷积50*24*24次,共28800次,因此有28800行
output = np.dot(imgcol,
self.weights.reshape(self.output_dim, -1).transpose(1, 0)) # (N*output_h*output_w,output_dim)
#weight为卷积核,共26个卷积核,是26*1*5*5的张量,也要转换成矩阵,矩阵的每一列是一个卷积核,
#这样,imgcol的行乘上weights的列,就完成了一次卷积。有26个卷积核,因此做了26次卷积,得到的结果中,每个元素都是一次卷积运算的结果
#每列是相同的卷积核的结果,每行是不同的卷积核的结果,结果为28800*26,因此有26个卷积核得到结果
output += self.bias
output = output.reshape(N, self.output_w * self.output_h, self.output_dim). \
transpose(0, 2, 1).reshape(N, int(self.output_dim), int(self.output_h), int(self.output_w))
#矩阵转换为张量,结果是50*26*24*24,每张纸都变厚了26倍,说明经过了26个卷积核的卷积
return output
函数的具体应用见注释。
反向传播方法:
def backward(self, last_layer_delta,lr):
'''
计算传递到上一层的梯度
计算到weights 和bias 的梯度 并更新参数
:param last_layer_delta: 输出层的梯度 (N,output_dim,output_h,output_w)
:return:
'''
def judge_h(x):
if x % 1 == 0 and x <= self.output_h-1 and x >= 0:
return int(x)
else:
return -1
def judge_w(x):
if x % 1 == 0 and x <= self.output_w - 1 and x >= 0:
return int(x)
else:
return -1
# 根据推到出的公式找出索引与卷积权重相乘
# mask用于得到每次卷积所需的敏感度矩阵
for i in range(self.Jacobi.shape[2]): # 遍历输入的高
for j in range(self.Jacobi.shape[3]): # W
mask = np.zeros((self.input.shape[0], self.output_dim,
self.ksize, self.ksize)) # (N,O,k,k)
index_h = [(i - k) / self.stride for k in range(self.ksize)]
index_w = [(j - k) / self.stride for k in range(self.ksize)]
index_h_ = list(map(judge_h, index_h))
index_w_ = list(map(judge_w, index_w))
for m in range(self.ksize):
for n in range(self.ksize):
if index_h_[m] != -1 and index_w_[n] != -1:
mask[:, :, m, n] = last_layer_delta[:, :, index_h_[m], index_w_[n]] # (N,O,1,1)
else:
continue
#mask升维,由50*52*3*3变为50*1*52*3*3
mask = mask.reshape(self.input.shape[0], 1, self.output_dim, self.ksize, self.ksize)
Jacobi_t=mask * self.weights.transpose(1, 0, 2, 3)
Jacobi_s_t=np.sum(Jacobi_t, axis=(2, 3, 4))
self.Jacobi[:, :, i, j] = Jacobi_s_t
#去掉padding
self.Jacobi = self.Jacobi[:, :, self.padding[0]:self.input.shape[2]-self.padding[1],
self.padding[0]:self.input.shape[3] - self.padding[1]]
# 计算 w
N,C,K,H,W = self.input.shape[0],self.input.shape[1],self.ksize**2,self.output_h,self.output_w
tmp = np.zeros((N,C,K,H,W))
for i in range(self.ksize):
for j in range(self.ksize):
#取出和对应位置相乘得数组
tmp[:,:,i*self.ksize+j,:,:] = self.input[:, :,i:self.output_h + i:self.stride, j:self.output_w + j:self.stride]
# print(tmp.shape)
tmp_new = np.sum(last_layer_delta.reshape(N,self.output_dim,1,1,H,W)*tmp.reshape(N,1,C,K,H,W),axis=(4,5)) #(N,O,C,K)
# print(tmp_new.shape)
self.weights_grad = np.sum(tmp_new.reshape(N,self.output_dim,C,self.ksize,self.ksize).transpose(1,2,0,3,4),axis=2) #(O,C,ksize,ksize)
# # 计算bias的梯度
tmp_bias = np.sum(last_layer_delta, axis=(2, 3))
self.bias_grad = np.sum(tmp_bias, axis=0)
tmp_bias = np.sum(last_layer_delta, axis=(2, 3))
self.bias_grad = np.sum(tmp_bias, axis=0)
self.update(lr)
return self.Jacobi
反向传播中,最麻烦的就是如何确定公式中的张量了。我们没有现成的方法,因此只能用矩阵对应元素相乘来做。在求上一层的偏导的时候,我们需要mask,敏感度矩阵,由于卷积变为了对应元素相乘,我们需要的敏感度矩阵大小要和卷积核大小一样,这样执行相乘操作,然后将一层的所有值加在一起,就可以看成是一个卷积操作。说起来容易,如何在一层大的敏感度矩阵中找到我们需要的小的,这个好麻烦。
在找到适当的mask之后,我们来看一下mask和weight的尺寸。
mask为50*52*3*3,这里的第一个50,是mini batch GD带来的,我们如果仅看一个样本,那就是52*3*3。也就是要进行卷积的恰当大小的敏感度矩阵。weight为52*26*3*3。有很重要的一步,对weight执行transpose(1, 0, 2, 3),维度变换,它的效果是什么呢?是将weight变为26*52*3*3,变成26个小箱子,每个小箱子高度是52,是由原来52个小箱子中的同一层得到的——那么每个小箱子和mask相乘,得到一个52*3*3的新的张量,将这个张量所有元素相加,就是一个上一层的敏感度。一共有26个小箱子,那么会得到26个数。然而直接相乘是不行的,为了防止混淆,就需要将mask升维,给50和后面的52*3*3分开,在其中多加一个维度,作为分隔符。注意一点:维度变换可以看成是张量没变,你理解或者是观察它的方式发生了变化。配合该网址食用。
这个多加一个维度,实际效果是什么呢?有些难以理解。参见本节最后的张量计算技巧。
然后再累加得到26个数,填入前一层的敏感度即可。每经过一个这样的流程,可以得到26个数,一共有26*12*12个数,因此每次是更新一束,一共要更新12*12束。
另外它这个实现方法很奇特,不是将weights旋转180°,而是将mask旋转180°。
然后需要确定卷积核如何更新:这次我们要从input中选取恰当的值,然后与输出的敏感度相乘即可。注意这里没有需要调转180°的了,因此实现起来比上面简单了不少。最后更新即可。
2、池化层
池化层类是另一个核心类。
初始化方法:
class max_pooling_2D():
def __init__(self,input_dim =3,stride = 2,ksize=2,padding=0):
'''
:param input_dim:
:param stride:
:param padding: padding数量
'''
self.input_dim = input_dim
self.input = None
self.output = None
self.stride = stride
self.ksize = ksize
self.padding = padding
self.record = None #记录取元素的位置
self.Jacobi = None
没什么好说的,就是需要使用的变量。
前向传播方法:
def forward(self,input):
'''
最大池化是找到2*2共四个元素中最大的作为池化后的代表值
:param input: (batchsize,c,h,w)
:return:
'''
assert len(np.shape(input)) == 4
self.record = np.zeros(input.shape)
#padding
input = np.pad(input, ((0, 0), (0, 0), (self.padding, self.padding),
(self.padding, self.padding)), mode='constant', constant_values=0)
self.input = input
#
input_N, input_C, input_h, input_w = input.shape[0], input.shape[1], \
input.shape[2], input.shape[3]
# padding 操作
#确定输出的张量的尺寸,默认是2*2池化,张量尺寸n,c,h,w,则池化后第一第二维不变,纸张的尺寸变化
#输出为50*26*12*12
output_h = int((input_h - self.ksize + 2*self.padding) / self.stride + 1) #padding 操作
output_w = int((input_w - self.ksize + 2 * self.padding) / self.stride + 1)
output = np.zeros(((int(input_N),int(input_C),int(output_h),int(output_w))))
for n in np.arange(input_N):
for c in np.arange(input_C):
for i in range(output_h):
for j in range(output_w):
#(batchsize,c,k,k)
x_mask = input[n,c,i*self.stride:i*self.stride+self.ksize,
j*self.stride:j*self.stride+self.ksize]
# print(x_mask)
# print(np.max(x_mask))
# print(output[n, c, i, j])
output[n,c,i,j] = np.max(x_mask)
self.output = output
return output
按部就班,先初始化输出张量,计算它的尺寸。然后划池子,找出最大值,放在输出张量的对应位置。
反向传播方法:
def backward(self,next_dz):
'''
:param next_dz: (N,C,H,W)
:return:
'''
self.Jacobi = np.zeros(self.input.shape)
N, C, H, W = self.input.shape
_, _, out_h, out_w = next_dz.shape
for i in range(out_h):
for j in range(out_w):
#print(self.input[:,:, i * self.stride:i * self.stride + self.ksize,j * self.stride:j * self.stride + self.ksize].shape)
# print(input[n, c, i * self.stride:i * self.stride + self.ksize,j * self.stride:j * self.stride + self.ksize].shape)
flat_idx = np.argmax(self.input[:,:,i*self.stride:i*self.stride+self.ksize,
j*self.stride:j*self.stride+self.ksize].reshape(N,C,self.ksize*self.ksize),axis=2)
h_idx = (i*self.stride +flat_idx//self.ksize).reshape(-1) #(N*C) 确定行位置
w_idx = (j*self.stride +flat_idx%self.ksize).reshape(-1) #确定列位置
for k in range(N*C):
self.Jacobi[k//C,k%C,h_idx[k],w_idx[k]] = next_dz[k//C,k%C,i,j] #对应回原来位置
# self.Jacobifor k in range(N*C)
# self.Jacobi[, c_list, h_idx.reshape(-1),w_idx.reshape(-1)] = next_dz[:,:,i,j]
# 返回去掉padding的雅可比矩阵
return self.Jacobi[:,:,self.padding:H-self.padding,self.padding:W-self.padding]
反向传播,也就是上采样,需要输入的张量中池子中最大值的坐标,这也是为什么我们要记录用变量记录输入。流程是:初始化输出张量,根据输入张量的最大值位置,将敏感度张量中的值放到对应位置。
3、softmax层
class softmax():
def __init__(self):
self.output = None
self.input_delta = None #记录计算过程的雅可比矩阵
self.Jacobi = None #反传到输入的雅可比矩阵
def forward(self,input):
'''
:param input: (batchsize,n) np数组
:return:
'''
batch_size = input.shape[0]
#n = input.shape[1]
self.Jacobi = np.zeros(input.shape)
self.input_delta = np.zeros(input.shape)
x = np.exp(input)
y = np.sum(x,axis=1).reshape(batch_size,1)
output = x/y
self.output = output
return output
def backward(self,last_layer_delta):
'''
:param last_layer_delta: (N,n)
:return:
对softmax的输入Zi求偏导,需要E对softmax的输出a求偏导的向量和a对Zi求偏导的向量。前者为last_layer_delta,要求后者
当ai对Zi求偏导时,结果为ai(1-ai),当ai对Zj求偏导时,结果为-aiaj
'''
for n in range(last_layer_delta.shape[1]): #遍历 n
tmp = -(self.output*self.output[:,n].reshape(-1,1))
#numpy中,[:,n]代表选取第n列,reshape(-1,1)表示将结果转换为1列
#tmp中储存-a0*an,-a1*an,-a2*an......-am*an
tmp[:,n]+=self.output[:,n]
#tmp中储存a0-a0*an,-a1*an,-a2*an......-am*an
#tmp现在就是后者
self.Jacobi[:,n] = np.sum(last_layer_delta*tmp,axis=1)
# batchsize = last_layer_delta.shape[0]
# n = last_layer_delta.shape[1]
return self.Jacobi
主要重点在于前向传播的公式和反向传播的公式,推导之后实现就很简单了。
softmax求偏导的推导过程参见该网址。
4、CrossEntropy层
class CrossEntropy():
def __init__(self):
self.loss = None
self.Jacobi = None
def forward(self,input,labels):
bachsize = input.shape[0]
loss = np.sum(-(labels * np.log(input) + (1 - labels) * np.log(1 - input)) / bachsize)
self.loss = loss
"""
疑似有问题
self.Jacobi = -(labels / input - input * (1 - labels) / (1 - input)) / bachsize
"""
self.Jacobi = -(labels / input - (1 - labels) / (1 - input)) / bachsize
#因为是批处理梯度下降,因此要除以bachsize
return loss
def backwards(self):
return self.Jacobi
抓到原始代码的一个小bug。对交叉熵求偏导的时候多乘了一个input。
注意我们使用mini batch进行梯度下降。batch取值50。那么求偏导值时别忘了除以这个batch。我最开始有点晕:为什么要在这里除以batch啊,你矩阵中一个元素不是对应一个样本的偏差么,不应该除。在这里,如果我将矩阵中元素当做是样本的偏差的话,在后面计算的时候,比如计算某个参数的偏导数,我会把50个样本求得的偏导数都加起来,之后除以50作为我们要的偏导数。也就是说,无论先除还是后除,早晚得除。在最开始除完之后,就不用在后面再除了,省了很多事。
5、网络pipeline
我们在使用sklearn的时候,有很好用的pipeline,来帮助我们清晰的建立起整个网络的流程。这里没有那么好用的工具。因此需要我们自己一步一步设计网络的各层。如下:
class CNN_Nets():
def __init__(self,lr=0.0001,batchsize=10):
'''
初始化神经网络,类似pipeline,卷积-池化-卷积-卷积-池化-全连接-softmax-输出
'''
self.lr = lr
self.bachsize = batchsize
#第一个卷积层
self.conv1 = Conv_2D(input_dim=1,output_dim=26,ksize = 5,stride = 1, padding =(0,0)) #(24,24)
self.Relu_1 = Relu()
self.maxpooling_1 = max_pooling_2D(input_dim=26,stride=2,ksize=2) #(12,12)
self.conv2 = Conv_2D(input_dim=26,output_dim=52 ,ksize = 3, stride = 1, padding= (0,0)) #(10,10)
self.Relu_2 = Relu()
self.conv3 = Conv_2D(input_dim=52, output_dim=10, ksize=1, stride=1, padding=(0, 0)) # (10,10) #降维
self.Relu_3 = Relu()
self.maxpooling_3 = max_pooling_2D(input_dim=52,stride=2,ksize=2) #(5,5)
self.fc_1 = Linear(input_num=5*5*10,output_num=1000)
self.sigmoid_1 = sigmoid()
self.fc_2 = Linear(input_num=1000,output_num=10)
self.softmax = softmax()
self.CrossEntropy = CrossEntropy()
self.outut = None
self.loss = None
self.Jacobi = None
def forward(self,input,labels):
'''
:param input: (n,c,h,w)
:param labels: (batchsize,10) 的one_hot编码
:return:
'''
N,C,H,W = input.shape
#50,1,28,28
#卷积层1
output = self.conv1.forward(input)
#50,26,24,24
output = self.Relu_1.forward(output)
#50,26,24,24
output = self.maxpooling_1.forward(input=output)
#卷积层2
#50,26,12,12
output = self.conv2.forward(output)
#50,52,10,10
output = self.Relu_2.forward(output)
#50,52,10,10
output = self.conv3.forward(output)
#50,10,10,10
output = self.Relu_3.forward(output)
#50,10,10,10
output = self.maxpooling_3.forward(input=output)
#卷积层3
#50,10,5,5
#第一个全连接层
output = np.reshape(output,(N,10*5*5))
#50,250
output = self.fc_1.forward(output)
#50,1000
output = self.sigmoid_1.forward(output)
#50,1000
#第二个全连接层
output = self.fc_2.forward(output)
#50,10
output = self.softmax.forward(output) #(batchsize,10)
#50,10
self.output = output
#50,10
#计算交叉熵和反传梯度
self.loss = self.CrossEntropy.forward(output,labels) #交叉熵
def backward(self):
grad = self.CrossEntropy.Jacobi#50,10
#这一步的grad求出的是E对softmax中的偏导,可以看成是输出层的误差或输出层的敏感度
grad = self.softmax.backward(grad)#50,10
grad = self.fc_2.backward(grad,lr =self.lr)#50,1000
grad = self.sigmoid_1.backward(grad)#50,1000
grad = self.fc_1.backward(grad,lr = self.lr)#50,250
grad = grad.reshape(self.bachsize,10,5,5) #重新恢复成图像,50,10,5,5
grad = self.maxpooling_3.backward(grad)#50,10,10,10
grad = self.Relu_3.backward(grad)#50,10,10,10
grad = self.conv3.backward(grad,lr= self.lr)#50,52,10,10
grad = self.Relu_2.backward(grad)#50,52,10,10
grad = self.conv2.backward(grad,lr=self.lr)#50,26,12,12
grad = self.maxpooling_1.backward(grad)#50,26,24,24
grad = self.Relu_1.backward(grad)#50,26,24,24
grad = self.conv1.backward(grad,lr=self.lr)#50,1,28,28
return grad
这样,网络的前向传播和反向传播都可以很清晰的理解了,在调用的时候也很方便。注意到反向传播中,下一个函数的输入是上一个函数的输出,完美的对应了反向传播的特点。这对于理解网络也很有好处。
在最开始读代码的时候,可能会感觉比较难懂,这时将各个函数的输入张量和输出张量写下来并对照理解,对于理解函数的工作过程十分有帮助,不妨一试。
6、张量计算技巧
让我们以下面的代码作为示范:
import numpy as np
array1 = np.array([[[1, 1],[2,2]],[[3,3],[4,4]]])
array2 = np.array([[[1, 1],[2,2]],[[3,3],[4,4]]])
#array1=array1.reshape(2,1,2,2)
array3=array1*array2
print(array3)
print(array3.shape)
array1=array1.reshape(2,1,2,2)
array3=array1*array2
print(array3)
print(array3.shape)
最开始,array1和array2是两个标准的三维张量,2*2*2,看作是两个立方体。相乘之后得到一个立方体——大小不变,也就是对应层相乘,结果为:
现在我把array1升维,变成2*1*2*2,由一个立方体变成两张纸,那么相乘之后得到两个立方体,结果是一个四维张量,2*2*2*2。第一个立方体是array1的第一张纸分别与array2的两层分别相乘,第二个立方体第二张纸,结果如下:
也就是说,加上这额外的一维之后,相当于由各层对应相乘,变为了一层对应多层。那如果不是一张纸呢?比如说我是四维升一维,那结果怎么看?看下面的代码:
import numpy as np
array1 = np.array([[[[1, 1],[2,2]],[[3,3],[4,4]],[[5,5],[6,6]],[[7,7],[8,8]]],[[[9, 9],[10,10]],[[11,11],[12,12]],[[13,13],[14,14]],[[15,15],[16,16]]]])
array2=array1
print(array1.shape)
print(array1)
array3=array1*array2
print(array3.shape)
print(array3)
array1=array1.reshape(2,1,4,2,2)
array3=array1*array2
print(array3.shape)
print(array3)
可以看出,array1和array2都是2*4*2*2的张量。那么,两个这样的张量相乘,结果为:
可以看出,还是对应层相乘。然后给array1升维:变成2*1*4*2*2,结果有点复杂:
结果变成2*2*4*2*2,这是怎么计算出来的?观察可知,新增维度后面的是4*2*2的张量,我们可以这么看:结果就是新增维度后面的张量,看做一块砖,而另外一个,没有升维的array2,自身有两块砖。array1的一块砖分别和array2的砖相乘,得到结果,结果和array2一样大。那array1有几块砖呢?两块。那么结果就是两个array那么大的张量,组合起来大小就是2*2*4*2*2。
五、预测结果
我们的mini batch大小为50,每次训练一个minibatch作为一次迭代,每次迭代计算一次损失函数,每20次迭代验证一次准确率。这时验证准确率选择的测试集大小为500。如下图所示,为11800次迭代,也就是遍历十次训练集后的损失函数曲线和准确率曲线。
可以很清晰的看出来,在约5000次迭代之后,迭代的效果已经很不明显了,loss曲线(左)出现了一条宽宽的尾巴,难以变窄,说明通过迭代使loss值变小的效率已经十分低,loss波动较明显,不够稳定。
准确率曲线(右)也一样,将横轴乘以20就是迭代次数。可以看出,在迭代200*20约4000次以后,准确率曲线几乎与x轴平行,也有波动,但波动不是很大。实际上在最后,一直在98%到99%波动。
每经过1180次迭代,将全部训练集遍历一遍,再进行一次验证。这时选择全部测试集进行验证。如下图:
如图,第一次遍历全部训练集,对全部测试集的准确率就已经有96.5%了,随后准确率稳步上升,在十次全部遍历之后准确率上升到98.5%。并且上升斜率越来越慢。
六、总结
CNN的复杂程度相比于之前写的神经网络上升了至少一个台阶。正向传播略微简单一点,但反向传播十分复杂,而且在引入高维张量以后更加复杂。主要难点就在于卷积和池化的逆操作。由于初识张量,因此对其的操作和性质几乎一无所知。想要真正理解和记忆正反向传播的过程和原理,需要自己着实下一番功夫,不能嫌弃过于复杂,或者犯懒而不去手动计算,那样只能是一知半解。
另外,CNN的训练极其缓慢,我的电脑使用CPU进行训练,遍历十次训练集花费超过12小时,十分煎熬。