【python】卷积神经网络:前向传播与反向传播的原理 & 仅使用numpy的CNN实现

一、概述

之前我们已经了解了普通的神经网络——使用前向传播和反向传播来进行训练。以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,卷积核为\omega,大小为n*n(三维张量为h*n*n),输出的张量为Z。

则对于二维张量情况,

z_{ij}=\sum_{l=0}^{n}\sum_{k=0}^{n}a_{(i+l)(j+k)}\omega_{lk}+bias

三维张量情况,

z_{ij}=\sum_{m=0}^{h}\sum_{l=0}^{n}\sum_{k=0}^{n}a_{m(i+l)(j+k)}\omega_{mlk}+bias

②卷积→池化

本文所参考的使用maxpooling,即最大池化。从张量的每一层中划分池子,分别取最大值即可。

③池化→全连接

也很简单,先把池化层输出的张量展开为一维向量,然后输入全连接层即可。

2、反向传播

建议这部分配合该网址食用。

这里我有必要重申一下反向传播的特点。我们反向传播,是为了更新参数。参数更新的幅度怎么算

\Delta \omega_{ij}=\eta\frac{\partial E}{\partial \omega_{ij}}=\eta\frac{\partial E}{\partial z_{j}}\frac{\partial z_j}{\partial \omega_{ij}}

这里,\eta是学习率,我们事先指定,a_i是与\omega_{ij}相连的输入值。关键就是后面那个偏导数。上面的E是loss function,也就是损失函数,因此,某个参数的更新量,就等于学习率*损失函数对该参数的偏导,也就等于学习率*损失函数对该层对应输出的偏导*输出对参数的偏导。

由于偏导的链式传播的特点,无论l层和l-1层是卷积啊,池化啊,什么的乱七八糟的,只要我们设z_{l+1}为l+1层的输出,z_{l}为l层的输出,那么就有

\frac{\partial E}{\partial z_{l}}=\frac{\partial E}{\partial z_{l+1}}\frac{\partial z_{l+1}}{\partial z_{l}}

这意味着什么呢?意味着我求任意一个参数的更新量,都可以通过求该层输出的偏导,乘以该层输出对该参数的偏导得到。这就很方便。由于这个偏导是在是太重要,因此就将误差函数对某节点输出值的偏导称之为该节点的“敏感度”,记为\delta_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)这个地方的输出:

z_{(1,1,1)}= a_{(1,1,1)}*\omega_{(1,1,1,1)}+a_{(1,1,2)}*\omega_{(1,1,1,2)}+a_{(1,1,3)}*\omega_{(1,1,1,3)}+ a_{(1,2,1)}*\omega_{(1,1,2,1)}+a_{(1,2,2)}*\omega_{(1,1,2,2)}+a_{(1,2,3)}*\omega_{(1,1,2,3)}+ a_{(1,3,1)}*\omega_{(1,1,3,1)}+a_{(1,3,2)}*\omega_{(1,1,3,2)}+a_{(1,3,3)}*\omega_{(1,1,3,3)}+...+a_{(26,1,1)}*\omega_{(1,26,1,1)}+a_{(26,1,2)}*\omega_{(1,26,1,2)}+a_{(26,1,3)}*\omega_{(1,26,1,3)}+ a_{(26,2,1)}*\omega_{(1,26,2,1)}+a_{(26,2,2)}*\omega_{(1,26,2,2)}+a_{(26,2,3)}*\omega_{(1,26,2,3)}+ a_{(26,3,1)}*\omega_{(1,26,3,1)}+a_{(26,3,2)}*\omega_{(1,26,3,2)}+a_{(26,3,3)}*\omega_{(1,26,3,3)}

差点给我写吐了。

计算一个输出值,就这么麻烦。那么让E对第一个卷积核的(1,1,1)求偏导,即对\omega_{(1,1,1,1)}求偏导,就需要E对z的第一层的每个值求偏导,然后第一层的每个z对\omega_{(1,1,1,1)}求偏导。从上式可以得到 \frac{\partial z_{(1,1,1)}}{\partial \omega_{(1,1,1,1)}} ,即a_{(1,1,1)},但这不够,我们还需要\frac{\partial z_{(1,1,2)}}{\partial \omega_{(1,1,1,1)}}......乃至\frac{\partial z_{(1,10,10)}}{\partial \omega_{(1,1,1,1)}}这些偏导乘上对应的“E对z”求偏导,才是我们要的“E对第一个卷积核的(1,1,1)求偏导”

这麻烦炸了。这一个卷积核的偏导,要求10*10个偏导,能不能弄出一个直观的形式啊?

试试吧。第一个是a_{(1,1,1)},第二个是a_{(1,1,2)},然后是a_{(1,1,3)}...a_{(1,1,10)};接下来a_{(1,2,1)}...a_{(1,2,10)};......;a_{(1,10,1)}...a_{(1,10,10)}。这是第一层。那么还得乘上对应的“E对z”的偏导呢:

a_{(1,1,1)}\delta_{(1,1,1)}a_{(1,1,2)}\delta_{(1,1,2)}......a_{(1,10,1)}\delta_{(1,10,1)}...a_{(1,10,10)}\delta_{(1,10,10)}。这不就是输入张量,那个52*12*12的第一张纸对输出偏导张量,那个52*10*10,的第一张纸,求卷积的第一个值么。

