Press "Enter" to skip to content

手把手带你用Numpy实现CNN

Numpy是一个非常好用的python科学计算的库,CNN是现在视觉领域深度学习的基础之一。虽然好的框架很多,不过自己用Numpy实现一个可以使用的CNN的模型有利于初学者加深对CNN的理解。

 

本文旨在通过这样一个专栏(文章) 介绍如何用Numpy从零实现一个可以训练的CNN简易网络,同时对深度学习(CNN)的相关基础知识进行一些复习,也希望能够给正在入门的学弟学妹们一些简单的归纳。

 

看干货请跳过<内容介绍>和<开始之前>或者直接踹门去我的github wuziheng

 

内容介绍:

 

进入正题:通过完成这篇(系列)文章,希望自己能够讲清楚以下几件事:

Numpy实现基本的CNN网络组件:conv, pooling, relu, fullyconnect, softmax

LeNet网络结构图

基本组件列表

从实现的基本组件搭建可以训练的网络,完成mnist的训练与测试,画出一个下图的训练曲线

实现的mnist的训练曲线

从贯序连接的层模型到计算图模型的引入,完成自动求值,求导等功能
常见的初始化方法,激活函数,优化方法,Loss函数的实现与比较,例如relu系与sigmoid系的比较,sgd,momentum,Adam的比较等等
常见的trick的实现,例如dropout, batchnorm, residual

实际上,这些东西tensorflow的源代码都有.一开始我只是看到某公司笔试题然后花了一到两天完成了第一个的内容。随后愈发觉得自己都写一下也蛮好玩的,有空的同学可以一起玩一玩,当然觉得这些都烂熟于心的大佬们,你们可以跳过~

 

文章之前的工作的timeline和详细的代码都可以在我的github: https://github.com/wuziheng/CNN-Numpy 上看到,已经实现的有1~4的大部分&dropout。

 

开始之前:

 

正式开始之前,先讲一讲为什幺会写这个小项目。我相信在深度学习这幺火爆的前提下,一定有很多同学和我一样是赶鸭子上架自学踹门而入的。比如我从16年12月开始接触深度学习,17年3月春节回校之后在小老板的要求下开始在Nvidia的X1+k80上进行一些网络和框架的试验(因为实验室不做学术+外加老板之前不是做这个的)所以之后自己抽空参加了一些比赛,继续踹门学习至今。

从赶鸭子上架到踹门自学

一整年下来,搬运了很多脚本,更多的时候比较迷茫,在入门的很长一段时间内对于深度学习还总是有一种雾里看花的感觉,训练~测试~敲里吗~训练~测试~敲里吗~循环

 

后来我意识到,迷茫可能是因为自己浅尝辄止,遇事多以完成目标为止。只有自己去写了,了解自己的每一行代码,才能慢慢的消除这种感觉。所以才萌生了自己实现这些基本的算法的想法,正好新一届学弟们也开始入门,我也想留下一点自己小小的经验~希望能够帮助到其他人。

 

需求

 

 

    1. 如果你会一点Python(~不会也没关系,贼简单)

 

    1. 如果你也想入门深度学习(~大概知道lenet+bp就可以了)

 

 

那我想你一定可以和我一起实现这个每一行代码都属于自己的简易CNN框架。

 

Part I卷积神经网络的基本组件

 

绝大部分的同学入门深度学习,第一个接触的应该就是LeNet,我们也将以此为例子介绍卷积神经网络的基本组件。参考上面的的网络结构图,它包含了卷积层(Convolutions),池化层(pooling), 全连接层(Full connection)。

 

卷积层(Convolutions)

 

卷积:如果你有图像处理的基础,对于卷积操作我想你一定不会陌生。在传统的图像处理中,卷积操作多用来进行滤波,锐化或者边缘检测啥的。我们可以认为卷积是利用某些设计好的参数组合(卷积核)去提取图像空域上相邻的信息。

 

卷积计算图解

在二维图像上,卷积操作一方面可以高效地按照我们的需求提取图像的邻域信息,在全局上又有着非常好的平移等变性,简单就是说你将输入图像的某一部分移动到另外一部分,在输出图像上也会有着相应的移动。比如下图是花书里用来阐述卷积在特征提取的效率优势上的一张图片,我们用来演示卷积的效果。假设我们把狗狗的鼻子移动到左上角,那幺相应输出里面的狗鼻子也会出现在左上角。(照片来源Paula Goodfellow)

卷积计算效果图

