一、前言
通过上一篇文章,我们大概了解了卷积是什么,并且分析了为什么卷积能在图像识别上起到巨大的作用。接下来,废话不多话,我们自己尝试动手搭建一个简易的CNN网络。
二、准备工作
在开始的时候,我们首先概括一下卷积所需要进行的工作:
- 定义一个卷积核:卷积核是一个小的矩阵(例如3x3或5x5),包含一些数字。这个卷积核的作用是在图像中识别特定类型的特征,例如边缘、线条等,也可能是难以描述的抽象特征。
- 卷积核滑过图像:卷积操作开始时,卷积核会被放置在图像的左上角。然后,它会按照一定的步长(stride)在图像上滑动,可以是从左到右,也可以是从上到下。步长定义了卷积核每次移动的距离。
- 计算点积:在卷积核每个位置,都会计算卷积核和图像对应部分的点积。这就是将卷积核中的每个元素与图像中对应位置的像素值相乘,然后将所有乘积相加。
- 生成新的特征图:每次计算的点积结果被用来构建一个新的图像,也称为特征图或卷积图。
- 重复以上过程:通常在一个 CNN 中,我们会有多个不同的卷积核同时进行卷积操作。这意味着我们会得到多个特征图,每个特征图捕捉了原始图像中的不同特征。
上图中间的矩阵就是所谓的卷积核,又称为滤波器。这个滤波器可以帮助我们观测到一定区域大小的像素信息,并且通过卷积计算,变成”低频“的信息特征,比如上面我们提到的一些图像的边缘,纹理等等。当权重系数(卷积核)的参数改变时,它可以提取的特征类型也会改变。所以训练卷积神经网络时,实质上训练的是卷积核的参数。如下图所示,是一次卷积计算的过程:
一个内核从图像的左上角开始滑动,将核所覆盖的像素值与相应的核值相乘,并对乘积求和。结果被放置在新图像中与核的中心相对应的点处。上面的235其实就是计算所得出的灰度值,当一个内核走完整个图片之后,得出的结果大概是这样子:
三、CNN架构
当只有一层CNN结构时,一般都会有如下几个层级,来帮助我们进行一次卷积,训练对应的特征。
- 输入层(Input Layer):
输入层负责接收原始数据,例如图像。每个节点对应输入数据的一个特征。
- 卷积层(Convolutional Layer):
卷积层是CNN的核心。它通过应用卷积操作来提取图像中的特征。每个卷积层包含多个卷积核(也称为滤波器),每个卷积核负责检测输入中的不同特征。卷积操作通过滑动卷积核在输入上进行计算,并生成特征图。
- 激活函数层(Activation Layer):
在卷积层之后,一般会添加激活函数,例如ReLU(Rectified Linear Unit),用于引入非线性性。这有助于模型学习更复杂的模式和特征。
- 池化层(Pooling Layer):
池化层用于减小特征图的空间维度,降低计算复杂度,并减少过拟合风险。常见的池化操作包括最大池化和平均池化。
- 全连接层(Fully Connected Layer):
全连接层将前面层的所有节点与当前层的所有节点连接。这一层通常用于整合前面层提取的特征,并生成最终的输出。在分类问题中,全连接层通常输出类别的概率分布。
- 输出层(Output Layer):
输出层给出网络的最终输出,例如分类的概率分布。通常使用softmax函数来生成概率分布。
输入层就不用解释了,毕竟在全连接网络中我们已经对它有了一定的了解,我们首先看看在卷积层中,具体是怎么实现的。在我们接下来要做的 MNIST 手写数字识别的数据集中,我们用其中的图片来举例,例如 8 这个图片:
在卷积层中左边黑色为原图,中间为卷积层,右边为卷积后的输出。我们可以注意到,当我们是不用的卷积核时,所对应的结果是不同的。
上面第一种卷积核所有元素值相同,所以它可以计算输入图像在卷积核覆盖区域内的平均灰度值。这种卷积核可以平滑图像,消除噪声,但会使图像变得模糊。第二种卷积核可以检测图像中的边缘,可以看到输入的8的边缘部分颜色更深一些,在更大的图片中这种边缘检测的效果会更明显。
需要注意的是,虽然上边说道不同的卷积核有着不同的作用,但是在卷积神经网络中,卷积核并不是手动设计出来的,而是通过数据驱动的方式学习得到的。这就是说,我们并不需要人工设计出特定的卷积核来检测边缘、纹理等特定的特征,而是让模型自己从训练数据中学习这些特征,即模型可以自动从复杂数据中学习到抽象和复杂的特征,这些特征可能人工设计难以达到。
在卷积过程中需要注意的两个参数是:步长和零填充。如果两者优化得当,可以让CNN的效果更加好。
1. 步长 - Strade
在卷积神经网络(CNN)中,"步长"(stride)是一个重要的概念。步长描述的是在进行卷积操作时,卷积核在输入数据上移动的距离。在两维图像中,步长通常是一个二元组,分别代表卷积核在垂直方向(高度)和水平方向(宽度)移动的单元格数。例如,步长为1意味着卷积核在每次移动时,都只移动一个单元格,这就意味着卷积核会遍历输入数据的每一个位置;同理,如果步长为2,那么卷积核每次会移动两个单元格。如下图,就是 strade=2 时,卷积后所得的结果:
步长的选择会影响卷积操作的输出尺寸。更大的步长会产生更小的输出尺寸,反之同理。
之所以设置步长,主要考虑以下几点:
- 降低计算复杂性:当步长大于1时,卷积核在滑动过程中会"跳过"一些位置,这将减少输出的尺寸并降低后续层的计算负担。
- 模型的可扩展性:增大步长可以有效地降低网络层次的尺寸,使得模型能处理更大尺寸的输入图片。
- 控制过拟合:过拟合是指模型过于复杂,以至于开始"记住"训练数据,而不是"理解"数据中的模式。通过减少模型的复杂性,我们可以降低过拟合的风险。
- 减少存储需求:更大的步长将产生更小的特征映射,因此需要更少的存储空间。
2. 零填充 - Zero Padding
注意上面的图,我们发现底部输入图片的中间十字部分,被输入了两次。并且输入图像与卷积核进行卷积后的结果中损失了部分值,输入图像的边缘被“修剪”掉了(边缘处只检测了部分像素点,丢失了图片边界处的众多信息)。这是因为边缘上的像素永远不会位于卷积核中心,而卷积核也没法扩展到边缘区域以外。这个结果我们是不能接受的,有时我们还希望输入和输出的大小应该保持一致。为解决这个问题,可以在进行卷积操作前,对原矩阵进行边界填充(Padding),也就是在矩阵的边界上填充一些值,以增加矩阵的大小,通常都用”空“来进行填充。
如果定义输入层的边长是M,卷积核的边长是K,填充的圈数为P,以及步长的长度为S,我们可以推导出卷积之后输出的矩阵大小为:
激活层就不解释了,一般来说引入激活层只是引入非线性,官方指导中也说明,一般使用 Relu 即可。而池化层则其实是为了让模型具有泛化的能力,其实说白了就是为了避免模型的 overfit,使用池化层让模型忘记一些之前学过的特征,这里的做法其实在后面的很多模型中都能看到。
池化层主要采用最大池化(Max Pooling)、平均池化(Average Pooling)等方式,对特征图进行操作。以最常见的最大池化为例,我们选择一个窗口(比如 2x2)在特征图上滑动,每次选取窗口中的最大值作为输出,如下图这就是最大池化的工作方式:
大致可以看出,经过池化计算后的图像,基本就是左侧特征图的“低像素版”结果。也就是说池化运算能够保留最强烈的特征,并大大降低数据体量。
四、手写数字CNN设计
接下来,我们可以使用上面提到的 CNN 的各个层,给我们自己的手写数字识别设计网络架构。其实就是让 卷积层 -> Relu -> 池化层 叠加 N 层,然后最后最后再加入全连接层,进行分类。
下图就是具体的网络架构图
用代码实现如下:
import torch from torch import nn from torch.nn import functional as F from torch import optim import torchvision from matplotlib import pyplot as plt from utils import plot_curve, plot_image, one_hot, predict_plot_image # step 1 : load dataset batch_size = 512 # https://blog.csdn.net/weixin_44211968/article/details/123739994 # DataLoader 和 dataset 数据集的应用 train_loader = torch.utils.data.DataLoader( torchvision.datasets.MNIST('./data', train=True, download=True, transform=torchvision.transforms.Compose([ torchvision.transforms.ToTensor(), torchvision.transforms.Normalize( (0.1307,), (0.3081,) ) ])), batch_size=batch_size, shuffle=True ) test_loader = torch.utils.data.DataLoader( torchvision.datasets.MNIST('./data', train=True, download=True, transform=torchvision.transforms.Compose([ torchvision.transforms.ToTensor(), torchvision.transforms.Normalize( (0.1307,), (0.3081,) ) ])), batch_size=batch_size, shuffle=False ) # step 2 : CNN网络 class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() # 图片是灰度图片,只有一个通道 # 第一层 self.conv1 = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=5, stride=1, padding=2) self.relu1 = nn.ReLU() self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 第二层 self.conv2 = nn.Conv2d(in_channels=16, out_channels=32, kernel_size=5, stride=1, padding=2) self.relu2 = nn.ReLU() self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 全连接层 self.fc1 = nn.Linear(in_features=7 * 7 * 32, out_features=256) self.relufc = nn.ReLU() self.fc2 = nn.Linear(in_features=256, out_features=10) # 定义前向传播过程的计算函数 def forward(self, x): # 第一层卷积、激活函数和池化 x = self.conv1(x) x = self.relu1(x) x = self.pool1(x) # 第二层卷积、激活函数和池化 x = self.conv2(x) x = self.relu2(x) x = self.pool2(x) # 将数据平展成一维 x = x.view(-1, 7 * 7 * 32) # 第一层全连接层 x = self.fc1(x) x = self.relufc(x) # 第二层全连接层 x = self.fc2(x) return x # step3: 定义损失函数和优化函数 # 学习率 learning_rate = 0.005 # 定义损失函数,计算模型的输出与目标标签之间的交叉熵损失 criterion = nn.CrossEntropyLoss() model = CNN() optimizer = optim.SGD(model.parameters(), lr=learning_rate, momentum=0.9) # step4: 模型训练 train_loss = [] # 定义迭代次数 device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") # 将神经网络模型 net 移动到指定的设备上。 model = model.to(device) # 为了和全连接网路的训练次数一致,我们采用相同的迭代次数 total_step = len(train_loader) num_epochs = 3 for epoch in range(num_epochs): for i, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) optimizer.zero_grad() # 清空上一个batch的梯度信息 # 将输入数据 inputs 喂入神经网络模型 net 中进行前向计算,得到模型的输出结果 outputs。 outputs = model(images) # 使用交叉熵损失函数 loss = criterion(outputs, labels) # 使用反向传播算法计算模型参数的梯度信息 loss.backward() # 更新梯度 optimizer.step() train_loss.append(loss.item()) # 输出训练结果 if (i + 1) % 100 == 0: print('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}'.format(epoch + 1, num_epochs, i + 1, total_step, loss.item())) print('Finished Training') # 打印 loss 损失图 plot_curve(train_loss) # step 5 : 准确度测试 # 测试CNN模型 with torch.no_grad(): # 进行评测的时候网络不更新梯度 correct = 0 total = 0 for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print('Accuracy of the network on the 10000 test images: {} %'.format(100 * correct / total))
损失可以看到经过两层的卷积层之后,虽然没有全连接层的Loss那么低,但是下降的还是非常快的,最后趋于平稳。
经过三个epoch的训练,结果如下:
Epoch [1/3], Step [100/118], Loss: 0.4683 Epoch [2/3], Step [100/118], Loss: 0.2781 Epoch [3/3], Step [100/118], Loss: 0.1155 Finished Training Accuracy of the network on the 10000 test images: 96.47 %
我们可以惊奇的发现,这里test数据中预测的准确率竟然高达 96.47%。要知道我们使用好几层的全连接网络,最后虽然 Loss 值非常低,但是在 test 数据中的准确度都只是徘徊在 90% 左右。而我们在使用相同层数,同等训练 epoch 的情况下,我们就把识别准确率提高了7%左右,这真是令人兴奋的进步!!!
以上就是CNN的全部实践过程了,这个系列的一小节就到此结束了,随着学习深入,我发现机器学习中比较能做出成果的是深度学习,所以这几篇都是关于深度学习的内容。后面会更多的更新机器学习相关知识,我只是知识的搬运工,下期再见~
Reference
[1] https://zhuanlan.zhihu.com/p/635438713
[2] https://mlnotebook.github.io/post/CNN1/
[3] https://poloclub.github.io/cnn-explainer/#article-convolution
[4] https://blog.csdn.net/weixin_41258131/article/details/133013757
[5] https://alexlenail.me/NN-SVG/LeNet.html
__EOF__