1. 计算机视觉的发展历程
计算机视觉作为一门让机器学会如何去“看”的学科,具体的说,就是让机器去识别摄像机拍摄的图片或视频中的物体,检测出物体所在的位置,并对目标物体进行跟踪,从而理解并描述出图片或视频里的场景和故事,以此来模拟人脑视觉系统。因此,计算机视觉也通常被叫做机器视觉,其目的是建立能够从图像或者视频中“感知”信息的人工系统。
计算机视觉技术经过几十年的发展,已经在交通(车牌识别、道路违章抓拍)、安防(人脸闸机、小区监控)、金融(刷脸支付、柜台的自动票据识别)、医疗(医疗影像诊断)、工业生产(产品缺陷自动检测)等多个领域应用,影响或正在改变人们的日常生活和工业生产方式。未来,随着技术的不断演进,必将涌现出更多的产品和应用,为我们的生活创造更大的便利和更广阔的机会。
飞桨为计算机视觉任务提供了丰富的 API,并通过底层优化和加速保证了这些 API 的性能。同时,飞桨还提供了丰富的模型库,覆盖图像分类、检测、分割、文字识别和视频理解等多个领域。用户可以直接使用这些 API 组建模型,也可以在飞桨提供的模型库基础上进行二次研发。
由于篇幅所限,本章将重点介绍计算机视觉的经典模型(卷积神经网络)和两个典型任务(图像分类和目标检测)。主要涵盖如下内容:
- 卷积神经网络:卷积神经网络(Convolutional Neural Networks, CNN)是计算机视觉技术最经典的模型结构。本教程主要介绍卷积神经网络的常用模块,包括:卷积、池化、激活函数、批归一化、丢弃法等。
- 图像分类:介绍图像分类算法的经典模型结构,包括:LeNet、AlexNet、VGG、GoogLeNet、ResNet,并通过眼疾筛查的案例展示算法的应用。
- 目标检测:介绍目标检测 YOLOv3 算法,并通过林业病虫害检测案例展示 YOLOv3 算法的应用。
计算机视觉的发展历程要从生物视觉讲起。对于生物视觉的起源,目前学术界尚没有形成定论。有研究者认为最早的生物视觉形成于距今约 7 亿年前的水母之中,也有研究者认为生物视觉产生于距今约 5 亿年前寒武纪。寒武纪生物大爆发的原因一直是个未解之谜,不过可以肯定的是在寒武纪动物具有了视觉能力,捕食者可以更容易地发现猎物,被捕食者也可以更早的发现天敌的位置。视觉能力加剧了猎手和猎物之间的博弈,也催生出更加激烈的生存演化规则。视觉系统的形成有力地推动了食物链的演化,加速了生物进化过程,是生物发展史上重要的里程碑。经过几亿年的演化,目前人类的视觉系统已经具备非常高的复杂度和强大的功能,人脑中神经元数目达到了 1000 亿个,这些神经元通过网络互相连接,这样庞大的视觉神经网络使得我们可以很轻松的观察周围的世界,如下图所示。
对人类来说,识别猫和狗是件非常容易的事。但对计算机来说,即使是一个精通编程的高手,也很难轻松写出具有通用性的程序(比如:假设程序认为体型大的是狗,体型小的是猫,但由于拍摄角度不同,可能一张图片上猫占据的像素比狗还多)。那么,如何让计算机也能像人一样看懂周围的世界呢?研究者尝试着从不同的角度去解决这个问题,由此也发展出一系列的子任务,如 下图所示。
- 图像分类(Image Classification):用于识别图像中物体的类别(如:bottle、cup、cube)。
- 目标监测(Object Detection):目标检测,用于检测图像中每个物体的类别,并准确标出它们的位置。
- 图像语义分割(Semantic Segmentation):,用于标出图像中每个像素点所属的类别,属于同一类别的像素点用一个颜色标识。
- 实例分割(Instance Segmentation): 值得注意的是,2 中的目标检测任务只需要标注出物体位置,而 4 中的实例分割任务不仅要标注出物体位置,还需要标注出物体的外形轮廓。
在早期的图像分类任务中,通常是先人工提取图像特征,再用机器学习算法对这些特征进行分类,分类的结果强依赖于特征提取方法,往往只有经验丰富的研究者才能完成,如下图所示。
在这种背景下,基于神经网络的特征提取方法应运而生。Yann LeCun 是最早将卷积神经网络应用到图像识别领域的,其主要逻辑是使用卷积神经网络提取图像特征,并对图像所属类别进行预测,通过训练数据不断调整网络参数,最终形成一套能自动提取图像特征并对这些特征进行分类的网络,如下图所示。
这一方法在手写数字识别任务上取得了极大的成功,但在接下来的时间里,却没有得到很好的发展。其主要原因一方面是数据集不完善,只能处理简单任务,在大尺寸的数据上容易发生过拟合;另一方面是硬件瓶颈,网络模型复杂时,计算速度会特别慢。
目前,随着互联网技术的不断进步,数据量呈现大规模的增长,越来越丰富的数据集不断涌现。另外,得益于硬件能力的提升,计算机的算力也越来越强大。不断有研究者将新的模型和算法应用到计算机视觉领域。由此催生了越来越丰富的模型结构和更加准确的精度,同时计算机视觉所处理的问题也越来越丰富,包括分类、检测、分割、场景描述、图像生成和风格变换等,甚至还不仅仅局限于 2 维图片,包括视频处理技术和 3D 视觉等。
2. 卷积神经网络(Convolutional Neural Networks, CNN)
卷积神经网络是目前计算机视觉中使用最普遍的模型结构。本章节主要介绍卷积神经网络的一些基础模块,包括:
- 卷积(Convolution)
- 池化(Pooling)
- ReLU 激活函数
- 批归一化(Batch Normalization)
- 丢弃法(Dropout)
回顾一下,在之前我们介绍了手写数字识别任务,里面有两个模型,第一个就是全连接网络进行特征提取,代码如下:
# 全连接层神经网络实现
class MNIST_FC_Model(nn.Layer):
def __init__(self):
super(MNIST_FC_Model, self).__init__()
# 定义两层全连接隐含层,输出维度是10,当前设定隐含节点数为10,可根据任务调整
self.classifier = nn.Sequential(nn.Linear(in_features=784, out_features=256),
nn.Sigmoid(),
nn.Linear(in_features=256, out_features=64),
nn.Sigmoid())
# 定义一层全连接输出层,输出维度是1
self.head = nn.Linear(in_features=64, out_features=10)
def forward(self, x):
# x.shape: [bath size, 1, 28, 28]
x = paddle.flatten(x, start_axis=1) # [bath size, 784]
x = self.classifier(x)
y = self.head(x)
return y
我们看到,在 forward
函数中,我们首先需要将图片展平为一维向量之后再输入特征提取层(classifier
),但这样会存在如下两个问题:
- 输入数据的空间信息被丢失。 空间上相邻的像素点往往具有相似的 RGB 值,RGB 的各个通道之间的数据通常密切相关,但是转化成 1 维向量时,这些信息被丢失。同时,图像数据的形状信息中,可能隐藏着某种本质的模式,但是转变成 1 维向量输入全连接神经网络时,这些模式也会被忽略。
- 模型参数过多,容易发生过拟合。 在手写数字识别案例中,每个像素点都要跟所有输出的神经元相连接。当图片尺寸变大时,输入神经元的个数会按图片尺寸的平方增大,导致模型参数过多,容易发生过拟合。
为了解决上述问题,我们引入卷积神经网络(CNN)进行特征提取,代码如下:
# 多层卷积神经网络实现
class MNIST_CNN_Model(nn.Layer):
def __init__(self):
super(MNIST_CNN_Model, self).__init__()
self.classifier = nn.Sequential(
nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2),
nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),
nn.ReLU(),
nn.MaxPool2D(kernel_size=2, stride=2))
self.head = nn.Linear(in_features=980, out_features=args.num_classes)
def forward(self, x):
# x.shape: [10, 1, 28, 28]
x = self.classifier(x) # [bath size, 20, 7, 7]
x = x.flatten(1) # [batch size, 980]
x = self.head(x) # [batch size, num_classes]
return x
我们发现,CNN 网络不需要先对图片进行展平操作,可以直接对图片进行特征提取。这样做既能提取到相邻像素点之间的特征模式,又能保证参数的个数不随图片尺寸变化。下图是一个典型的卷积神经网络结构,多层卷积和池化层组合作用在输入图片上,在网络的最后通常会加入一系列全连接层,ReLU 激活函数一般加在卷积或者全连接层的输出上,网络中通常还会加入 Dropout 来防止过拟合。
还需要说明的一点就是:在 CNN 中,计算范围是在像素点的空间邻域内进行的,卷积核参数的数目也远小于全连接层。卷积核本身与输入图片大小无关,它代表了对空间邻域内某种特征模式的提取。比如,有些卷积核提取物体边缘特征,有些卷积核提取物体拐角处的特征,图像上不同区域共享同一个卷积核。当输入图片大小不一样时,仍然可以使用同一个卷积核进行操作。
3. 卷积(Convolution)
这一小节将介绍卷积算法的原理和实现方案,并通过具体的案例展示如何使用卷积对图片进行操作,主要涵盖如下内容:
- 卷积计算
- 填充(padding)
- 步幅(stride)
- 感受野(Receptive Field)
- 多输入通道、多输出通道和批量操作
- 飞桨卷积 API 介绍
- 卷积算子应用举例
3.1 卷积计算
卷积是数学分析中的一种积分变换的方法,在图像处理中采用的是卷积的离散形式。这里需要说明的是,在卷积神经网络中,卷积层的实现方式实际上是数学中定义的互相关 (Cross-Correlation)运算,与数学分析中的卷积定义有所不同,这里跟其他框架和卷积神经网络的教程保持一致,都使用互相关运算作为卷积的定义,具体的计算过程如下图所示。
互相关(cross-correlation)是一种在信号处理和图像处理中常用的操作,用于衡量两个信号之间的相似性。在数学上,互相关表示两个函数之间的一种比较,通常用来找出一个信号中的某种模式在另一个信号中的位置。
给定两个离散信号 (x) 和 (y),它们的互相关可以通过以下公式来计算:其中, 和 分别是信号 和 在不同位置上的取值,
互相关的计算过程可以理解为,将一个信号 在时间上滑动,与另一个信号 进行点乘 并求和 ,得到一个新的信号,表示在不同位置上两个信号的相似程度。如果在某个位置上两个信号的形状相似,那么互相关结果的值会较大。
在图像处理中,互相关可以用来在一个图像中寻找另一个图像的匹配模式。在深度学习中,卷积操作实际上就是互相关的一种形式,用来在图像中提取特征。
说明:
- 卷积核(kernel)也被叫做滤波器(filter),假设卷积核的高和宽分别为 和 ,则将称为
卷积,比如 卷积,就是指卷积核的高为 3, 宽为 5。 - 在卷积神经网络中,一个卷积算子除了上面描述的卷积过程之外,还包括加上偏置项的操作。例如假设偏置为 1,则上面卷积计算的结果为:
3.2 填充(Padding)
在上面的例子中,输入图片尺寸为 ,输出图片尺寸为 ,经过一次卷积之后,图片尺寸变小。卷积输出特征图的尺寸计算方法如下(卷积核的高和宽分别为 和 ):
如果输入尺寸为 4,卷积核大小为 3 时,输出尺寸为 。我们可以自行检查当输入图片和卷积核为其他尺寸时,上述计算式是否成立。当卷积核尺寸大于 1 时,输出特征图的尺寸会小于输入图片尺寸。如果经过多次卷积,输出图片尺寸会不断减小。为了避免卷积之后图片尺寸变小,通常会在图片的外围进行填充(padding),如下图所示。
如上图所示:
- 填充的大小为 1,填充值为 0。填充之后,输入图片尺寸从 变成了 ,使用 的卷积核,输出图片尺寸为 。
- 填充的大小为 2,填充值为 0。填充之后,输入图片尺寸从 变成了 ,使用 的卷积核,输出图片尺寸为 。
如果在图片高度方向,在第一行之前填充 行,在最后一行之后填充 行;在图片的宽度方向,在第 1 列之前填充 列,在最后 1 列之后填充 列;则填充之后的图片尺寸为 。经过大小为
在卷积计算过程中,通常会在高度或者宽度的两侧采取等量填充,即 & ,上面计算公式也就变为:
卷积核大小通常使用 这样的奇数,如果使用的填充大小为 ,,则卷积之后图像尺寸不变。例如当卷积核大小为 3 时,padding 大小为 1,卷积之后图像尺寸不变;同理,如果卷积核大小为 5,padding 大小为 2,也能保持图像尺寸不变。
3.3 步幅 / 步长(Stride)
上面那张图中卷积核每次滑动一个像素点,这是步幅为 1 的特殊情况。下图是步幅为 2 的卷积过程,卷积核在图片上移动时,每次移动大小为 2 个像素点。
当宽和高方向的步幅分别为 和
假设输入图片尺寸是 ,卷积核大小 ,填充 ,步幅为 ,则输出特征图的尺寸为:
3.4 感受野(Receptive Field)
输出特征图上每个点的数值,是由输入图片上大小为 的区域的元素与卷积核每个元素相乘再相加得到的,所以输入图像上
切记,感受野一般都是说输入图片
感受野内每个元素数值的变动,都会影响输出点的数值变化。比如 卷积对应的感受野大小就是 ,如下图所示。
而当通过两层 的卷积之后,感受野的大小将会增加到 ,如下图所示。
因此,当增加卷积网络深度的同时,感受野将会增大,输出特征图中的一个像素点将会包含更多的图像语义信息。
随着网络的加深,特征图的会越来越小,那么感受野也会越来越大。就拿上面这个图举例子,输出特征图 2 的感受野为 ,意思就是说,输出特征图 2 上的一点包含、融合了原始输入图片上
3.5 多输入通道、多输出通道和批量操作
前面介绍的卷积计算过程比较简单,实际应用时,处理的问题要复杂的多。例如:对于彩色图片有 RGB 三个通道,需要处理多输入通道的场景。输出特征图往往也会具有多个通道,而且在神经网络的计算中常常是把一个批次的样本放在一起计算,所以卷积算子需要具有批量处理多输入和多输出通道数据的功能,下面将分别介绍这几种场景的操作方式。
3.5.1 多输入通道场景
上面的例子中,卷积层的数据是一个 2 维数组,但实际上一张图片往往含有 RGB 三个通道,要计算卷积的输出结果,卷积核的形式也会发生变化。假设输入图片的通道数为 ,输入数据的形状是 ,计算过程如下图所示。
- 对每个通道分别设计一个 2 维数组作为卷积核,卷积核数组的形状是 。
- 对任一通道 ,分别用大小为 的卷积核在大小为
- 将这 个通道的计算结果相加,得到的是一个形状为
3.5.2 多输出通道场景
一般来说,卷积操作的输出特征图也会具有多个通道 ,这时我们需要设计 个维度为 的卷积核,卷积核数组的维度是 ,如下图所示。
- 对任一输出通道 ,分别使用上面描述的形状为
- 将这 个形状为 的二维数组拼接在一起,形成维度为
注意❗️:通常将卷积核的输出通道数叫做卷积核的个数。
3.5.3 批量操作(Batch)
在卷积神经网络的计算中,通常将多个样本放在一起形成一个 mini-batch 进行批量操作,即输入数据的维度是 。由于会对每张图片使用同样的卷积核进行卷积操作,卷积核的维度与上面多输出通道的情况一样,仍然是 ,输出特征图的维度是 ,如下图所示。
3.6 PaddlePaddle 卷积的 API 介绍
飞桨卷积算子对应的 API 是 paddle.nn.Conv2D
,用户可以直接调用 API 进行计算,也可以在此基础上修改。Conv2D
名称中的 “2D” 表明卷积核是二维的,多用于处理图像数据。类似的,也有 Conv3D
可以用于处理视频数据(图像的序列)。
需要注意的是,在 PyTorch 中卷积的 API 为:
torch.nn.Conv2d
,其中维度 D 是小写的。
class paddle.nn.Conv2D (in_channels, out_channels,
kernel_size, stride=1, padding=0,
dilation=1, groups=1, padding_mode=‘zeros’,
weight_attr=None, bias_attr=None, data_format=‘NCHW’)
常用的参数如下:
-
in_channels
(int):输入图像的通道数。 -
out_channels
(int):卷积核的个数,和输出特征图通道数相同,相当于上文中的 。 -
kernel_size
(int | list | tuple):卷积核大小,可以是整数,比如 3,表示卷积核的高和宽均为 3 ;或者是两个整数的 list,例如 [3,2],表示卷积核的高为 3,宽为 2。 -
stride
(int | list | tuple,可选):步长大小,可以是整数,默认值为 1,表示垂直和水平滑动步幅均为 1;或者是两个整数的 list,例如 [3,2],表示垂直滑动步幅为 3,水平滑动步幅为 2。 - padding (int | list | tuple,可选):填充大小,可以是整数,比如 1,表示竖直和水平边界填充大小均为 1;或者是两个整数的 list,例如 [2,1],表示竖直边界填充大小为 2,水平边界填充大小为 1。
使用元素对 kernel_size 进行赋值时,也是遵循 先 H 后 W 的。
形状总结:
- 输入数据维度
- 输出数据维度
- 权重参数 (卷积核参数 ):
- 偏置参数 :
注意❗️:即使输入只有一张灰度图片 ,也需要处理成四个维度的输入向量 。
3.7 卷积算子 paddle.nn.Conv2D
应用举例
下面介绍卷积算子 paddle.nn.Conv2D
在图片中应用的三个案例,并观察其计算结果。
3.7.1 案例 1 —— 简单的黑白边界检测
下面是使用 Conv2D
算子完成一个图像边界检测的任务。图像左边为光亮部分,右边为黑暗部分,需要检测出光亮跟黑暗的分界处。
设置宽度方向的卷积核参数为 ,此卷积核会将宽度方向间隔为 1 的两个像素点的数值相减。当卷积核在图片上滑动时,如果它所覆盖的像素点位于亮度相同的区域,则左右间隔为 1 的两个像素点数值的差为 0。只有当卷积核覆盖的像素点有的处于光亮区域,有的处在黑暗区域时,左右间隔为 1 的两个点像素值的差才不为 0。将此卷积核作用到图片上,输出特征图上只有对应黑白分界线的地方像素值才不为 0。具体代码如下所示,结果输出在下方的图案中。
import matplotlib.pyplot as plt
import numpy as np
import paddle
import paddle.nn as nn
from paddle.nn.initializer import Assign
if __name__ == "__main__":
# 创建初始化权重参数 w
w = np.array([1, 0, -1], dtype="float32")
# 将权重矩阵调整为 卷积核 的样式 -> [C_out, c_in, k_h, k_w]
w = w.reshape(1, 1, 1, 3)
# 创建卷积算子,设置输出通道数、卷积核大小和初始化权重参数
# 创建卷积算子的时候,通过参数属性weight_attr指定参数初始化方式
conv = nn.Conv2D(in_channels=1, out_channels=1,
kernel_size=(1, 3), # k_h = 1, k_w = 3
padding=0, stride=1,
weight_attr=paddle.ParamAttr(initializer=Assign(value=w)))
# 创建输入图片,图片左边的像素点取值为1,右边的像素点取值为0
img = np.ones(shape=[50, 50], dtype="float32")
img[:, 30:] = 0.0
# 调整图片尺寸以符合Conv2D的输入要求
x = img.reshape(1, 1, 50, 50)
# 将 ndarray 转换为 tensor
x = paddle.to_tensor(x)
# 使用卷积对输入图片进行特征提取
out = conv(x)
# 将 tensor 转换为 ndarray 以方便我们画图
out = out.numpy()
# 开始画图
fig, axes = plt.subplots(1, 2, dpi=100)
axes[0].imshow(img, cmap="gray")
axes[0].set_title("origin image")
axes[1].imshow(np.squeeze(out), cmap='gray')
axes[1].set_title("convolved image")
plt.show()
3.7.2 案例 2 —— 图像中物体边缘检测
上面展示的是一个人为构造出来的简单图片,使用卷积网络检测图片明暗分界处的示例。对于真实的图片,也可以使用合适的卷积核(
import matplotlib.pyplot as plt
import numpy as np
import paddle
import paddle.nn as nn
from paddle.nn.initializer import Assign
from PIL import Image
if __name__ == "__main__":
# 创建初始化权重参数 w
w = np.array([[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]], dtype="float32") / 8
# 将权重矩阵调整为 卷积核 的样式 -> [C_out, c_in, k_h, k_w]
w = w.reshape(1, 1, 3, 3)
# 由于输入通道数是3,因此需要调整卷积核的通道数,与输入图片一致
w = np.repeat(w, repeats=3, axis=1) # 沿着通道方向重复3次
# 创建卷积算子,设置输出通道数、卷积核大小和初始化权重参数
# 创建卷积算子的时候,通过参数属性weight_attr指定参数初始化方式
conv = nn.Conv2D(in_channels=3, out_channels=1,
kernel_size=(3, 3), # k_h = 1, k_w = 3
padding=0, stride=1,
weight_attr=paddle.ParamAttr(initializer=Assign(value=w)))
# 读取输入图片
img = Image.open("Tom_and_Jerry.jpg")
# 转换图片格式
x = np.array(img, dtype="float32")
# 调整图片形状以符合Conv2D的输入要求
# [H, W, C] -> [C, H, W]
x = np.transpose(x, [2, 0, 1])
# 添加Batch维度
x = x.reshape(1, 3, img.height, img.width)
# 将 ndarray 转换为 tensor
x = paddle.to_tensor(x)
# 使用卷积对输入图片进行特征提取
out = conv(x)
# 将 tensor 转换为 ndarray 以方便我们画图
out = out.numpy()
# 开始画图
fig, axes = plt.subplots(1, 2, dpi=100)
axes[0].imshow(img)
axes[0].set_title("origin image")
axes[1].imshow(np.squeeze(out), cmap="gray")
axes[1].set_title("convolved image")
plt.savefig("应用2.png", dpi=300)
plt.show()
3.7.3 案例 3 —— 图像均值模糊
另外一种比较常见的卷积核(
import matplotlib.pyplot as plt
import numpy as np
import paddle
import paddle.nn as nn
from paddle.nn.initializer import Assign
from PIL import Image
if __name__ == "__main__":
# 创建初始化权重参数 w
w = np.ones([1, 1, 5, 5], dtype="float32") / 25 # [C_out, c_in, k_h, k_w]
# 创建卷积算子,设置输出通道数、卷积核大小和初始化权重参数
# 创建卷积算子的时候,通过参数属性weight_attr指定参数初始化方式
conv = nn.Conv2D(in_channels=1, out_channels=1,
kernel_size=(5, 5), # k_h = 1, k_w = 3
padding=0, stride=1,
weight_attr=paddle.ParamAttr(initializer=Assign(value=w)))
# 读取输入图片
img = Image.open("Tom_and_Jerry.jpg").convert("L")
# 转换图片格式
img = np.array(img, dtype="float32")
# 调整图片形状以符合Conv2D的输入要求
# [H, W] -> [C, H, W]
x = img.reshape(1, 1, img.shape[0], img.shape[1])
# 将 ndarray 转换为 tensor
x = paddle.to_tensor(x)
# 使用卷积对输入图片进行特征提取
out = conv(x)
# 将 tensor 转换为 ndarray 以方便我们画图
out = out.numpy()
# 开始画图
fig, axes = plt.subplots(1, 2, dpi=100)
axes[0].imshow(img, cmap="gray")
axes[0].set_title("origin image")
axes[1].imshow(np.squeeze(out), cmap="gray")
axes[1].set_title("convolved image")
plt.savefig("应用3.png", dpi=300)
plt.show()
4. 池化(Pooling)
池化是使用某一位置的相邻输出的总体统计特征代替网络在该位置的输出,其好处是当输入数据做出少量平移时,经过池化函数后的大多数输出还能保持不变。比如:当识别一张图像是否是人脸时,我们需要知道人脸左边有一只眼睛,右边也有一只眼睛,而不需要知道眼睛的精确位置,这时候通过池化某一片区域的像素点来得到总体统计特征会显得很有用。由于池化之后特征图会变得更小,如果后面连接的是全连接层,能有效的减小神经元的个数,节省存储空间并提高计算效率。 如下图所示,将一个
- 图(a):平均池化(Average Pooling)。这里使用大小为
- 图(b):最大池化(Max Pooling)。对池化窗口覆盖区域内的像素取最大值,得到输出特征图的像素值。
当池化窗口在图片上滑动时,会得到整张输出特征图。池化窗口的大小称为池化大小,用 表示。在卷积神经网络中用的比较多的是窗口大小为 ,步幅为 2 的池化。
与卷积核类似,池化窗口在图片上滑动时,每次移动的步长称为步幅,当宽和高方向的移动大小不一样时,分别用 表示。也可以对需要进行池化的图片进行填充,填充方式与卷积类似,假设在第一行之前填充 行,在最后一行后面填充 行。在第一列之前填充 列,在最后一列之后填充
在卷积神经网络中,通常使用
通过这种方式的池化,输出特征图的高和宽都减半,但通道数不会改变。
5. ReLU激活函数
5.1 Sigmoid 和 ReLU 激活函数对比
前面介绍的网络结构中,普遍使用 Sigmoid 函数做激活函数。在神经网络发展的早期,Sigmoid 函数用的比较多,而目前用的较多的激活函数是 ReLU。这是因为 Sigmoid 函数在反向传播过程中,容易造成梯度的衰减。让我们仔细观察 Sigmoid 函数的形式,就能发现这一问题。
Sigmoid 激活函数定义如下:
ReLU 激活函数的定义如下:
下面的程序画出了 Sigmoid 和 ReLU 函数的曲线图:
import numpy as np
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 5))
# 创建数据x
x = np.arange(-10, 10, 0.1)
# 计算Sigmoid函数
s = 1.0 / (1 + np.exp(0. - x))
# 计算ReLU函数
y = np.clip(x, a_min=0., a_max=None)
# 以下部分为画图代码
f = plt.subplot(121)
plt.plot(x, s, color='r')
currentAxis=plt.gca()
plt.text(-9.0, 0.9, r'$y=Sigmoid(x)$', fontsize=13)
currentAxis.xaxis.set_label_text('x', fontsize=15)
currentAxis.yaxis.set_label_text('y', fontsize=15)
f = plt.subplot(122)
plt.plot(x, y, color='g')
plt.text(-3.0, 9, r'$y=ReLU(x)$', fontsize=13)
currentAxis=plt.gca()
currentAxis.xaxis.set_label_text('x', fontsize=15)
currentAxis.yaxis.set_label_text('y', fontsize=15)
plt.savefig("两种激活函数对比.png")
plt.show()
5.2 梯度消失现象
在神经网络里,将经过反向传播之后,梯度值衰减到接近于零的现象称作梯度消失现象。
从上面的函数曲线可以看出,当 为较大的正数的时候,Sigmoid 函数数值非常接近于 1,函数曲线变得很平滑,在这些区域 Sigmoid 函数的导数接近于零。当 为较小的负数时,Sigmoid 函数值也非常接近于 0,函数曲线也很平滑,在这些区域 Sigmoid 函数的导数也接近于 0。只有当
从上面的式子可以看出,Sigmoid 函数的导数 最大值为 。前向传播时, ;而在反向传播过程中, 的梯度等于
使得 的梯度数值最大也不会超过 的梯度的 。
由于最开始是将神经网络的参数随机初始化的, 的取值很有可能在很大或者很小的区域,这些地方都可能造成 Sigmoid 函数的导数接近于 0,导致 的梯度接近于 0;即使 取值在接近于 0 的地方,按上面的分析,经过 Sigmoid 函数反向传播之后,的梯度不超过 的梯度的 ,如果有多层网络使用了 Sigmoid 激活函数,则比较靠后的那些层梯度将衰减到非常小的值。
ReLU 函数则不同,虽然在 的地方,ReLU 函数的导数为 0。但是在 的地方,ReLU 函数的导数为 1,能够将 的梯度完整的传递给 ,而不会引起梯度消失。
6. 批归一化(Batch Normalization)
批归一化方法(Batch Normalization,BatchNorm)是由 Ioffe 和 Szegedy 于 2015 年提出的,已被广泛应用在深度学习中,其目的是对神经网络中间层的输出进行标准化处理,使得中间层的输出更加稳定。
通常我们会对神经网络的数据进行标准化处理,处理后的样本数据集满足均值 为 0,方差 为1的统计分布,这是因为当输入数据的分布比较固定时,有利于算法的稳定和收敛。对于深度神经网络来说,由于参数是不断更新的,即使输入数据已经做过标准化处理,但是对于比较靠后的那些层,其接收到的输入仍然是剧烈变化的,通常会导致数值不稳定,模型很难收敛。BatchNorm 能够使神经网络中间层的输出变得更加稳定,并有如下三个优点:
- 使学习快速进行(能够使用较大的学习率)
- 降低模型对初始值的敏感性
- 从一定程度上抑制过拟合
BatchNorm 主要思路是在训练时以 mini-batch 为单位,对神经元的数值进行归一化,使数据的分布满足均值 为 0,方差
6.1 第一步:计算 mini-batch 内样本的均值
其中 表示 mini-batch中的第
例如输入 mini-batch 包含 3 个样本,每个样本有 2 个特征,分别是:
对每个特征分别计算 mini-batch 内样本的均值:
则样本均值是:
求均值是按特征维度进行的
6.2 第二步:计算 mini-batch 内样本的方差
上面的计算公式先计算一个批次内样本的均值 和方差 ,然后再对输入数据做归一化,将其调整成均值为 0,方差为 1 的分布。
对于上述给定的输入数据 ,可以计算出每个特征对应的方差:
则样本方差是:
求方差也是按特征维度进行的
6.3 计算标准化之后的输出
其中 是一个微小值(例如 1e−7
),其主要作用是为了防止分母为 0。
对于上述给定的输入数据 ,可以计算出标准化之后的输出:
我们可以验证一下 输入数据
import numpy as np
def calc_mean_and_var(data):
# 计算均值
mean = np.mean(data, axis=0)
# 计算方差
variance = np.var(data, axis=0)
return mean, variance
def normalization(data, mean, var):
return (data - mean) / np.sqrt(var)
if __name__ == "__main__":
# 定义输入数据
origin_data = np.array([[1, 2], [3, 6], [5, 10]])
mean, var = calc_mean_and_var(origin_data)
print("[原始数据] 均值:", mean)
print("[原始数据] 方差:", var)
# 求归一化的数据
normalization_data = normalization(origin_data, mean, var)
print("归一化后的数据:\n", normalization_data)
# 验证过归一化数据是否符合均值为0方差为1
mean_norm, var_norm = calc_mean_and_var(normalization_data)
print("[归一化] 均值:", mean_norm)
print("[归一化] 方差:", var_norm)
结果:
[原始数据] 均值: [3. 6.]
[原始数据] 方差: [ 2.66666667 10.66666667]
归一化后的数据:
[[-1.22474487 -1.22474487]
[ 0. 0. ]
[ 1.22474487 1.22474487]]
[归一化] 均值: [0. 0.]
[归一化] 方差: [1. 1.]
如果强行限制输出层的分布是标准化的,可能会导致某些特征模式的丢失,所以在标准化之后,BatchNorm 会紧接着对数据做缩放和平移。
其中 和 是可学习的参数,可以赋初始值 ,在训练过程中不断学习调整。
上面列出的是 BatchNorm 方法的计算逻辑,下面针对两种类型的输入数据格式分别进行举例。PaddlePaddle 支持输入数据的维度大小为 2、3、4、5 四种情况,这里给出的是维度大小为 2 和 4 的示例。
6.4 示例
6.4.1 示例一: 当输入数据形状是
当输入数据形状是 时,一般对应全连接层的输出。这种情况下会分别对 的每一个分量计算
输入 | Shape |
均值 | |
方差 | |
缩放参数 | |
平移参数 |
示例代码如下所示:
import numpy as np
import paddle
import paddle.nn as nn
def calc_mean_and_var(data):
# 计算均值
mean = np.mean(data, axis=0)
# 计算方差
variance = np.var(data, axis=0)
return mean, variance
if __name__ == "__main__":
# 定义数据
data = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype="float32") # [N, C]
# 使用 BN 计算归一化后的输出
bn = nn.BatchNorm1D(num_features=3) # 参数为通道数
x = paddle.to_tensor(data)
y = bn(x).numpy() # [N, C]
print(f"BN 层的输出为:\n{y}\n其shape为: {y.shape}")
# 验证
mean, var = calc_mean_and_var(y)
print(f"BN 后的均值为: {mean}, 其shape为: {mean.shape}") # [C, ]
print(f"BN 后的方差为: {var}, 其shape为: {var.shape}") # [C]
print(f"BN 的缩放参数为: {bn.weight}") # [C, ]
print(f"BN 的平移参数为: {bn.bias}") # [C, ]
BN 层的输出为:
[[-1.2247438 -1.2247438 -1.2247438]
[ 0. 0. 0. ]
[ 1.2247438 1.2247438 1.2247438]]
其shape为: (3, 3)
BN 后的均值为: [0. 0. 0.], 其shape为: (3,)
BN 后的方差为: [0.99999833 0.99999833 0.99999833], 其shape为: (3,)
BN 的缩放参数为: Parameter containing: Tensor(shape=[3], dtype=float32, place=Place(gpu:0), stop_gradient=False, [1., 1., 1.])
BN 的平移参数为: Parameter containing: Tensor(shape=[3], dtype=float32, place=Place(gpu:0), stop_gradient=False, [0., 0., 0.])
6.4.1 示例二: 当输入数据形状是
当输入数据形状是 时, 一般对应卷积层的输出,这种情况下会沿着 这一维度进行展开,分别对每一个通道计算 个样本中总共
输入 | Shape |
均值 | |
方差 | |
缩放参数 | |
平移参数 |
可能有人会问:“BatchNorm 里面不是还要对标准化之后的结果做仿射变换吗,怎么使用 Numpy 计算的结果与 BatchNorm 算子一致?” 这是因为 BatchNorm 算子里面自动设置初始值 (上面代码的结果中我们就可以看到了),这时候仿射变换相当于是恒等变换。在训练过程中这两个参数会不断的学习,这时仿射变换就会起作用。
示例代码如下所示。
import numpy as np
import paddle
import paddle.nn as nn
def calc_mean_and_var(data):
# 计算均值
mean = np.mean(data, axis=0)
# 计算方差
variance = np.var(data, axis=0)
return mean, variance
if __name__ == "__main__":
paddle.seed(100)
np.random.seed(100)
# 定义数据
data = np.random.random((10, 3, 64, 64)).astype("float32") # [N, C, H, W]
print(data.shape) # (10, 3, 64, 64)
# 使用 BN 计算归一化后的输出
bn = nn.BatchNorm2D(num_features=3) # 参数为通道数
x = paddle.to_tensor(data)
y = bn(x).numpy() # [N, C, H, W]
print(f"BN 层的输出.shape为: {y.shape}")
print(f"BN 层的输出.shape为: {y.shape}")
mean, var = calc_mean_and_var(y)
print(f"BN 后的均值.shape为: {mean.shape}") # [C, ]
print(f"BN 后的方差.shape为: {var.shape}") # [C, ]
print(f"BN 的缩放参数.shape: {bn.weight.shape}") # [C, ]
print(f"BN 的平移参数为.shape: {bn.bias.shape}") # [C, ]
结果:
BN 层的输出.shape为: (10, 3, 64, 64)
BN 层的输出.shape为: (10, 3, 64, 64)
BN 后的均值.shape为: (3, 64, 64)
BN 后的方差.shape为: (3, 64, 64)
BN 的缩放参数.shape: [3]
BN 的平移参数为.shape: [3]
提示:这里通过
numpy
计算出来的输出与BatchNorm2D
算子的结果略有差别,因为在BatchNorm2D
算子为了保证数值的稳定性,在分母里面加上了一个比较小的浮点数epsilon=1e-05
。
6.5 预测时使用BatchNorm
上面介绍了在训练过程中使用 BatchNorm 对一批样本进行归一化的方法,但如果使用同样的方法对需要预测的一批样本进行归一化,则预测结果会出现不确定性。
例如样本 A、样本 B 作为一批样本计算均值和方差,与样本 A、样本 C 和样本 D 作为一批样本计算均值和方差,得到的结果一般来说是不同的。那么样本 A 的预测结果就会变得不确定,这对预测过程来说是不合理的。解决方法是在训练过程中将大量样本的均值和方差保存下来,预测时直接使用保存好的值而不再重新计算。实际上,在 BatchNorm 的具体实现中,训练时会计算均值和方差的移动平均值。在 PaddlePaddle 中,默认是采用如下方式计算:
在训练过程的最开始将 和 设置为 0,每次输入一批新的样本,计算出 和 ,然后通过上面的公式更新 和 ,在训练的过程中不断的更新它们的值,并作为 BatchNorm 层的参数保存下来。预测的时候将会加载参数 和 ,用他们来代替 和 。
7. 丢弃法(Dropout)
丢弃法(Dropout)是深度学习中一种常用的抑制过拟合的方法,其做法是在神经网络学习过程中,随机删除一部分神经元。训练时,随机选出一部分神经元,将其输出设置为 0,这些神经元将不对外传递信号。
下图是 Dropout 示意图,左边是完整的神经网络,右边是应用了 Dropout 之后的网络结构。应用 Dropout 之后,会将标了
Q1:Dropout 操作是对输入特征图进行的吗?
A1:是的,Dropout 操作是对输入特征图进行的。
Q2:如果是一张图片送入网络,那么 Dropout 会怎么丢弃呢?丢弃像素点?
A2:是的,当一张图片作为输入送入神经网络时,Dropout 操作会随机丢弃一部分像素点。具体来说,Dropout 会在每次前向传播过程中,独立地随机选择一些像素点,并将它们设置为零,从而“关闭”这些像素点对应的信息。这个过程相当于对输入图片进行了遮挡,模拟了一些像素信息丢失的情况。
请注意❗️:Dropout 一般应用在特征图上,具体来说就是将特征图某些像素点设置为 0。
在预测场景时,会向前传递所有神经元的信号,可能会引出一个新的问题:训练时由于部分神经元被随机丢弃了,输出数据的总大小会变小。比如:计算其
- downscale_in_infer:训练时以比例 随机丢弃一部分神经元,不向后传递它们的信号;预测时向后传递所有神经元的信号,但是将每个神经元上的数值乘以 。
- upscale_in_train:训练时以比例 随机丢弃一部分神经元,不向后传递它们的信号,但是将那些被保留的神经元上的数值除以 ;预测时向后传递所有神经元的信号,不做任何处理。
在 PaddlePaddle Dropout
API中,通过 mode
参数来指定用哪种方式对神经元进行操作:
paddle.nn.Dropout(p=0.5, axis=None,
mode="upscale_in_train”,
name=None)
主要参数如下:
-
p
(float) :将输入节点置为 0 的概率,即丢弃概率,默认值:0.5。该参数对元素的丢弃概率是针对于每一个元素而言,而不是对所有的元素而言。举例说,假设矩阵内有 12 个数字,经过概率为 0.5 的 dropout 未必一定有 6 个零。 -
mode
(str) :丢弃法的实现方式,有’downscale_in_infer’
和’upscale_in_train’
两种,默认是’upscale_in_train’
。
不同框架对于 Dropout 的默认处理方式可能不同,在使用时可以查看 API 详细了解。
下面这段程序展示了经过 Dropout 之后输出数据的形式。
import paddle
import paddle.nn as nn
import numpy as np
if __name__ == "__main__":
np.random.seed(100)
# 创建数据
data_1 = np.random.rand(1, 3, 2, 2).astype("float32") # [N, C, H, W]
data_2 = np.arange(1, 13).reshape([-1, 3]).astype("float32") # [N, C]
# 使用dropout作用到输入数据上
x_1 = paddle.to_tensor(data_1)
x_2 = paddle.to_tensor(data_2)
"""方式1:downgrade_in_infer模式下"""
drop_method_1 = nn.Dropout(p=0.5, mode="downscale_in_infer")
droped_train_11 = drop_method_1(x_1)
droped_train_12 = drop_method_1(x_1)
# 切换到eval模式。在动态图模式下,使用eval()切换到求值模式,该模式禁用了dropout
drop_method_1.eval()
drop_11_eval_11 = drop_method_1(x_1)
drop_12_eval_12 = drop_method_1(x_1)
"""方式2:upscale_in_train模式下"""
drop_method_2 = nn.Dropout(p=0.5, mode="upscale_in_train")
droped_train_21 = drop_method_2(x_2)
droped_train_22 = drop_method_2(x_2)
# 切换到eval模式。在动态图模式下,使用eval()切换到求值模式,该模式禁用了dropout
drop_method_2.eval()
drop_21_eval_21 = drop_method_2(x_2)
drop_22_eval_22 = drop_method_2(x_2)
# 输出
print('x1: {}, \n\n droped_train_11: \n\n {}, \n\n drop_11_eval_11: \n {}\n\n'.format(data_1, droped_train_11.numpy(), drop_11_eval_11.numpy()))
print('x1: {}, \n\n droped_train_12: \n\n {}, \n\n drop_12_eval_12: \n {}\n\n'.format(data_1, droped_train_12.numpy(), drop_12_eval_12.numpy()))
print('x2: {}, \n\n droped_train_21: \n\n {}, \n\n drop_21_eval_21: \n {}\n\n'.format(data_2, droped_train_21.numpy(), drop_21_eval_21.numpy()))
print('x2: {}, \n\n droped_train_22: \n\n {}, \n\n drop_22_eval_22: \n {}\n\n'.format(data_2, droped_train_22.numpy(), drop_22_eval_22.numpy()))
结果:
x1:
[[[[0.54340494 0.2783694 ]
[0.4245176 0.84477615]]
[[0.00471886 0.12156912]
[0.67074907 0.82585275]]
[[0.13670659 0.5750933 ]
[0.89132196 0.20920213]]]],
droped_train_11:
[[[[0.54340494 0.2783694 ]
[0.4245176 0. ]]
[[0.00471886 0. ]
[0.67074907 0. ]]
[[0.13670659 0.5750933 ]
[0. 0.20920213]]]],
drop_11_eval_11:
[[[[0.27170247 0.1391847 ]
[0.2122588 0.42238808]]
[[0.00235943 0.06078456]
[0.33537453 0.41292638]]
[[0.0683533 0.28754666]
[0.44566098 0.10460106]]]]
x1:
[[[[0.54340494 0.2783694 ]
[0.4245176 0.84477615]]
[[0.00471886 0.12156912]
[0.67074907 0.82585275]]
[[0.13670659 0.5750933 ]
[0.89132196 0.20920213]]]],
droped_train_12:
[[[[0. 0. ]
[0.4245176 0.84477615]]
[[0.00471886 0.12156912]
[0.67074907 0. ]]
[[0.13670659 0.5750933 ]
[0. 0. ]]]],
drop_12_eval_12:
[[[[0.27170247 0.1391847 ]
[0.2122588 0.42238808]]
[[0.00235943 0.06078456]
[0.33537453 0.41292638]]
[[0.0683533 0.28754666]
[0.44566098 0.10460106]]]]
x2:
[[ 1. 2. 3.]
[ 4. 5. 6.]
[ 7. 8. 9.]
[10. 11. 12.]],
droped_train_21:
[[ 0. 0. 0.]
[ 0. 10. 0.]
[14. 0. 18.]
[20. 0. 0.]],
drop_21_eval_21:
[[ 1. 2. 3.]
[ 4. 5. 6.]
[ 7. 8. 9.]
[10. 11. 12.]]
x2:
[[ 1. 2. 3.]
[ 4. 5. 6.]
[ 7. 8. 9.]
[10. 11. 12.]],
droped_train_22:
[[ 2. 0. 6.]
[ 0. 0. 12.]
[ 0. 0. 0.]
[20. 22. 0.]],
drop_22_eval_22:
[[ 1. 2. 3.]
[ 4. 5. 6.]
[ 7. 8. 9.]
[10. 11. 12.]]
从上述代码的输出可以发现,经过 dropout 之后,tensor 中的某些元素变为了 0,这个就是 dropout 实现的功能,通过随机将输入数据的元素置 0,消除减弱了神经元节点间的联合适应性,增强模型的泛化能力。