卷积神经网络之所以非常适合处理视觉问题,卷积的平移等变性是一大功臣。卷积神经网络中的卷积层可以理解成是在二维图像卷积的基础上增广而来。在二维图像卷积操作里,对于一个固定的卷积核,它能够提取输入图像的某种特征,但在实际的视觉问题里,某一个尺度下的某种空间特征不足以解决我们的需求。所以我们需要改进原始的卷积操作使得可以提取不同尺度下的不同特征。

 

一次操作(一层)中使用多个卷积核得到该尺度下的多张特征映射
多层(次)提取不同尺度下的不同特征信息

 

由于第一点改进,自然而然,即使第一张图片输入只有一个通道,后面其他层的输入都是多通道。所以对应的我们的卷积核也是多通道。即输入图像和卷积核都添加了channel这个维度,那幺卷积层中的卷积操作变为了如下的定义:

 

 

到这,我们大概可以总结出一个卷积层的前向计算(Forward)和他的功能。当然正如我们前面提到过,需要读者懂一点BP算法,因为即使我们知道如何通过卷积层提取输入图像的特征,卷积层依旧无法正常的工作,因为卷积操作最关键的部分卷积核的数值没有被确定下来。BP算法就是告诉我们,如何通过监督学习的方法来优化我们的卷积核的数值,使得我们能够找到在对应任务下表现最好的卷积核(特征),当然这个说法不是很准确。所以在我们实现的卷积层的类中,还会包含一个Backward方法,用于反向传播求导。考虑到一次篇幅不要太长这一部分的原理和实现将放在下一篇文章里。

 

在这篇接下来的部分里我们就将逐一用Numpy实现一个可以运行的Conv2D类以及Forward方法。

 

Show me your code

 

PS:文章看到的版本是不基于graph模型的,Conv2D直接继承自Object,所有的数据和操作都是裸露的,单纯为了实现功能。github上面这一部分代码已经不用了,放在layers文件夹下。

 

初始化

 

根据上面的公式,我们知道实现一个卷积前向计算的操作,我们需要知道以下信息:

 

 

    1. 输入数据的shape = [N,W,H,C] N=Batchsize/W=width/H=height/C=channels

 

    1. 卷积核的尺寸ksize ,个数output_channels, kernel shape [output_channels,k,k,C]

 

    1. 卷积的步长,基本默认为1.

 

    1. 卷积的方法,VALID or SAME,即是否通过padding保持输出图像与输入图像的大小不变

 

 

实际上还需要知道核参数的初始化方法

 

class Conv2D(object):    def __init__(self, shape, output_channels, ksize=3, stride=1, method='VALID'):        self.input_shape = shape        self.output_channels = output_channels        self.input_channels = shape[-1]        self.batchsize = shape[0]        self.stride = stride        self.ksize = ksize        self.method = method        weights_scale = math.sqrt(ksize*ksize*self.input_channels/2)        self.weights = np.random.standard_normal(    (ksize, ksize, self.input_channels, self.output_channels)) / weights_scale        self.bias = np.random.standard_normal(self.output_channels) / weights_scale

 

我们通过初始化函数确定上面提到的所需参数,在第一次声明的时候完成了对该层的构建。如下:

 

conv1 = Conv2D([batch_size, 28, 28, 1], 12, 5, 1)

 

这个conv1的实例就代表了该层,自然也会包含该层所需要的参数,例如kernel weights, kernel bias,我们用 生成对应的kernel weights和kernel bias。因为 生成的mean=0,stdev=1的随机Numpy数组,这里我们后面除以相应的weights_scale(msra方法)去控制一下初始化生成的weights的stdev,好的初始化可以加速收敛。

 

下面这一部分是反向传播中用到的,这篇文章中暂时不会用到,self.eta用于储存backward传回来的 他与该层的out是一个同样维度的数组.

 

这里我们就可以看到method时如何控制输出数据的形状的。“SAME”就表示添加padding使得输出长宽不变。self.w_gradient,self.b_gradient则分别用于储存backward计算过后得到的该次的

 

if method == 'VALID':    self.eta = np.zeros((shape[0], (shape[1] - ksize ) / self.stride + 1, (shape[1] - ksize ) / self.stride + 1,     self.output_channels)) 
if method == 'SAME':    self.eta = np.zeros((shape[0], shape[1]/self.stride, shape[2]/self.stride,self.output_channels)) 
self.w_gradient = np.zeros(self.weights.shape) self.b_gradient = np.zeros(self.bias.shape) self.output_shape = self.eta.shape

 

