前言
Numpy是一个非常好用的python科学计算的库,CNN是现在视觉领域深度学习的基础之一。虽然好的框架很多,不过自己用Numpy实现一个可以使用的CNN的模型有利于初学者加深对CNN的理解。
后面我们将通过一系列文章介绍如何用Numpy从零实现一个可以训练的CNN简易网络,同时对深度学习(CNN)的相关基础知识进行一些复习,也希望能够给正在入门的同学一些简单的归纳。
在这一系列的文章中,我们主要需要做以下一些工作:
Numpy实现如下图中所示的基本的CNN网络组件:conv, pooling, relu, fullyconnect, softmax
用实现的基本组件搭建可以训练的网络,完成mnist的训练与测试,画出一个下图的训练曲线
从贯序连接的层模型到计算图模型的引入,完成自动求值,求导等功能
常见的初始化方法,激活函数,优化方法,Loss函数的实现与比较,例如relu系与sigmoid系的比较,sgd,momentum,Adam的比较等等
常见的trick的实现,例如dropout, batchnorm, residual
绝大部分的同学入门深度学习,第一个接触的应该就是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文件夹下。
初始化
根据上面的公式,我们知道实现一个卷积前向计算的操作,我们需要知道以下信息:
输入数据的shape = [N,W,H,C] N=Batchsize/W=width/H=height/C=channels
卷积核的尺寸ksize ,个数output_channels, kernel shape [output_channels,k,k,C]
卷积的步长,基本默认为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,我们用 np.random.standard_noral(kernel_shape)生成对应的kernel weights和kernel bias。因为 np.random.standard_noral()生成的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)
如何实现卷积层前向计算,是一个非常老生长谈的问提,贾扬清大神对这个问题解释的比较清楚,可以参考查询下在Caffe中如何计算卷积
这里主要使用的就是im2col优化方法:通过将图像展开,使得卷积运算可以变成两个矩阵乘法
图解im2col
详情参见论文 High Performance Convolutional Neural Networks for Document Processing
我们的forward方法的实现基本就是实现了上图,主要分为以下四个步骤:
完整的代码如下
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
首先我们将卷积层的参数weights通过ndarray自带的reshape方法reshape到上图中Kernal Matrix的形状。
根据self.method,选择是否对输入的数据进行padding,这里我们调用 np.pad()方法,对我们的输入数据四维ndarray的第二维和第三维分别padding上与卷积核大小相匹配的0元素。
声明一个list用于存储转换为column的image,在backward中我们还会用到。
对于batch中的每一个数据,分别调用im2col方法,将该数据转化为上图中的Input features(Matrix), 然后调用 np.dot()完成矩阵乘法得到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代码", 获得地址.