实现卷积神经网络
梯度检验
前面两个实验实现交叉熵损失函数、激活函数、线性层时,都是直接对其函数进行求导,在数学上称之为解析梯度 Analytic gradient,而在数学上梯度的真正定义是:
在这里还是以第一次实验中的一个简单的一元四次函数为例:
def f(x): return (x+1)*(x-1)*(x+2)*(x-2)
h = 1e-5 # 一个非常小的数字
x = 1
(f(x+h)-f(x-h))/(2*h)
答案是正确的。有人可能会问,为什么需要介绍这个呢?原因是,在实际使用的时候,常用解析梯度求解,然后用数值梯度检验实现、计算是否正确。
前面的实验中,我们使用了 PyTorch 的自动求导机制来检验实现是正确的,但 PyTorch 的实现不是万能的,必须全部使用 PyTorch 的 Tensor 操作完成。因此,接下来介绍和实现卷积神经网络的卷积、池化层时,会使用数值梯度检验实现是否正确。
根据上面介绍的公式,仿照简单函数的计算,封装一个计算数值梯度的函数进行检验,输入应为一个 NumPy 矩阵和一个封装好的函数:
def eval_numerical_gradient(function, input, h=1e-5):
assert type(input) == np.ndarray, '输入应为一个矩阵'
# 保存输入数据的维度,梯度应和待求梯度的变量维度一致
shape = input.shape
# 将输入数据展开,以方便计算
data = np.ravel(input)
# 初始化,保存梯度
gradient = np.zeros_like(data, dtype=float)
# 按公式计算每一个变量的梯度
for index in range(data.shape[0]):
# 公式 1 实现
data[index] += h
value1 = function(data.reshape(shape))
data[index] -= 2*h
value2 = function(data.reshape(shape))
# 计算梯度
gradient[index] = (value1-value2)/(2*h)
# 将该变量变回最初的
data[index] += h
# 返回梯度时,需要 reshape 为原始输入数据的维度
return np.reshape(gradient, shape)
卷积神经网络
1957 年 Frank Rosenblatt 使用 Mark I Perceptron machine 第一个实现了 Perceptron(感知机) 算法。虽然现在来看,感知机的原理非常的简单,但是事实上现有的神经网络也只是感知机的复杂化而已。六十多年前,感知机正式揭开了神经网络的序幕。
Mark I Perceptron machine 只有一个摄像头,通过硫化物拍照产生 20*20 的图片,来识别字母。感知机所进行的操作也很简单,输出只有 0 和 1。
一直到现在的卷积神经网络得到深入研究和应用的时候,还需要归功于 LeCun、Hinton、Bengio 等大牛们的努力,这三个人是深度学习的开山鼻祖,现有的很多方向、基本算法都是有他们或者他们的学生提出来的。吴恩达也很厉害,但是和这三位相比也稍有不如,吴恩达更出名的是其向大众做科普的贡献,推动了这个行业的发展。
卷积神经网络的第一次应用,是 LeCun 在 1998 提出的 LeNet-5,引入了卷积、池化操作,最终应用在 document recognition,也就是手写体识别上。
LeNet-5 由堆叠的两层卷积、池化(下采样)和后续的线性层构成。这个模式也是现有神经网络普遍采用的结果,卷积操作之后进行下采样,然后在最后通过线性层进行分类或回归等。这个五层网络和现在的大型网络肯定比不了的,毕竟是二十年前的算法,现有的 CNN 都能堆出上百层。
卷积神经网络得到广泛应用的开端是 2012 年 Hinton 的学生 Alex Krizhevsky 在 ImageNet Large Scale Visual Recognition Challenge (ILSVRC),也就是常听到的 ImageNet 数据集上取得了比传统方法好得多的准确度。
AlexNet 网络结构示意图:
AlexNet 看起来和 LeNet-5 一样,输入是三通道的224∗224 的彩色图片,然后堆叠卷积、池化,和线性层,最后输出概率。
需要注意的是,除了网络加深之外,数据量也变得非常大了,输入的是3224224的彩色图片,而 MNIST 数据集才多少?的彩色图片,而MNIST数据集才多少?12828。在 2012 年,GPU 发展还没有现在这样大,Alex 使用两个 GTX 580(只有 3GB 显存),将参数分成两部分并行训练,所以现在看到的 AlexNet 网络示意图分为了上下两个分支。
现有的趋势就是网络加深,例如下图在 ILSVRC 比赛取得冠军的网络结构:
2010 年和 2011 年是传统方法,2012 年 AlexNet 夺冠之后 ImageNet 正式成为卷积神经网络的天下,而且新网络的层数渐渐增多直到 ResNet 的出现,网络结构超过了一百层。
值得说的是,ResNet 的作者何凯明,现在在 Facebook AI Research 工作,是广东省理科高考状元。当年 ResNet 一经发表,就刷爆了检测、分割、识别等的榜单。除了 ResNet,何凯明做了很多杰出的工作,在此不多做介绍。
下面列举一些基于卷积神经网络的著名的算法或网络结构:
分类
AlexNet
ZFNet
GoogleNet
SqueezeNet
VGG
物体检测
Fast-rcnn, Faster-rcnn
YOLO: Real Time Object Detection
SSD: Single Shot MultiBox Detector
人脸识别
DeepID
Center Loss
这只是一小部分例子,卷积神经网络在许多方向取得了很棒的效果。
既然 CNN 最重要的一部分是卷积和池化,接下来将会推导和实现卷积层和池化层,并结合前面实验完成的线性层、激活层、交叉熵损失函数完成一个完整的 LeNet-5。
卷积层
Convolution 卷积运算实际是分析数学中的一种运算方式,由两个函数生成一个函数,并描述其中一个函数是如何随着另一个函数改变的。而在卷积神经网络中通常是仅涉及离散卷积的情形。
深度学习的所进行卷积运算,分成输入数据、卷积核(Convolution kernel 或 Convolution filter),由卷积核对输入数据进行步长为 Stride 的窗口滑动,每次卷积所进行的操作非常简单,卷积核和输入数据的对应位置相乘相加对应到输出数据中,公式描述如下:
K 为卷积核,m和n为卷积核的大小,I为输入数据,输出数据S的 (i,j)坐标的数据等于I的(i,j)起始位置的数据和K的乘积之和。
对于多通道数据,如C×H×W,所对应的卷积核也是多通道的,如C×K1×K2。
如下图所示,输入一个多通道数据,形如C×H×W,经过一个卷积核卷积之后,对应输出的一个 Feature map(深度学习将经过卷积之后的数据称为 Feature map)。通常卷积层的的卷积核数量不止一个,所以有多层 Feature map。
其他的很容易理解,接下来我们介绍一下Padding 补齐的含义即可。
深度学习中的补齐,主要的目的是为了保存图像边缘的信息,或者使得输出数据的维度满足某种要求(例如输出和输入数据的维度相等)。补齐通常是指在输入数据的边缘补0,也叫做 Zero padding,如下图,在 7×7 的输入数据的边缘补齐一个像素,使其变成9×9:
当对齐采用 核大小为3×3、步长为 1 的卷积操作时,输出为7×7。关于如何计算卷积之后的输出数据高宽,通常使用如下公式:
括号所进行的操作为向下取整,例如对 1.5 向下取整为 1。
卷积的意义
讲了这么多,只是知道了卷积操作是如何进行的,但是卷积有什么用呢?接下来举一个简单的例子来理解卷积的含义。
这是一张 Lena 的图片,选择这张图片作为例子,演示卷积的作用。首先下载该图片:
!wget -nc "http://labfile.oss.aliyuncs.com/courses/1213/lena.png"
然后,我们会将图片转为灰度图,并使用下面的一个 3\times 33×3 的卷积核对整张图片进行卷积。
首先,转为灰度图:
!pip install opencv-python # 安装所需 opencv
import cv2
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
# 上面定义的卷积核
kernel = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]])
# 读取图片并转化为灰度图
img = cv2.imread('lena.png', )
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
plt.imshow(img)
接下来就对其使用该卷积核进行卷积, Padding 为 0,步长为 1:
kernel_size, stride, padding = 3, 1, 0
# 计算输出大小
output_shape = (
np.floor((np.array(img.shape)-kernel_size)/stride)+1).astype(int)
output = np.zeros(output_shape)
# 遍历输出图片,输出图片的数据等于卷积核对输入图片相应位置卷积
for rows in range(output_shape[0]):
for coloumns in range(output_shape[1]):
output[rows, coloumns] = np.sum(
kernel*img[rows*stride: rows*stride+kernel_size,
coloumns*stride: coloumns*stride+kernel_size])
output
plt.imshow(output)
效果很神奇,经过该卷积核卷积之后,输出数据可视化就是很明显的原始图片的边缘信息。
事实上,卷积网络中的卷积核参数是通过网络训练学出的,除了类似于上图的提取边缘信息的卷积核。还可以训练处检测颜色、形状、纹理等等众多基本模式的卷积核。这些卷积核都可以包含在一个足够复杂的深层卷积神经网络中。通过「组合」这些卷积核以及随着网络后续操作的进行,基本模式会逐渐被抽象为具有高层语义的「概念」表示,并以此对应到具体的样本类别。
卷积层的前向传播
关于卷积的前向传播前面介绍的已经够清晰了,接下来使用代码实现一个卷积层的前向传播过程。
首先定义一个 conv2d 的类,初始化时传入输入数据的通道数、卷积核数量、卷积核大小、步长和补齐数。在前面的实验实现线性层等时,都定义了一个 forward 和 backward 函数,对应前向传播和后向传播过程,卷积层也是一样的。实现过程将会按照如下步骤进行:
首先输入数据是一个B×C×H×W 的数据,其中B代表批量大小(回想一下随机梯度下降的按批更新参数)C 是输入数据的通道数,H、W为输入数据的高宽。
然后根据 Padding 值对输入数据对齐,根据公式计算出该卷积层 Feature map 的大小,初始化该层的 Feature map。
最后按照上面介绍的卷积操作对输入数据进行卷积。
class conv2d_only_forward(object):
'''
output_channel: 该层卷积核的数量
input_channel: 输入数据的通道数,例如灰度图为 1,彩色图为 3,又或者上一层的卷积数量为 12,那下一层卷积的输入通道为 12
kernel_size: 卷积核大小
stride: 卷积移动的步长
padding: 补齐
'''
def __init__(self, input_channel, output_channel, kernel_size, stride=1, padding=0):
# 初始化网络权重,卷积核数量*输入通道数*卷积核大小*卷积核大小,一个卷积核应为输入通道数*卷积核大小*卷积核大小,每一层有多个卷积核
self.weight = (np.random.randn(output_channel, input_channel,
kernel_size, kernel_size)*0.01).astype(np.float32)
# 偏置项,每一个卷积核都对应一个偏置项,y=xW+b
self.bias = np.zeros((output_channel), dtype=np.float32)
# 保存各项参数
self.output_channel = output_channel
self.input_channel = input_channel
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
def forward(self, input):
# 输入数据各个维度代表的含义: batch_size, input_channel, height, width
batch_size, input_channel, height, width = input.shape
# 防止一些错误的调用
assert len(input.shape) == 4, '输入必须是四维的,batch_size*channel*width*height'
# 上一层的卷积核数量必须等于这一层的输入通道数
assert input_channel == self.input_channel, '网络输入通道数必须与网络定义的一致'
# 根据公式计算 Feature map 的大小
# 公式 5 实现
feature_height = int(
(height-self.kernel_size+2*self.padding)/self.stride)+1
feature_width = int(
(width-self.kernel_size+2*self.padding)/self.stride)+1
# 对输入数据进行补齐
input = np.pad(input, [(0, ), (0, ), (self.padding, ),
(self.padding, )], 'constant', constant_values=0)
# 保存输入数据,后续需要计算梯度
self.data = input
# 初始化 Feature map
feature_maps = np.zeros(
(batch_size, self.output_channel, feature_height, feature_width), dtype=np.float32)
# 再看一下 Lena 图片进行卷积操作的例子,这里为了为了计算高效只使用了两层循环,可以多层循环展开计算
# 公式 4 实现
for i in range(feature_height):
for j in range(feature_width):
feature_maps[:, :, i, j] = np.sum(self.weight[np.newaxis, :, :, :, :]*input[:, np.newaxis, :, i*self.stride: i*self.stride +
self.kernel_size, j*self.stride: j*self.stride+self.kernel_size], axis=(2, 3, 4)) + self.bias[np.newaxis, :]
return feature_maps
# 待完成
def backward(self, top_grad):
pass
完成之后可是如何验证这里的实现是正确的呢?所以接下来可以使用 PyTorch 实现的卷积操作进行验证,并最终计算两种方法结果的平均绝对误差。
import torch
# 定义各项参数
input_channel, output_channel, kernel_size, stride = 3, 10, 3, 1
# 定义一个这里实现的卷积层
conv = conv2d_only_forward(input_channel, output_channel, kernel_size, stride)
# 输入数据
input = np.random.randn(2, input_channel, 28, 28).astype(np.float32)
# 同样的参数使用 PyTorch 的实现
conv_torch = torch.nn.Conv2d(
input_channel, output_channel, kernel_size, stride)
# PyTorch 的实现对数据卷积
featuremaps_torch = conv_torch(torch.from_numpy(input))
# 加载 PyTorch 实现的卷积层权重和偏置到这里实现的卷积层之中
conv.weight = list(conv_torch.parameters())[0].detach().numpy()
conv.bias = list(conv_torch.parameters())[1].detach().numpy()
# 前向传播
featuremaps = conv.forward(input)
# 计算绝对值平均误差
np.mean(np.abs(featuremaps_torch.detach().numpy()-featuremaps))
最后输出的误差接近于 0,所以这里实现的卷积层前向传播是正确的。
卷积层的后向传播
卷积层的前向传播很容易理解和实现,真正的难点是接下来的卷积层的后向传播。
现在很多资料上对卷积层的反向传播过程,都是翻转 180 度,然后 Zero Padding,对上一层梯度进行卷积,搞得非常麻烦,理解也不是很清晰。很多公式推导很难证明其正确性。如何真正理解卷积层的反向传播,必须理解卷积层的所进行操作的本质: 点积和,卷积核对应点对输入数据对应点相乘,再相加得到结果(或者多一项偏置),然后通过窗口滑动(步长 Stride)卷积整个数据。
所以再来回顾一次用公式表达卷积层所进行的操作:
这是上面介绍卷积时所用的公式,特定于输入通道为 1、步长为 1 的数据。假设输入通道为C,则卷积核的大小为C×M×N,步长为s,这时候输出的 Feature map 是单通道的,多个卷积核对应多个 Feature map。
同样的,梯度下降需要对三个变量关于损失函数求梯度,输入数据、权重、偏置。
关于偏置b求梯度非常容易:
而输出S关于偏置求偏导为 1,所以上式可以简化为:
结果很清晰,损失函数关于偏置的梯度为对应输出的 Feature map 梯度之和。难点在于如何对输入和权重求偏导,因此必须记住卷积的本质:点积和,然后再利用链式法则求导,过程和推导偏置的梯度类似。
因此总结关于输入数据求梯度,只需要将对应的卷积核乘以对应的上一层梯度相加即可。说是这样说,最后实现的时候肯定会想,怎么做才能将输入数据对应到输出的每一个点上,即是否参与到这个点的运算上。这个时候其实方法很简单:重复一次前向传播的过程,并加上对应的梯度。
这是关于卷积层反向传播最清晰的解释,直接使用卷积的定义求解,也是深度学习中对于任何运算最通用的一种方法。实际上,很多经典教材中都几乎没有关于卷积层反向传播的推导,网上常见的方法,不一定能实现。
结合前面实现的 forward 函数,实现一个完整的 conv2d 类,包含前向、后向传播。
class conv2d(object):
'''
output_channel: 该层卷积核的数量
input_channel: 输入数据的通道数,例如灰度图为1,彩色图为3,又或者上一层的卷积数量为12,那下一层卷积的输入通道为12
kernel_size: 卷积核大小
stride: 卷积移动的步长
padding: 补齐
'''
def __init__(self, input_channel, output_channel, kernel_size, stride=1, padding=0):
# 初始化网络权重,卷积核数量*输入通道数*卷积核大小*卷积核大小
# 一个卷积核应为输入通道数*卷积核大小*卷积核大小,每一层有多个卷积核
self.weight = (np.random.randn(output_channel, input_channel,
kernel_size, kernel_size)*0.01).astype(np.float32)
# 偏置项,每一个卷积核都对应一个偏置项,y=xW+b
self.bias = np.zeros((output_channel), dtype=np.float32)
# 保存各项参数
self.output_channel = output_channel
self.input_channel = input_channel
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
def forward(self, input):
# 输入数据各个维度代表的含义: batch_size, input_channel, height, width
batch_size, input_channel, height, width = input.shape
# 防止一些错误的调用
assert len(input.shape) == 4, '输入必须是四维的,batch_size*channel*width*height'
# 上一层的卷积核数量必须等于这一层的输入通道数
assert input_channel == self.input_channel, '网络输入通道数必须与网络定义的一致'
# 根据公式计算 Feature map 的大小
feature_height = int(
(height-self.kernel_size+2*self.padding)/self.stride)+1
feature_width = int(
(width-self.kernel_size+2*self.padding)/self.stride)+1
# 对输入数据进行补齐
input = np.pad(input, [(0, ), (0, ), (self.padding, ),
(self.padding, )], 'constant', constant_values=0)
# 保存输入数据,后续需要计算梯度
self.data = input
# 初始化 Feature map
feature_maps = np.zeros(
(batch_size, self.output_channel, feature_height, feature_width), dtype=np.float32)
# 再看一下 Lena 图片进行卷积操作的例子,这里为了为了计算高效只使用了两层循环,可以多层循环展开计算
for i in range(feature_height):
for j in range(feature_width):
feature_maps[:, :, i, j] = np.sum(self.weight[np.newaxis, :, :, :, :]*input[:, np.newaxis, :, i*self.stride: i*self.stride +
self.kernel_size, j*self.stride: j*self.stride+self.kernel_size], axis=(2, 3, 4)) + self.bias[np.newaxis, :]
return feature_maps
'''
top_grad: 上一层的梯度,维度为 batch_size, output_channel, feature_height, feature_width,与该层输出一致
'''
def backward(self, top_grad, lr):
# 初始化梯度矩阵,维度和待求解梯度的数据一致
self.grad = np.zeros_like(self.data, dtype=np.float32)
self.grad_w = np.zeros_like(self.weight, dtype=np.float32)
self.grad_b = np.zeros_like(self.bias, dtype=np.float32)
# 上一层梯度的维度
batch_size, output_channel, feature_height, feature_width = top_grad.shape
# 遍历输出 Feature map 的每一个点
for i in range(feature_height):
for j in range(feature_width):
for f in range(output_channel):
# 权重的梯度为对应数据乘以对应的输出数据的某个点的梯度,batch_size 直接求和即可
# 公式 12 实现
self.grad_w[f, :, :, :] += np.sum(np.multiply(self.data[:, :, i*self.stride: i*self.stride+self.kernel_size,
j * self.stride: j*self.stride+self.kernel_size],
top_grad[:, f, i, j, np.newaxis, np.newaxis, np.newaxis]), axis=0)
# 对输入数据求梯度
for n in range(batch_size):
# 对应权重乘以上一层的对应点的梯度
# 公式 13 实现
self.grad[n, :, i*self.stride: i*self.stride+self.kernel_size, j*self.stride: j *
self.stride+self.kernel_size] += top_grad[n, f, i, j]*self.weight[f]
# 去除 padding 的影响
self.grad = self.grad[:, :, self.padding: self.grad.shape[2] -
self.padding, self.padding: self.grad.shape[3]-self.padding]
# 计算该层关于偏置的梯度
# 直接对该卷积对应的 Feature map 的梯度求和即可
# 公式 11 实现
for i in range(self.output_channel):
self.grad_b[i] = np.sum(top_grad[:, i, :, :])
# 更新参数
self.weight -= lr*self.grad_w
self.bias -= lr*self.grad_b
梯度检验
在最开始的部分,介绍了梯度检验的方法,提到应该使用「解析梯度」计算梯度,使用「数值梯度」检验梯度计算是否正确。在这里实现了一个卷积层,但并不知道这里的实现是否正确,这里当然可以使用 PyTorch 实现的卷积层进行检验,但是 PyTorch 不是万能的,所以在此使用数值梯度检验梯度。
首先,知道卷积层有三个梯度需要检验,分别是损失函数关于权重、偏置、输入的梯度。所以在此定义三个函数,使用最开始封装的方法分别检验这三个变量的梯度。其中,损失函数采用直接对输出的 Feature map 求和,其梯度为全为 1 的矩阵,实现起来比较简单。
# 定义各项参数
batch_size, input_channel, output_channel, kernel_size, stride, input_size = 1, 1, 2, 3, 2, 5
conv = conv2d(input_channel, output_channel, kernel_size, stride)
input = np.random.randn(batch_size, input_channel,
input_size, input_size).astype(np.float32)
# 保证每次测试时的参数是一致的
def init():
np.random.seed(1)
conv.weight = np.random.random(conv.weight.shape)*0.01
conv.bias = np.zeros_like(conv.bias)
def check_weight_gradient(cur_weight):
conv.weight = cur_weight
# 损失函数为直接求和
return np.sum(conv.forward(input))
def check_bias_gradient(cur_bias):
conv.bias = cur_bias
return np.sum(conv.forward(input))
def check_input_gradient(cur_input):
return np.sum(conv.forward(cur_input))
接下来,我们执行梯度检验。如果计算数据量太大会导致速度较慢,在这里使用非常少的数据进行检验。
init()
# 根据这里实现的计算三个梯度
feature_maps = conv.forward(input)
# 求和函数的梯度为全为 1 的矩阵
conv.backward(np.ones_like(feature_maps), lr=0.001)
analytic_gradient_w = conv.grad_w
analytic_gradient_b = conv.grad_b
analytic_gradient_input = conv.grad
# 因为上面 backward 时更新了参数
init()
# 计算权重的误差
numerical_gradient_w = eval_numerical_gradient(
check_weight_gradient, conv.weight)
# 计算平均误差
weight_error = np.mean(np.abs(numerical_gradient_w-analytic_gradient_w))
init()
# 计算偏置的误差
numerical_gradient_b = eval_numerical_gradient(check_bias_gradient, conv.bias)
bias_error = np.mean(np.abs(numerical_gradient_b-analytic_gradient_b))
init()
# 计算输入的误差
numerical_gradient_input = eval_numerical_gradient(check_input_gradient, input)
input_error = np.mean(np.abs(numerical_gradient_input-analytic_gradient_input))
weight_error, bias_error, input_error
最后,三个误差接近于 0,因此可以忽略不计,所以在这里实现的卷积层是正确的。我们可以输出两种方法计算的梯度,你会发现基本没有区别:
numerical_gradient_w, analytic_gradient_w
池化层
池化层是卷积神经网络的重要组件之一,在计算上和卷积层有点类似,这个类似稍后会提及。池化层有几种类型,例如 Max-Pooling(最大池化),Avarage-Pooling(均值池化)等,两者区别在于在进行池化时的方式不同。深度学习中常用最大池化,所以接下来将只会介绍最大池化。
池化层也有和卷积层一样需要提前设置的参数,如池化的核大小(对应卷积核大小)、池化的步长(对应卷积的步长)、池化的方式(最大、平均池化)。但是和卷积层的区别在于,池化层不包含需要学习的参数,只是对输入数据进行降采样。
下面以一个示例为例,其中对输入的 Feature map 采用步长为 2,核为 2 的最大池化。
右边显示了在对输入数据进行池化时所进行的操作及结果,和卷积类似的按步长窗口滑动。例如,第一个 2×2 的窗口中,最大值为 6,对应到结果中则是第一个的 6;滑动后的第二个窗口最大值为 8,对应到结果则是第二个的 8,以此类推。
当对左图的输入为224×224×64 的 Feature map 进行步长为 2,核为 2 的池化时,输出是112×112×64,这个结果很容易理解,输出减小了一半。池化层的引入是仿照人的视觉系统对视觉输入对象进行降维(降采样)和抽象。有两个作用:特征不变性和特征降维。
模型关心某个特征是否出现而不关心它出现的具体位置。例如,当判定一张图像中是否包含人脸时,我们并不需要知道眼睛的精确像素位置,我们只需要知道有一只眼睛在脸的左边,有一只在右边就行了。但在一些其他领域,保存特征的具体位置却很重要。所以这时候需要慎重使用池化。
除此之外,池化相当于特征降维,减小计算量和参数个数。例如,上图中对输入 Feature map 下采样之后,计算量明显减少了一倍。同时对数据进行池化时,输入数据都会对应输出数据的一个区域,从而使模型可以抽取更广范围的特征。
池化层的前向传播
最大池化层的实现非常简单,按照上面介绍的实现即可,首先实现前向传播:
class MaxPool2d_only_forward(object):
# 各项参数
def __init__(self, kernel_size, stride, padding=0):
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
# 前向传播
def forward(self, input):
# input: batch_size, input channel, height, width
assert len(input.shape) == 4, '输入必须是四维的,batch_size*channel*width*height'
batch_size, input_channel, height, width = input.shape
# 计算输出的大小,和卷积层一样
feature_height = int(
(height-self.kernel_size+2*self.padding)/self.stride)+1
feature_width = int(
(width-self.kernel_size+2*self.padding)/self.stride)+1
# 初始化输出矩阵
pooled_feature_map = np.zeros(
(batch_size, input_channel, feature_height, feature_width))
# 按设置的 padding 补齐
input = np.pad(input, [(0, ), (0, ), (self.padding, ),
(self.padding, )], 'constant', constant_values=0)
self.data = input
# 窗口滑动
for i in range(feature_height):
for j in range(feature_width):
# 最大池化
pooled_feature_map[:, :, i, j] = np.max(
input[:, :, i*self.stride: i*self.stride+self.kernel_size,
j*self.stride: j*self.stride+self.kernel_size], axis=(2, 3))
return pooled_feature_map
def backward(self, top_grad):
pass
完成之后和前面类似,使用 PyTorch 实现的池化层验证是否正确:
# 定义各项参数
batch_size, input_channel, kernel_size, stride, input_height, input_width = 100, 10, 2, 2, 150, 200
input = np.random.randn(batch_size, input_channel,
input_height, input_width).astype(np.float32)
pooling_layer = MaxPool2d_only_forward(kernel_size, stride)
output = pooling_layer.forward(input)
pooling_layer_torch = torch.nn.MaxPool2d(kernel_size, stride)
output_torch = pooling_layer_torch(torch.from_numpy(input))
np.mean(np.abs(output_torch.numpy()-output)) # 计算平均绝对误差
不出意外,两者之间的误差为 0,表明这里的实现是正确的。
池化层的后向传播
池化层的后向传播推导和卷积层的后向传播推导类似,有一种很形象的类推方式,上面说到池化层和卷积层在某种程度上很相似。池化层和卷积层的区别在池化层的卷积核采用的是y=Wx+b 求和,而池化采用的是 max 或者avarage 函数。最大池化采用的是max函数,举一个例子介绍如何对max函数求导。
前面介绍的 ReLU 激活函数就是一个max函数,在小于 0 的部分直接取 0:
假设输入数据x=x1,x2,…,xi,…,xm,最大值函数:
因此,最大池化层的误差传播,只需要将上一层梯度分散到这一块最大值所在的位置。对于步长不等于核大小的最大池化,利用链式法则只需要将误差累加即可。同样和卷积层的实现类似,反向传播时只需要再执行一遍池化的过程,将误差分散开来即可。注意,池化层没有需要学习的参数,所以只需要对输入数据求梯度。
下面是整合了前向传播和后向传播的最大池化层代码:
class MaxPool2d(object):
# 各项参数
def __init__(self, kernel_size, stride, padding=0):
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
# 前向传播
def forward(self, input):
# input: batch_size, input channel, height, width
assert len(input.shape) == 4, '输入必须是四维的,batch_size*channel*width*height'
batch_size, input_channel, height, width = input.shape
# 计算输出的大小,和卷积层一样
feature_height = int(
(height-self.kernel_size+2*self.padding)/self.stride)+1
feature_width = int(
(width-self.kernel_size+2*self.padding)/self.stride)+1
# 初始化输出矩阵
pooled_feature_map = np.zeros(
(batch_size, input_channel, feature_height, feature_width))
# 按设置的 padding 补齐
input = np.pad(input, [(0, ), (0, ), (self.padding, ),
(self.padding, )], 'constant', constant_values=0)
self.data = input
# 窗口滑动
for i in range(feature_height):
for j in range(feature_width):
# 最大池化
pooled_feature_map[:, :, i, j] = np.max(
input[:, :, i*self.stride: i*self.stride+self.kernel_size,
j*self.stride: j*self.stride+self.kernel_size], axis=(2, 3))
return pooled_feature_map
# 后向传播
def backward(self, top_grad):
# 输入数据梯度
self.grad = np.zeros_like(self.data)
# 上一层梯度的维度
batch_size, output_channel, feature_height, feature_width = top_grad.shape
# 遍历输出 Feature map 的每一个点
for n in range(batch_size):
for i in range(feature_height):
for j in range(feature_width):
# 取出这一块的数据
one_kernel_data = self.data[n, :, i*self.stride: i*self.stride +
self.kernel_size,
j*self.stride: j*self.stride+self.kernel_size]
# 下面三行代码都是获得最大值点的坐标
# 将一个矩形展开
channels = one_kernel_data.shape[0]
one_kernel_data = np.reshape(
one_kernel_data, (one_kernel_data.shape[0], -1))
# 获取该矩形最大值所在的坐标
one_kernel_featuremap_argmax = np.argmax(
one_kernel_data, axis=1)
# np.unravel_index 将被展开的坐标换算成对应的矩形内的坐标
argmax1, argmax2 = np.unravel_index(
one_kernel_featuremap_argmax, (self.kernel_size, self.kernel_size))
# 误差将会被分散到最大值所在的点上
# 公式 16 实现
self.grad[n, :, i*self.stride: i*self.stride+self.kernel_size, j*self.stride: j *
self.stride+self.kernel_size][np.arange(channels),
argmax1, argmax2] += top_grad[n, :, i, j]
# 去除 padding 的影响
self.grad = self.grad[:, :, self.padding: self.grad.shape[2] -
self.padding, self.padding: self.grad.shape[3]-self.padding]
完成之后同样使用梯度检验是否正确,下面的代码是计算解析梯度,也就是这里实现的梯度。
# 定义各项参数
batch_size, input_channel, kernel_size, stride, padding, input_height, input_width = 1,2,2,1,1,10,10
input = np.random.randn(batch_size, input_channel,
input_height, input_width).astype(np.float32)
pooling_layer = MaxPool2d(kernel_size, stride, padding=padding)
output = pooling_layer.forward(input)
pooling_layer.backward(np.ones_like(output))
analytic_gradient = pooling_layer.grad
计算数值梯度,并计算两者之间的差别。
def check_input_gradient(cur_input):
return np.sum(pooling_layer.forward(cur_input))
# 计算输入的误差
numerical_gradient = eval_numerical_gradient(check_input_gradient, input)
input_error = np.mean(np.abs(numerical_gradient_input-analytic_gradient_input))
input_error
上面输出的误差接近于 0,所以这里实现是正确的。
以上完成了卷积神经网络的两个重要组件,卷积层和池化层。其中使用到了许多 NumPy 函数和数学推导,需要耐心多看几遍,不熟悉的 NumPy 操作需要自行查阅 API 文档。完成这两部分之后,再结合前面实现的线性层、激活层、损失函数,即可实现一个简单的卷积神经网络。
这里需要注意的是,不要使用这些代码在大数据集上尝试,因为这些代码只能在 CPU 上运行。由于 Python 的性能本就不好,所以当数据量大了之后运行速度会很慢。这就是为什么几乎所有的深度学习框架底层都会使用 C/C++ 实现,并且使用 GPU 加速的原因。真正的深度学习框架底层实现会使用到很多并行计算的知识,而不是在本次课程所实现的一样简单的 for 循环解决问题。
下面将会利用所有的代码,实现一个经典的 LeNet-5。
LeNet-5 实现
前面已经介绍了,LeNet-5 是 LeCun 在 1998 提出的,第一次应用卷积神经网络,识别 MNIST 数据集手写体数据集。下面将会根据 Caffe 的 LeNet-5 实现 完成。
本次实现的区别在于,将会在每个卷积层和线性层之后都是用 ReLU 激活函数,Caffe 中的实现只有线性层有激活函数。因此整个的网络结构如图:
LeNet-5 由两层「卷积-激活-池化」块构成,然后扁平化输入到两层线性层,最后再 fc2 输出十个类别的概率。其中各项参数分别为:
conv1: 20 个5∗5 卷积核,步长为 1。
pool1: 步长为 2,大小为 2 的最大池化。
conv2: 50 个5∗5 卷积核,步长为 1。
pool2: 步长为 2,大小为 2 的最大池化。
fc1: 输入为 800(pool2 输出展开后为 800),输出为 500 的线性层。
fc2: 输入为 500,输出为 10 的线性层,输出每个类别概率。
所有卷积和池化层都无 Padding。
接下来根据这些参数定义网络结构:
class LeNet(object):
def __init__(self):
# 根据参数定义 LeNet-5 的各个组件
self.conv1 = conv2d(1, 20, 5, 1)
self.relu1 = ReLU()
self.pool1 = MaxPool2d(2, 2)
self.conv2 = conv2d(20, 50, 5, 1)
self.relu2 = ReLU()
self.pool2 = MaxPool2d(2, 2)
self.fc1 = Linear(800, 500)
self.relu3 = ReLU()
self.fc2 = Linear(500, 10)
# 前向传播过程
def forward(self, input):
# 根据网络结构定义前向传播,上一层的输出作为下一层的输入
input = self.relu1.forward(self.conv1.forward(input))
input = self.pool1.forward(input)
input = self.relu2.forward(self.conv2.forward(input))
input = self.pool2.forward(input)
# 从池化或卷积到线性层数据必须展开称为向量,因为线性层只接受向量输入(对于单个样本)。
# 在某些深度学习框架中,这个操作被封装为 FlattenLayer,本次课程不实现
self.flatten_shape = input.shape # 展开
input = np.reshape(input, (input.shape[0], -1))
input = self.relu3.forward(self.fc1.forward(input))
output = self.fc2.forward(input)
return output
# 后向传播
def backward(self, top_grad, lr):
# 后向传播的顺序和前向传播相反
self.fc2.backward(top_grad, lr)
self.relu3.backward(self.fc2.grad)
self.fc1.backward(self.relu3.grad, lr)
# 被展开的梯度必须还原为上一层的维度
unflattened_grad = np.reshape(self.fc1.grad, self.flatten_shape)
self.pool2.backward(unflattened_grad)
self.relu2.backward(self.pool2.grad)
self.conv2.backward(self.relu2.grad, lr)
self.pool1.backward(self.conv2.grad)
self.relu1.backward(self.pool1.grad)
self.conv1.backward(self.relu1.grad, lr)
定义完网络结构后,因为参数过多,而 Python 执行效率很低,所以运行速度非常慢,这里直接使用优化之后的代码进行训练,下一个实验我们就会介绍如何优化卷积神经网络。
# 下载打包好的优化源码文件和 MNIST 数据集数据集
!wget -nc "http://labfile.oss.aliyuncs.com/courses/1213/nn.py"
!wget -nc "http://labfile.oss.aliyuncs.com/courses/1213/mnist.zip"
!unzip -o mnist.zip
from sklearn.preprocessing import OneHotEncoder
import nn
# 读取并归一化数据,不归一化会导致 nan
test_data = ((nn.read_mnist('mnist/t10k-images.idx3-ubyte')) /
256.0).astype(np.float32)[:, np.newaxis, :, :]
train_data = ((nn.read_mnist('mnist/train-images.idx3-ubyte')) /
256.0).astype(np.float32)[:, np.newaxis, :, :]
# 独热编码标签
encoder = OneHotEncoder(categories='auto')
encoder.fit(np.arange(10).reshape((-1, 1)))
train_labels = encoder.transform(nn.read_mnist(
'mnist/train-labels.idx1-ubyte').reshape((-1, 1))).toarray().astype(np.float32)
test_labels = encoder.transform(nn.read_mnist(
'mnist/t10k-labels.idx1-ubyte').reshape((-1, 1))).toarray().astype(np.float32)
batch_size = 120
train_dataloader = nn.Dataloader(
train_data, train_labels, batch_size, shuffle=True)
test_dataloader = nn.Dataloader(
test_data, test_labels, batch_size, shuffle=False)
loss_layer = nn.CrossEntropyLossLayer() # 损失层
lr = 0.1 # 学习率
np.random.seed(1) # 固定随机生成的权重
max_iter = 6 # 最大迭代次数和步长
step_size = 4
scheduler = nn.lr_scheduler(lr, step_size) # 学习率衰减
net = nn.LeNet()
test_loss_list, train_loss_list, train_acc_list, test_acc_list, best_net = nn.train_and_test(
loss_layer, net, scheduler, max_iter, train_dataloader, test_dataloader, batch_size)
以上代码执行时间较长,请耐心等待。当然,你可以单元格非编辑状态下,连续点击两次 I
键强制中止训练,下载示例代码 到本地环境中执行。
不出意外的话,最后将会获得超过 99% 的准确度。而在上一个多层神经网络的实验中,我们最终只得到 98% 左右的准确度。可不要小瞧这 1% 的提升。
实现平均池化的前向传播过程
使用 NumPy 实现平均池化的前向传播
前面的课程中,我们已经知道了最大池化的前向传播是对每一块取最大值,而平均池化则是对每一块取平均值。实验中,最大池化的前向传播实现如下:
class MaxPool2d_only_forward(object):
# 各项参数
def __init__(self, kernel_size, stride, padding=0):
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
# 前向传播
def forward(self, input):
# input: batch_size, input channel, height, width
assert len(input.shape)==4, '输入必须是四维的,batch_size*channel*width*height'
batch_size, input_channel, height, width = input.shape
# 计算输出的大小,和卷积层一样
feature_height = int((height-self.kernel_size+2*self.padding)/self.stride)+1
feature_width = int((width-self.kernel_size+2*self.padding)/self.stride)+1
# 初始化输出矩阵
pooled_feature_map = np.zeros((batch_size, input_channel, feature_height, feature_width))
# 按设置的 padding 补齐
input = np.pad(input, [(0, ), (0, ), (self.padding, ), (self.padding, )], 'constant', constant_values=0)
self.data = input
# 窗口滑动
for i in range(feature_height):
for j in range(feature_width):
# 最大池化
pooled_feature_map[:, :, i, j] = np.max(input[:, :, i*self.stride: i*self.stride+self.kernel_size, j*self.stride: j*self.stride+self.kernel_size], axis=(2, 3))
return pooled_feature_map
def backward(self, top_grad):
pass
其中最关键的一个步骤是取待池化的 Feature map 某块的最大值,使用 np.max 完成。
接下来请你模仿最大池化的前向传播过程,实现一个包含了 forward 函数的平均池化封装类,最终返回池化后的结果。请牢记,平均池化和最大池化区别仅在于对每一块求平均值。
参考公式 (1)和最大池化实现,完成 NumPy 实现平均池化的前向传播过程。
使用 PyTorch 实现平均池化的前向传播
上面的挑战中,你已经学会了使用 NumPy 实现平均池化的前向传播过程。下面,我们看一看结果是否与 PyTorch 的计算结果一致。
使用 PyTorch 实现平均池化的前向传播。