53从 0 到 1 实现卷积神经网络--实现卷积神经网络

实现卷积神经网络

梯度检验

前面两个实验实现交叉熵损失函数、激活函数、线性层时,都是直接对其函数进行求导,在数学上称之为解析梯度 Analytic gradient,而在数学上梯度的真正定义是:


image.png

在这里还是以第一次实验中的一个简单的一元四次函数为例:


image.png

image.png
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(感知机) 算法。虽然现在来看,感知机的原理非常的简单,但是事实上现有的神经网络也只是感知机的复杂化而已。六十多年前,感知机正式揭开了神经网络的序幕。

image.png

Mark I Perceptron machine 只有一个摄像头,通过硫化物拍照产生 20*20 的图片,来识别字母。感知机所进行的操作也很简单,输出只有 0 和 1。
一直到现在的卷积神经网络得到深入研究和应用的时候,还需要归功于 LeCun、Hinton、Bengio 等大牛们的努力,这三个人是深度学习的开山鼻祖,现有的很多方向、基本算法都是有他们或者他们的学生提出来的。吴恩达也很厉害,但是和这三位相比也稍有不如,吴恩达更出名的是其向大众做科普的贡献,推动了这个行业的发展。
卷积神经网络的第一次应用,是 LeCun 在 1998 提出的 LeNet-5,引入了卷积、池化操作,最终应用在 document recognition,也就是手写体识别上。
image.png

LeNet-5 由堆叠的两层卷积、池化(下采样)和后续的线性层构成。这个模式也是现有神经网络普遍采用的结果,卷积操作之后进行下采样,然后在最后通过线性层进行分类或回归等。这个五层网络和现在的大型网络肯定比不了的,毕竟是二十年前的算法,现有的 CNN 都能堆出上百层。
卷积神经网络得到广泛应用的开端是 2012 年 Hinton 的学生 Alex Krizhevsky 在 ImageNet Large Scale Visual Recognition Challenge (ILSVRC),也就是常听到的 ImageNet 数据集上取得了比传统方法好得多的准确度。

AlexNet 网络结构示意图:

image.png

AlexNet 看起来和 LeNet-5 一样,输入是三通道的224∗224 的彩色图片,然后堆叠卷积、池化,和线性层,最后输出概率。
需要注意的是,除了网络加深之外,数据量也变得非常大了,输入的是3224224的彩色图片,而 MNIST 数据集才多少?的彩色图片,而MNIST数据集才多少?12828。在 2012 年,GPU 发展还没有现在这样大,Alex 使用两个 GTX 580(只有 3GB 显存),将参数分成两部分并行训练,所以现在看到的 AlexNet 网络示意图分为了上下两个分支。

现有的趋势就是网络加深,例如下图在 ILSVRC 比赛取得冠军的网络结构:


image.png

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 的窗口滑动,每次卷积所进行的操作非常简单,卷积核和输入数据的对应位置相乘相加对应到输出数据中,公式描述如下:

image.png

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。
image.png

image.png

其他的很容易理解,接下来我们介绍一下Padding 补齐的含义即可。
深度学习中的补齐,主要的目的是为了保存图像边缘的信息,或者使得输出数据的维度满足某种要求(例如输出和输入数据的维度相等)。补齐通常是指在输入数据的边缘补0,也叫做 Zero padding,如下图,在 7×7 的输入数据的边缘补齐一个像素,使其变成9×9:
image.png

当对齐采用 核大小为3×3、步长为 1 的卷积操作时,输出为7×7。关于如何计算卷积之后的输出数据高宽,通常使用如下公式:
image.png

括号所进行的操作为向下取整,例如对 1.5 向下取整为 1。
卷积的意义
讲了这么多,只是知道了卷积操作是如何进行的,但是卷积有什么用呢?接下来举一个简单的例子来理解卷积的含义。
image.png

这是一张 Lena 的图片,选择这张图片作为例子,演示卷积的作用。首先下载该图片:

!wget -nc "http://labfile.oss.aliyuncs.com/courses/1213/lena.png"

然后,我们会将图片转为灰度图,并使用下面的一个 3\times 33×3 的卷积核对整张图片进行卷积。


image.png

首先,转为灰度图:

!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)卷积整个数据。
所以再来回顾一次用公式表达卷积层所进行的操作:

image.png

这是上面介绍卷积时所用的公式,特定于输入通道为 1、步长为 1 的数据。假设输入通道为C,则卷积核的大小为C×M×N,步长为s,这时候输出的 Feature map 是单通道的,多个卷积核对应多个 Feature map。
同样的,梯度下降需要对三个变量关于损失函数求梯度,输入数据、权重、偏置。
关于偏置b求梯度非常容易:
image.png

而输出S关于偏置求偏导为 1,所以上式可以简化为:
image.png

结果很清晰,损失函数关于偏置的梯度为对应输出的 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 的最大池化。

image.png

右边显示了在对输入数据进行池化时所进行的操作及结果,和卷积类似的按步长窗口滑动。例如,第一个 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:

image.png

假设输入数据x=x1,x2,…,xi,…,xm,最大值函数:
image.png

因此,最大池化层的误差传播,只需要将上一层梯度分散到这一块最大值所在的位置。对于步长不等于核大小的最大池化,利用链式法则只需要将误差累加即可。同样和卷积层的实现类似,反向传播时只需要再执行一遍池化的过程,将误差分散开来即可。注意,池化层没有需要学习的参数,所以只需要对输入数据求梯度。
下面是整合了前向传播和后向传播的最大池化层代码:

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 中的实现只有线性层有激活函数。因此整个的网络结构如图:

image.png

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 实现平均池化的前向传播。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,539评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,911评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,337评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,723评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,795评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,762评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,742评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,508评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,954评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,247评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,404评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,104评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,736评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,352评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,557评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,371评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,292评论 2 352

推荐阅读更多精彩内容