你要这么说我可就不困了。那按这么说,卷积核第二层的第一个值是两个张量的第二张纸求卷积的第一个值......第二十六层也一样。哦,那,“E对第一个卷积核的座标为(h,i,j)处的参数求偏导”,等于输入张量的第h层与偏导张量的第1层求卷积的(i,j)处的值

好起来了。那么整体的第一个卷积核的偏导,它是一个张量,应该等于输入张量每层分别与偏导张量求卷积!来看看尺寸对不对:

输入张量一层是12*12,偏导张量一层是10*10,求卷积得到3*3,正好是卷积核一层的大小。没错了。

这样我们就知道了卷积核的偏导该如何求。

接下来看卷积层的第二个任务:求上一层的偏导。简而言之就是求\frac{\partial E}{\partial a_{(h,i,j)}},根据链式法则,我们要求\frac{\partial E}{\partial a_{(h,i,j)}},就要求出所有的z_{(H,I,J)},这个元素中含有a_{(h,i,j)}。又需要用到上面那个巨长无比的式子了:

我们现在想求\frac{\partial E}{\partial a_{(1,1,1)}},那么,就得找到所有用到a_{(1,1,1)}的z。都有哪个z用到了a_{(1,1,1)}呢?回想一下前向传播的过程:a的第一层乘以卷积核的第一层...a的第二十六层乘以卷积核的第二十六层,相加,得到一层输出z。也就是说,每一层的z,都有a的第一层的参与。那究竟是每层z的哪一个值呢?z_{(h,1,1)},只有它在计算的时候用到了a_{(1,1,1)}。这有点难想,z每层的(1,1),是由a所有层的(1,1)...(3,3)和卷积核所有层的(1,1)...(3,3)卷积出来的,因此z_{(h,1,1)}用到了a_{(1,1,1)},它不但用到了a_{(1,1,1)},还有a_{(1,1,2)}......a_{(26,3,3)}。那么我们就要求\frac{\partial z_{(h,1,1)}}{\partial a_{(1,1,1)}},也就是\frac{\partial z_{(1,1,1)}}{\partial a_{(1,1,1)}}......\frac{\partial z_{(52,1,1)}}{\partial a_{(1,1,1)}}

好麻烦啊!!!!!!

\frac{\partial z_{(1,1,1)}}{\partial a_{(1,1,1)}}=\omega_{(1,1,1,1)},...,\frac{\partial z_{(52,1,1)}}{\partial a_{(1,1,1)}}=\omega_{(52,1,1,1)}

然后也得乘\delta,\omega_{(1,1,1,1)}*\delta_{(1,1,1)},...,\omega_{(52,1,1,1)}*\delta_{(1,1,1)}。这些玩意再相加。

等一下,好像有点眼熟。这操作,好像是某种卷积的一部分?是偏导张量(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,那\delta_{(1,1,1)}就没法和\omega_{(1,1,1,1)}对应了啊,对应\delta_{(1,1,1)}的是\omega_{(1,1,3,3)}。这可怎么办。真令人头大。

接着往下走一步吧。我们可以简单想一下:z_{(h,1,1)}用到了a_{(1,1,1)},对于第h层,只有z_{(h,1,1)}用到了a_{(1,1,1)},这很好。但是a_{(1,1,2)}呢?z_{(h,1,1)}用到了a_{(1,1,2)}z_{(h,1,2)}也用到了a_{(1,1,2)},而且\frac{\partial z_{(1,1,2)}}{\partial a_{(1,1,2)}}=\omega_{(1,1,1,1)},也就是说,想找用到a_{(1,1,2)}的z,要比a_{(1,1,1)}多一倍:

\frac{\partial z_{(1,1,1)}}{\partial a_{(1,1,2)}}=\omega_{(1,1,1,2)}\frac{\partial z_{(1,1,2)}}{\partial a_{(1,1,2)}}=\omega_{(1,1,1,1)},......,\frac{\partial z_{(52,1,1)}}{\partial a_{(1,1,2)}}=\omega_{(52,1,1,2)}\frac{\partial z_{(52,1,2)}}{\partial a_{(1,1,2)}}=\omega_{(52,1,1,1)}

然后也得乘\delta\omega_{(1,1,1,2)}*\delta_{(1,1,1)}\omega_{(1,1,1,1)}*\delta_{(1,1,2)}...诶?又不对劲了啊。按我之前说的,偏导张量先padding,然后分别与每个卷积核卷积,那么\frac{\partial E}{\partial a_{(1,1,2)}}应该是\omega_{(1,1,3,2)}*\delta_{(1,1,1)}\omega_{(1,1,3,3)}*\delta_{(1,1,2)},和我们上面推的完全对不上。

我们得相信我们的推导:

\omega_{(1,1,1,1)}*\delta_{(1,1,1)},这俩搭配求卷积是对的;

\omega_{(1,1,1,2)}*\delta_{(1,1,1)}\omega_{(1,1,1,1)}*\delta_{(1,1,2)},这搭配求卷积也是对的。

那么问题来了。这个(14*14)与(3*3)卷积,怎么能让上面这个相乘呢?你第一个卷积,(14*14)就左下角有个值,难不成你\omega_{(1,1,1,1)}跑到左下角去了?诶?如果真是\omega_{(1,1,1,1)}跑到左下角,那第二个卷积中,\omega_{(1,1,1,1)}正好和\delta_{(1,1,2)}相乘啊,那\omega_{(1,1,1,2)}就转到第三行的第二个去了?

也就是说,这个(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小时,十分煎熬。

 

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