前向计算(forward)

 

如何实现卷积层前向计算,是一个非常老生长谈的问提,譬如我唯一一次面试(尴尬的经历):

 

面试官:     ”...天南...海北...“ 面试官:     ”你知道卷积是怎幺算的嘛?“(原话) 懵逼的我:   ”还能怎幺算~?~不就是点乘幺?“ 面试官:     ~懵逼~ 我:         ~懵逼~

 

回头面试挂了,我才意识到他可能是想问我讲一下im2col,然后矩阵再乘法。或者用cuda自己实现一个并行的卷积计算(比如说X1的线程数就很适合一次处理一张1080p图像的一行)。实际上在这次非常尴尬的面试之前,上面说的这两种方法我都自己实现过,可我就是不太明白面试官要问啥~这里我要借这个机会吐槽一下这个面试官,下次可不可有点铺垫,再明确问卷积层的计算有哪些优化的方法?~

 

回到正题:CNN中的卷积层是如何计算?

 

贾扬清大神比我回答的好多了,参考这个链接 在Caffe中如何计算卷积

 

这里主要使用的就是im2col优化方法:通过将图像展开,使得卷积运算可以变成两个矩阵乘法

图解im2col

参见论文 High Performance Convolutional Neural Networks for Document Processing

 

我们的forward方法的实现基本就是实现了上图,主要分为以下四个步骤:

 

完整的代码如下(因为我不会把list和代码块间隔放只能分开了)

 

def forward(self, x):    col_weights = self.weights.reshape([-1, self.output_channels])    if self.method == 'SAME':        x = np.pad(x, ((0, 0), (self.ksize / 2, self.ksize / 2), (self.ksize / 2, self.ksize / 2), (0, 0)),                         'constant', constant_values=0)    self.col_image = []    conv_out = np.zeros(self.eta.shape)    for i in range(self.batchsize):        img_i = x[i][np.newaxis, :]        self.col_image_i = im2col(img_i, self.ksize, self.stride)        conv_out[i] = np.reshape(np.dot(self.col_image_i, col_weights) + self.bias, self.eta[0].shape)        self.col_image.append(self.col_image_i)    self.col_image = np.array(self.col_image)    return conv_out

 

 

    1. 首先我们将卷积层的参数weights通过ndarray自带的reshape方法reshape到上图中Kernal Matrix的形状。

 

    1. 根据self.method,选择是否对输入的数据进行padding,这里我们调用

    1.  方法,对我们的输入数据四维ndarray的第二维和第三维分别padding上与卷积核大小相匹配的0元素

 

    1. 声明一个list用于存储转换为column的image,在backward中我们还会用到

 

    1. 对于batch中的每一个数据,分别调用im2col方法,将该数据转化为上图中的Input features(Matrix), 然后调用

    1.  完成矩阵乘法得到Output features(Matrix), reshape输出的shape,填充到输出数据中。

 

 

im2col的代码如下:

 

def im2col(image, ksize, stride):    # image is a 4d tensor([batchsize, width ,height, channel])    image_col = []    for i in range(0, image.shape[1] - ksize + 1, stride):        for j in range(0, image.shape[2] - ksize + 1, stride):            col = image[:, i:i + ksize, j:j + ksize, :].reshape([-1])            image_col.append(col)    image_col = np.array(image_col)    return image_col

 

到这里我们就基本实现了简单的卷积层的forward方法,你可以通过输入图片,指定weights的值,进行简单的运算,测试是否能够正确的进行前向的计算,完整的代码可以去我的github直接看最新版,虽然有很较大的出入,但一样是非常容易理解的。 我们也能看出,之所以选用python+Numpy实现,是因为大多数需要用到的方法,例如reshape,pad,dot,都已经在Numpy中实现好了,同时,也很方便我们实时的去检查。

 

小结: 考虑到篇幅问题,第一篇文章里就只介绍到这里。整篇文章基本上是以图像的视角,介绍了如何利用Numpy实现CNN中的卷积层的前向计算。这只是CNN精彩的地方,但不是最关键的地方,下一篇中我们将先介绍BP算法以及从机器学习相关视角来看待CNN,然后实现卷积层里面的backward()与apply_gradient()。当然也欢迎直接去我的github CNN-Numpy 可以看到迄今为止实现的所有内容,欢迎大家的宝贵意见~

Be First to Comment

发表评论

电子邮件地址不会被公开。 必填项已用*标注