LeNet5——CNN的开山之作
前篇介绍了DNN网络,理论上通过增加网络层数可以逼近任意复杂的函数,即通用近似定理。但在实践过程中,增加网络层数也带来了两个问题:其一是层数较深的网络容易可能存在梯度消失或梯度弥散问题,其二是网络层数的增加也带来了过多的权重参数,对训练数据集和算力资源都带来了更大的考验。与此同时,针对图像这类特殊的训练数据,应用DNN时需要将其具有二维矩阵结构的像素点数据拉平成一维向量,而后方可作为DNN的模型输入——这一过程实际上丢失了图片像素点数据的方位信息,所以针对图像数据应用DNN也不见得是最优解。
在这样的研究背景下,卷积神经网络应运而生,并开启了深度学习的新篇章。延续前文的行文思路,本篇从以下几个方面展开介绍:
- 什么是CNN
- CNN为何有效
- CNN的适用场景
- 在PyTorch中的使用
01 什么是CNN
卷积神经网络,应为Convolutional Neural Network,简称CNN,一句话来说就是应用了卷积滤波器和池化层两类模块的神经网络。显然,这里表达的重点在于CNN网络的典型网络模块是卷积滤波器和池化层。所以,这里有必要首先介绍这两类模块。
1.卷积滤波器
作为一名通信专业毕业人士,我对卷积一词并不陌生,最初在信号处理的课中就有所接触。当然,卷积操作本身应该是一个数学层面的操作,对两个函数f(x)和g(x)做卷积,其实就是求解以下积分:
这个卷积的数学表达形式很优美,但其实有些过于抽象。从计算机的角度理解,一个连续的函数积分是不便操作的,因为计算机只能接受离散的输入,所以上述卷积操作的离散表达形式便是:
当然了,上述形式也只是从连续的积分形式变成了离散的求和形式。其中一个值得关注的细节是这里的卷积操作符号用的"*"——这也是后续所有卷积神经网络中沿用的卷积操作符号。
那么,这个卷积操作如何理解呢?这里引用网络上的一张信号处理卷积的动图:
补充说明:在上述卷积操作中,两个函数x()和y()均为单位脉冲函数,即有值的时刻均取值为1。而至于说这个卷积有什么功能和优势,其实在通信处理中其最大的价值在于用于时域和频域的变换——时域卷积等于频域卷积相乘,用公式表达就是FFT(f*g) = FFT(f)×FFT(g),这里FFT表达信号处理领域常用的操作:快速傅里叶变换。换句话说,卷积和乘法构成了两个信号在时频域的交换操作。
ok,了解了卷积操作的功能和其用途之后,我们来看其在神经网络中能有什么应用,或者进一步说对于图像分类任务的神经网络有什么应用。
注意到,卷积操作适用于两个函数(连续积分形式),或者说两串序列数据(离散求和形式);更进一步地,即可通过交错形式逐一得到二者对位相乘的求和,并得到一个新的序列结果。也就是说,两个序列卷积的结果是一个新的序列。将这对应到用于图像分类的神经网络中,有两个问题:
- 卷积操作的两个对象(或者说两串序列)分别是什么呢?一个应该是图像的像素数据,而另一个则是网络权重,也就是说卷积操作中进行滑动相乘求和的对象分别是图像像素数据和网络权重
- 卷积是两个一维序列在卷,那应用到图像数据呢?难道还是要将其展平为一维序列吗?——这又回到了DNN中丢失了空间依赖信息的问题。所以,这里卷积操作的范围又进一步由一维序列延伸为二维矩阵——很小的一处改动,但却是CNN的灵魂之处。
除了上述两个问题,其实还隐藏一个细节:卷积之所以用“卷”这个词,大概是因为卷积操作的两个序列是反向滑动的——一个向左,一个向右。但无论以什么方向滑动,但对于像素数据和网络权重来说,卷积其本质是将二者对位相乘求和。那么,正向对位是对位,反向对位也是对位,为何还要卷一下呢——直接正向对位不足够吗?当然是可以的,所以神经网络中的卷积都是直接对位相乘求和。
经过这样设计的卷积操作已基本实现了从数学中卷积到神经网络中卷积的衍变,但还有最后一处调整:数学中的卷积操作是输入两个序列,得到一个新的序列,同时这两个序列可以长度不同,如果两个序列长度分别记作M和N的话,那么卷积得到的新序列长度为M+N-1。但在神经网络中,似乎这种维度不可控的操作不够友好,所以就要求两个卷积对象尺寸一致,并只保留了对位相乘求和的一个结果作为卷积输出。具体来说,单个卷积完成的是以下操作:
至此,算是真正完成了神经网络中单个卷积操作的讲解,小结一下,可概括为以下要点:
- 神经网络中的卷积操作源起于数学中的卷积,但取消了反向滑动的特点,而仅采用正向对位相乘的特性——从这个角度讲,神经网络中的卷积叫做加权求和更贴切
- 神经网络中卷积操作的两个对象是像素数据和网络权重,其中这里的网络权重也叫做一个卷积核(kernel),且要求二者尺寸相同
- 神经网络中的单词卷积操作只保留一个输出
类似于DNN网络中的神经元结构,在CNN网络中上述单个卷积核的操作应该叫做一个神经元。那么,有了单个神经元,就可以很容易的通过滑动的形式将其推广到整张图像:整张的含义既包括横向和纵向,也包括多个通道,例如彩色图片的RGB。所以,在一幅图像上做卷积操作,就是如下过程:
注:一组卷积模板组成的矩阵称作卷积核,一个卷积核仅作用于单个输入通道上,若前一层有M个通道,后一层输出N个通道,则需要M×N个卷积核。除原始图片输入数据外,后续经过卷积层提取的每个通道都叫做一个特征图。
这里再次贴出DNN的网络架构,方便我们对比:
对比DNN和CNN两种网络,可以窥探更深层的对比:
- CNN的网络结构体现的也是相邻网络层之间的连接关系,但这种连接仅考虑了小范围的输入,即局部连接而非全连接
- 与DNN中各神经元拥有不同的连接权重相比,CNN中的连接权重只有一套公共的模板,即权重共享
局部连接、权重共享,这是CNN的两大特性,也正是这两大特性,一方面大大降低了权重参数的数量,另一方面也更容易提取图像数据的局部特征!
2.池化层
池化层,英文为pooling,其实单纯从其英文是很难理解为何要在卷积神经网络中设计一个这样的结构。虽然目前我个人未能理解这个名字的含义,但其功能却是非常直观和简单的——如果说卷积滤波器是用于局部特征提取的话,那么池化层可以看做是局部特征降维。
举个例子,池化层典型的有三种类型,即MaxPooling,AvgPooling,MinPooling,SumPooling等,其中前两种更为常用。那么MaxPooling要干的事情就是将局部的一组像素求其最大值作为输出,相应的AvgPooling和MinPooling则是求均值或者最小值,SumPooling就是求和。举个例子:
池化层的功能是非常容易理解的,那么设计的目的是什么呢?答案有两个:
- 数据降维,即将大尺寸图像数据变为小尺寸
- 特殊特征提取操作,即池化层其实也可看做是一种特殊的特征提取器(当然,与卷积核的滑动特征提取还是有显著的功能差异的)
至此,有了卷积层和池化层这两大模块的理解,即可用其堆叠出想要的卷积神经网络,例如在开篇给出的CNN开山之作——LeNet5中,则是一个含有两个卷积层、两个池化层和三个全连接层组成的网络。
上面从数学中的卷积操作开始,介绍了卷积神经网络中的卷积是如何设计的。实际上,当理解了卷积的编码实现之后,会发现其实卷积的计算还是非常简单的,一句话概括就是——卷积操作就是用卷积核中的权重矩阵通过滑窗的形式依次与图像像素数据进行相乘求和的过程。
那么问题来了,为什么这样的设计是有效的?换言之,原始的图像数据经过卷积操作之后提取到了哪些特征?这就是接下来要介绍的内容。
02 CNN为何有效
CNN为何有效,回答这一问题的核心在于解释卷积操作为何有效,因为CNN网络中的标志性操作是卷积。
为了理解卷积操作是如何工作的,这里先给出一段形象的描述,然后再以LeNet5为例加以探索实践。
图片源自台大李宏毅教授的深度学习PPT
在上述图片中,我们可以看到同样是检测鸟嘴(beak)的局部特征,通过选用相同的卷积核与其滤波,通过多次变换可以用以分辨其是一个尖嘴还是短嘴,从而为最终鸟的分类任务提供一个特征。
当然,这只是一个示意描述,那么实际情况如何呢?我们选用LeNet5对手写数字分类任务加以尝试,看看模型是怎么利用这一卷积操作。这里沿用深度学习模型搭建的三部曲:PyTorch学习系列教程:构建一个深度学习模型需要哪几步?
首先是mnist数据集的准备,可直接使用torchvision包在线下载:
from torchvision import datasets from torch.utils.data import DataLoader, TensorDataset train = datasets.MNIST('data/', download=True, train=True) test = datasets.MNIST('data/', download=True, train=False) X_train = train.data.unsqueeze(1)/255.0 y_train = train.targets trainloader = DataLoader(TensorDataset(X_train, y_train), batch_size=256, shuffle=True) X_test = test.data.unsqueeze(1)/255.0 y_test = test.targets
然后是LeNet5的网络模型(torchvision中内置了部分经典模型,但LeNet5由于比较简单,不在其中)
import torch from torch import nn class LeNet5(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 6, 5, padding=2) self.pool1 = nn.MaxPool2d((2, 2)) self.conv2 = nn.Conv2d(6, 16, 5) self.pool2 = nn.MaxPool2d((2, 2)) self.fc1 = nn.Linear(16*5*5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 10) def forward(self, x): x = F.relu(self.conv1(x)) x = self.pool1(x) x = F.relu(self.conv2(x)) x = self.pool2(x) x = x.view(len(x), -1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) x = self.fc3(x) return x
最后是模型的训练过程:
model = LeNet5() optimizer = optim.Adam(model.parameters()) criterion = nn.CrossEntropyLoss() for epoch in trange(10): for X, y in trainloader: pred = model(X) loss = criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() with torch.no_grad(): y_pred = model(X_train) acc_train = (y_pred.argmax(dim=1) == y_train).float().mean().item() y_pred = model(X_test) acc_test = (y_pred.argmax(dim=1) == y_test).float().mean().item() print(epoch, acc_train, acc_test) ### 训练结果 ### 10%|████████▎ | 1/10 [00:28<04:12, 28.05s/it] 0 0.9379666447639465 0.9406999945640564 20%|████████████████▌ | 2/10 [00:56<03:48, 28.54s/it] 1 0.9663333296775818 0.9685999751091003 30%|████████████████████████▉ | 3/10 [01:25<03:21, 28.76s/it] 2 0.975350022315979 0.9771000146865845 40%|█████████████████████████████████▏ | 4/10 [01:54<02:52, 28.78s/it] 3 0.9786166548728943 0.9787999987602234 50%|█████████████████████████████████████████▌ | 5/10 [02:22<02:22, 28.43s/it] 4 0.9850000143051147 0.9853000044822693 60%|█████████████████████████████████████████████████▊ | 6/10 [02:51<01:53, 28.49s/it] 5 0.9855666756629944 0.9843999743461609 70%|██████████████████████████████████████████████████████████ | 7/10 [03:20<01:26, 28.78s/it] 6 0.9882833361625671 0.9873999953269958 80%|██████████████████████████████████████████████████████████████████▍ | 8/10 [03:51<00:58, 29.37s/it] 7 0.9877333045005798 0.9872000217437744 90%|██████████████████████████████████████████████████████████████████████████▋ | 9/10 [04:21<00:29, 29.54s/it] 8 0.9905833601951599 0.9896000027656555 100%|██████████████████████████████████████████████████████████████████████████████████| 10/10 [04:49<00:00, 28.93s/it] 9 0.9918666481971741 0.9886000156402588
可见,短短通过10个epoch的训练,模型在训练集和测试集上均取得了很好的准确率得分,其中训练集高达99%以上,测试集上也接近99%,说明模型不存在过拟合。
那么接下来我们的重点来了:经过LeNet5模型中的两个卷积层操作之后,原本的手写数字图片变成了什么形态?换句话说,提取到了哪些特征?
我们这里举两个特殊case研究一下:
case-1:测试集样本0,对应手写数字7
1-a:原始图片:
1-b:经过第一层卷积,共提取6个通道的特征图
1-c:经过第二层卷积,提取16个通道的特征图
case-2:测试集样本1,对应手写数字2
2-a:原始图片
2-b:经过第一层卷积,共提取6个通道的特征图
2-c:经过第二层卷积,提取16个通道的特征图
对比两组案例的具体卷积提取结果,其实是能大体看出一些规律的,例如第一层卷积后的第1和第2通道更加注重提取手写数字的边角特征(轮廓),而第4和第5通道特征图注重手写数字的纵向特征,且分别提取左边缘和右边缘的特征;类似地,第3和第6通道特征图则注重提取手写数字的横向特征,且分别提取上边缘和下边缘特征:
而在此之后的第二卷积层中,则用于提取更为细节和丰富的特征,具体可以自行对比研究一下。至于说为什么提取了这些局部特征就可以完成手写数字的识别——即区分哪个是0,哪个是1等等?这里可以联想一下数字电路中逻辑判断的例子:对于由7个笔画组成的数字模板,当外圈全亮而中间不亮时为0,当右侧两个亮而其他不亮时为1。而现在LeNet5通过各个卷积核提取到的特征,就可根据取值大小对应到图片中各部分的亮暗情况,进而完成数字的分类。
当然,这个例子只是简单的举例,模型的实际处理逻辑会比这复杂得多——但全靠模型自己去训练和学习。
以上,我们首先通过识别鸟嘴的直观例子描述了卷积操作在CNN网络中扮演的角色——提取局部特征,而后用LeNet5模型在mnist手写数字数据集上的实际案例加以研究分析,证实了这一直观理解。所以,CNN模型之所以有效,其核心在于——卷积操作具有提取图像数据局部特征的能力。
此外,卷积操作配合池化层,其实还有更鲁棒的效果:包括图像伸缩不变性、旋转不变性等,这是普通的DNN所不具备的能力,此处不再展开。
03 CNN的适用场景
前面一直在以图像数据为例介绍CNN的原理和应用,当然图像数据也确实是CNN网络最为擅长的场景,反之亦然,即最擅长图像数据的网络结构是CNN。
除了图像数据,随着近年来研究的进展,CNN其实在更多的领域都有所突破和崭露头角,例如:
- 将一维卷积应用于序列数据建模,也可以提取相邻序列数据间的特征关系,从而很好的完成时序数据建模,例如TCN模型【参考文献:Temporal convolutional networks: A unified approach to action segmentation. 2016】
- 将二维卷积应用于空间数据建模,例如交通流量预测中,一个路口的流量往往与其周边路口的流量大小密切相关,此时卷积也是有效的
总而言之,以卷积和池化操作为核心的CNN网络,最为适用的场景是图像数据,也可推广到其他需要提取局部特征的场景。
04 在PyTorch中的使用
最后,简单介绍一下CNN网络中的两个关键单元:卷积模块和池化模块,在PyTorch中的基本使用。
1.卷积模块:Conv1d、Conv2d,Conv3d
PyTorch中卷积模块主要包括3个,即分别为1维卷积Conv1d、2维卷积Conv2d和3维卷积Conv3d,其中Conv2d即是最常用于图像数据的二维卷积,也是最早出现的模块;Conv1d则可用于时序数据中的卷积,而Conv3d目前个人还未接触到。这里以Conv2d为例展开介绍一下。首先是类的初始化参数:
依次说明:
- in_channels:输入层的图像数据通道数
- out_channels:输出层的图像数据通道数
- kernel_size:卷积核尺寸,既可以是标量,代表一个正方形的卷积核;也可以是一个二元组,分别代表长和宽
- stride:卷积核滑动的步幅,默认情况下为1,即逐像素点移动,若设置大于1的数值,则可以实现跨步移动的效果
- padding:边缘填充的层数,默认为0,表示对原始图片数据不做填充。如果取值大于0,例如padding1,则在原始图片数据的外圈添加一圈0值,注意这里是添加一圈,填充后的图片尺寸为原尺寸长和宽均+2。padding=1和stride=2的卷积示意图如下:
padding=1,stride=2的卷积
- dilation:用于控制是否是空洞卷积,这是后续论文新提出的卷积改进,即由原始的稠密的卷积核变为空洞卷积核,用于减小卷积核参数同时增大感受野。空洞卷积示意图如下:
dilation=1的空㓊卷积
细品一下stride和dilation两个参数对卷积操作影响的区别。
然后是Conv2d的输入和输出数据。Conv2d可以看做是一个特殊的神经网络层,所以其本质上是将一个输入的tensor变换为另一个tensor,其中输入和输出tensor的尺寸即含义分别如下:
- input:batch × in_channels × height × width
- output: batch × output_channels × height × width
即输入和输出tensor主要是图像通道数上的改变,图像的高和宽的大小则要取决于kernel、padding、stride和dilation四个参数的综合作用,这里不再给出具体的计算公式。
2.池化模块:MaxPool1d、MaxPool2d,MaxPool3d
池化模块在PyTorch中主要内置了最大池化和平均池化,每种池化又可细分为一维、二维和三维池化层。这里仍然以MaxPool2d简要介绍:
可见,池化模块的初始化参数与卷积模块中的初始化参数有很多共通之处,包括kernel、stride、padding和dilation等4个参数的设计上。相应的,由于池化层仅仅是各通道上实现数据尺寸的降维,所以其输入和输出数据的通道数不变,而仅仅是尺寸的变化,这里也不再给出相应的计算公式。
以上,便是对卷积神经网络的一些介绍,从卷积操作的起源、到对卷积提取局部特征的理解,最后到在PyTorch中的模块使用,希望对读者有所帮助。