一、CNN的引入
在人工的全连接神经网络中,每相邻两层之间的每个神经元之间都是有边相连的。当输入层的特征维度变得很高时,这时全连接网络需要训练的参数就会增大很多,计算速度就会变得很慢,例如一张黑白的 28×28 的手写数字图片,输入层的神经元就有784个,如下图所示:
若在中间只使用一层隐藏层,参数 w 就有 784×15=11760 多个;若输入的是28×28 带有颜色的RGB格式的手写数字图片,输入神经元就有28×28×3=2352 个…… 。这很容易看出使用全连接神经网络处理图像中的需要训练参数过多的问题。
而在卷积神经网络(Convolutional Neural Network,CNN)中,卷积层的神经元只与前一层的部分神经元节点相连,即它的神经元间的连接是非全连接的,且同一层中某些神经元之间的连接的权重 w 和偏移 b 是共享的(即相同的),这样大量地减少了需要训练参数的数量。
CNN 的关键并不只是“层数更多”,而是利用了图像的局部相关性。一个卷积核只观察局部区域,并且在整张图上重复使用同一组参数。这样既减少了参数数量,也让模型能够学习边缘、纹理等局部特征。
卷积神经网络CNN的结构一般包含这几个层:
- 输入层:用于数据的输入
- 卷积层:使用卷积核进行特征提取和特征映射
- 激励层:由于卷积也是一种线性运算,因此需要增加非线性映射
- 池化层:进行下采样,对特征图稀疏处理,减少数据运算量。
- 全连接层:通常在CNN的尾部进行重新拟合,减少特征信息的损失
- 输出层:用于输出结果
当然中间还可以使用一些其他的功能层:
- 归一化层(Batch Normalization):在CNN中对特征的归一化
- 切分层:对某些(图片)数据的进行分区域的单独学习
- 融合层:对独立进行特征学习的分支进行融合
二、CNN的层次结构
输入层:
在CNN的输入层中,(图片)数据输入的格式 与 全连接神经网络的输入格式(一维向量)不太一样。CNN的输入层的输入格式保留了图片本身的结构。
对于黑白的 28×28 的图片,CNN的输入是一个 28×28 的的二维神经元,如下图所示:
对于 RGB 格式的 `28×28` 图片,在 TensorFlow 默认的 NHWC 格式中,一批图片的形状为:
[batch_size, 28, 28, 3]
图片在 PyTorch 中通常使用 `[batch, channels, height, width]` 的 NCHW 格式。例如一批 64 张 RGB 图片的形状为 `[64, 3, 28, 28]`。其中 batch 表示一次送入网络的图片数量,channels 表示颜色通道数。刚开始学习时,比较容易混淆的就是通道维的位置;TensorFlow 常见格式是 NHWC,而 PyTorch 默认是 NCHW。
卷积层:
在卷积层中有几个重要的概念:
- local receptive fields(感受视野)
- shared weights(共享权值)
假设输入的是一个 28×28 的的二维神经元,我们定义5×5的 一个 local receptive fields(感受视野),即 隐藏层的神经元与输入层的5×5个神经元相连,这个5*5的区域就称之为Local Receptive Fields,如下图所示:
可类似看作:隐藏层中的神经元 具有一个固定大小的感受视野去感受上一层的部分特征。在全连接神经网络中,隐藏层中的神经元的感受视野足够大乃至可以看到上一层的所有特征。
而在卷积神经网络中,隐藏层中的神经元的感受视野比较小,只能看到上一次的部分特征,上一层的其他特征可以通过平移感受视野来得到同一层的其他神经元,由同一层其他神经元来看:
设移动的步长为1:从左到右扫描,每次移动 1 格,扫描完之后,再向下移动一格,再次从左到右扫描。
具体过程如动图所示:
可看出 卷积层的神经元是只与前一层的部分神经元节点相连,每一条相连的线对应一个权重 ww 。
一个感受视野带有一个卷积核,我们将 感受视野 中的权重 ww 矩阵称为卷积核;将感受视野对输入的扫描间隔称为步长(stride);当步长比较大时(stride>1),为了扫描到边缘的一些特征,感受视野可能会“出界”,这时需要对边界扩充(pad),边界扩充可以设为 00 或 其他值。步长 和 边界扩充值的大小由用户来定义。
卷积核的大小由用户来定义,即定义的感受视野的大小;卷积核的权重矩阵的值,便是卷积神经网络的参数,为了有一个偏移项 ,卷积核可附带一个偏移项 bb ,它们的初值可以随机来生成,可通过训练进行变化。
因此 感受视野 扫描时可以计算出下一层神经元的值为:
𝑏+∑𝑖=04∑𝑗=04𝑤𝑖𝑗𝑥𝑖𝑗
对下一层的所有神经元来说,它们从不同的位置去探测了上一层神经元的特征。
我们将通过 一个带有卷积核的感受视野扫描生成的下一层神经元矩阵 称为 一个feature map (特征映射图),如下图的右边便是一个 feature map:
因此在同一个feature map上的神经元使用的卷积核是相同的,因此这些神经元 shared weights,共享卷积核中的权值和附带的偏移。一个 feature map 对应 一个卷积核,若我们使用 3 个不同的卷积核,可以输出3个feature map:(感受视野:5×5,布长stride:1)
因此在CNN的卷积层,我们需要训练的参数大大地减少到了 (5×5+1)×3=78个。
假设输入的是一张 `28×28` 的 RGB 图片。在 PyTorch 的 NCHW 格式中,单张图片可表示为 `3×28×28`,其中 3 表示 RGB 三个颜色通道。这时卷积核的大小不只用长和宽来表示,还有深度,感受视野也对应的有了深度,如下图所示:
由图可知:感受视野: 3×2×2 ; 卷积核: 3×2×2 ,深度为3;下一层的神经元的值为:𝑏+∑2𝑑=0∑1𝑖=0∑1𝑗=0𝑤𝑑𝑖𝑗𝑥𝑑𝑖𝑗 . 卷积核的深度和感受视野的深度相同,都由输入数据来决定,长宽可由自己来设定,数目也可以由自己来设定,一个卷积核依然对应一个 feature map。
注:“stride=1”表示在长和宽上的移动间隔都为1,即 stridewidth=1且 strideheight=1
激励层:
激励层主要对卷积层的输出进行一个非线性映射,因为卷积层的计算还是一种线性计算。使用的激励函数一般为ReLu函数:
𝑓(𝑥)=𝑚𝑎𝑥(𝑥,0)
卷积层和激励层通常合并在一起称为“卷积层”。
池化层:
当输入经过卷积层时,若感受视野比较小,布长stride比较小,得到的feature map (特征图)还是比较大,可以通过池化层来对每一个 feature map 进行降维操作,输出的深度还是不变的,依然为 feature map 的个数。
池化层也有一个“池化视野(filter)”来对feature map矩阵进行扫描,对“池化视野”中的矩阵值进行计算,一般有两种计算方式:
- Max pooling:取“池化视野”矩阵中的最大值
- Average pooling:取“池化视野”矩阵中的平均值
扫描的过程中同样地会涉及的扫描布长stride,扫描方式同卷积层一样,先从左到右扫描,结束则向下移动布长大小,再从左到右。如下图示例所示:
其中“池化视野”filter: 2×2;布长stride:2。(注:“ 池化视野”为个人叫法)
最后可将 3 个 24×24的 feature map 下采样得到 3 个 24×24 的特征矩阵:
池化层本身没有需要训练的卷积核参数,它主要负责缩小特征图的高和宽。例如输入形状为 `[64, 32, 28, 28]`,经过 `kernel_size=2`、`stride=2` 的最大池化后,会变为 `[64, 32, 14, 14]`。通道数 32 不变,变化的是图像的空间尺寸。
归一化层:
1. Batch Normalization
Batch Normalization(批量归一化)实现了在神经网络层的中间进行预处理的操作,即在上一层的输入归一化处理后再进入网络的下一层,这样可有效地防止“梯度弥散”,加速网络训练。
Batch Normalization具体的算法如下图所示:
每次训练时,取 batch_size 大小的样本进行训练,在BN层中,将一个神经元看作一个特征,batch_size 个样本在某个特征维度会有 batch_size 个值,然后在每个神经元 xixi 维度上的进行这些样本的均值和方差,通过公式得到 xi∧,再通过参数 γ 和 β进行线性映射得到每个神经元对应的输出 yi 。在BN层中,可以看出每一个神经元维度上,都会有一个参数 γ 和 β ,它们同权重w一样可以通过训练进行优化。
在卷积神经网络中进行批量归一化时,一般对 未进行ReLu激活的 feature map进行批量归一化,输出后再作为激励层的输入,可达到调整激励函数偏导的作用。
一种做法是将 feature map 中的神经元作为特征维度,参数 γ 和 β的数量和则等于 2×fmapwidth×fmaplength×fmapnum,这样做的话参数的数量会变得很多;
另一种做法是把 一个 feature map 看做一个特征维度,一个 feature map 上的神经元共享这个 feature map的 参数 γ 和 β ,参数 γ 和 β 的数量和则等于 2×fmapnum,计算均值和方差则在batch_size个训练样本在每一个feature map维度上的均值和方差。
注:fmapnumfmapnum指的是一个样本的feature map数量,feature map 跟神经元一样也有一定的排列顺序。
Batch Normalization 算法的训练过程和测试过程的区别:
在训练过程中,我们每次都会将 batch_size 数目大小的训练样本 放入到CNN网络中进行训练,在BN层中自然可以得到计算输出所需要的均值和方差;
在 PyTorch 中,`model.train()` 与 `model.eval()` 对 Batch Normalization 很重要。训练模式下,BN 使用当前 batch 的均值和方差;预测模式下,BN 使用训练过程中累计的均值和方差。因此,即使只预测一张图片,也应先执行 `model.eval()`,否则预测结果可能不稳定。
2. Local Response Normalization
近邻归一化(Local Response Normalization)的归一化方法主要发生在不同的相邻的卷积核(经过ReLu之后)的输出之间,即输入是发生在不同的经过ReLu之后的 feature map 中。
LRN的公式如下:
𝑏(𝑖,𝑥,𝑦)=𝑎(𝑖,𝑥,𝑦)(𝑘+𝛼∑𝑚𝑖𝑛(𝑁−1,𝑖+𝑛/2)𝑗=𝑚𝑎𝑥(0,𝑖−𝑛/2)𝑎(𝑗,𝑥,𝑦)2) 𝛽
其中:
a(i,x,y)a(i,x,y) 表示第ii个卷积核的输出(经过ReLu层)的feature map上的 (𝑥,𝑦) 位置上的值。
b(i,x,y)b(i,x,y) 表示 a(i,x,y)a(i,x,y) 经LRN后的输出。
NN 表示卷积核的数量,即输入的 feature map的个数。
nn 表示近邻的卷积核(或feature map)个数,由自己来决定。
k,α,β是超参数,由用户自己调整或决定。
与BN的区别:BN依据mini batch的数据,近邻归一仅需要自己来决定,BN训练中有学习参数;BN归一化主要发生在不同的样本之间,LRN归一化主要发生在不同的卷积核的输出之间。
切分层:
在一些应用中,需要对图片进行切割,独立地对某一部分区域进行单独学习。这样可以对特定部分进行通过调整感受视野进行力度更大的学习。
融合层:
融合层可以对切分层进行融合,也可以对不同大小的卷积核学习到的特征进行融合。
例如在GoogleLeNet 中,使用多种分辨率的卷积核对目标特征进行学习,通过padding使得每一个feature map的长宽都一致,之后再将多个 feature map 在深度上拼接在一起:
融合的方法有几种,一种是特征矩阵之间的拼接级联,另一种是在特征矩阵进行运算 (+,−,x,max,conv)。
全连接层和输出层
全连接层主要对特征进行重新拟合,减少特征信息的丢失;输出层主要准备做好最后目标结果的输出。例如VGG的结构图,如下图所示:
典型的卷积神经网络
LeNet-5模型
第一个成功应用于数字数字识别的卷积神经网络模型(卷积层自带激励函数,下同):
卷积层的卷积核边长都是5,步长都为1;池化层的窗口边长都为2,步长都为2。
AlexNet 模型
具体结构图:
从AlexNet的结构可发现:经典的卷积神经网络结构通常为:
输入层 → (卷积层+→池化层?)+→全连接层+→输出层
AlexNet卷积层的卷积核边长为5或3,池化层的窗口边长为3。具体参数如图所示:
VGGNet 模型
VGGNet 模型 和 AlexNet模型 在结构上没多大变化,在卷积层部位增加了多个卷积层。AlexNet(上) 和 VGGNet (下)的对比如下图所示:
具体参数如图所示:其中CONV3-64:表示卷积核的长和宽为3,个数有64个;POOL2:表示池化窗口的长和宽都为2,其他类似。
GoogleNet 模型
使用了多个不同分辨率的卷积核,最后再对它们得到的feature map 按深度融合在一起,结构如图:
其中,有一些主要的模块称为 Inception module,例如:
在 Inception module 中使用到了很多 1×1的卷积核,使用 1×1 的卷积核,步长为1时,输入的feature map和输出的feature map长宽不会发生改变,但可以通过改变 1×1的卷积核的数目,来达到减小feature map的厚度的效果,从而做到一些训练参数的减少。
GoogleNet还有一个特点就是它是全卷积结构(FCN)的,网络的最后没有使用全连接层,一方面这样可以减少参数的数目,不容易过拟合,一方面也带来了一些空间信息的丢失。代替全连接层的是全局平均池化(Global Average Pooling,GAP)的方法,思想是:为每一个类别输出一个feature map,再取每一个feature map上的平均值,作为最后的softmax层的输入。
ResNet模型
在前面的CNN模型中,都是将输入一层一层地传递下去(图左),当层次比较深时,模型不是很好训练。在ResNet模型中,它将低层学习到的特征和高层的学习到的特征进行一个融合(加法运算,图右),这样反向传递时,导数传递得更快,减少梯度弥散的现象。
注意:F(X)的shape需要等于 X 的shape ,这样才可以进行相加。
#运行环境#
测试环境:
Python 3.10
PyTorch 1.13.1+cpu
NumPy | 1.23.5
scikit-learn 1.1.3
PyTorch 模块:
- `torch`:张量计算、自动求导和设备管理;
- `torch.nn`:卷积层、池化层、BN 层、全连接层和损失函数;
- `torch.optim`:Adam 优化器;
- `torch.utils.data`:`TensorDataset` 和 `DataLoader`,用于按批读取训练数据。
我没有直接使用当前最新版本的 PyTorch,而是选择了 PyTorch 1.13.1。这样可减少不同版本 API 变化带来的干扰。
四、PyTorch代码
PyTorch 卷积层:nn.Conv2d
在 PyTorch 中,二维卷积通常通过torch.nn.Conv2d定义:
import torch.nn as nn conv1 = nn.Conv2d( in_channels=1, # 输入通道数:灰度图为 1,RGB 彩色图为 3 out_channels=32, # 输出通道数:使用 32 个卷积核,得到 32 张特征图 kernel_size=5, # 卷积核大小:5 × 5 stride=1, # 步长:卷积核每次移动 1 个像素 padding=2, # 填充:图像边缘各补 2 圈 0,使卷积后图片大小不变 bias=True # 偏置项:是否为每个输出通道增加一个可学习的偏置值 )随后将输入数据传入卷积层:
output = conv1(input_data)池化层
在 PyTorch 中,最大池化层和平均池化层分别为:
import torch.nn as nn # 最大池化层 max_pool = nn.MaxPool2d( kernel_size=2, # 池化窗口大小:2 × 2 stride=2, # 步长:窗口每次移动 2 个像素 padding=0 # 不填充 ) # 平均池化层 avg_pool = nn.AvgPool2d( kernel_size=2, # 池化窗口大小:2 × 2 stride=2, # 步长:窗口每次移动 2 个像素 padding=0 # 不填充 )使用方法:
output = max_pool(input_data) # 或 output = avg_pool(input_data)input_data是一个 4 维张量,PyTorch 中的格式为:
[batch_size, channels, height, width]池化层不会改变图片的通道数,只会缩小特征图的高和宽。
例如输入形状为:
[64, 32, 28, 28]经过kernel_size=2、stride=2的池化层后,输出形状为:
[64, 32, 14, 14]其中:
kernel_size=2 # 每次处理 2 × 2 的小区域 stride=2 # 每次移动 2 格,通常使图片高、宽缩小为原来的一半 padding=0 # 不在图片边缘补 0PyTorch 不需要 TensorFlow 中[1, height, width, 1]这样的 4 维ksize列表,因为它只需要指定高、宽方向的池化窗口大小。
Batch Normalization 层
在 PyTorch 中,卷积层后的批量归一化通常使用:
batch_norm = nn.BatchNorm2d( num_features=32, # 输入特征图的通道数 eps=1e-5, # 防止除以 0 的极小值 momentum=0.1, # 更新均值和方差的速度 affine=True # 是否学习缩放和偏移参数 )使用方法:
output = batch_norm(input_data)其中,输入input_data是一个形状为:
[batch_size, channels, height, width]的 4 维张量。
例如:
[64, 32, 28, 28]此时应设置:
batch_norm = nn.BatchNorm2d(num_features=32)因为输入有32个通道。
变量含义:
num_features=32 # 输入特征图的通道数 eps=1e-5 # 防止方差为 0 时出现除以 0 的问题 momentum=0.1 # 控制历史均值、方差的更新速度 affine=True # 自动学习缩放参数和偏移参数与 TensorFlow 不同,PyTorch 不需要手动传入mean、variance、offset和scale:
mean、variance:训练时由 PyTorch 自动计算 offset:对应可学习的偏移参数 scale:对应可学习的缩放参数训练时,PyTorch 会根据当前批次数据计算均值和方差;测试或预测时,会使用训练过程中记录的均值和方差。
代码示例:
思路
本示例使用 sklearn 自带的 `load_digits()` 数据集。每张图片大小为 `8×8`,是单通道灰度图,因此输入张量形状为:
[batch_size, 1, 8, 8]
TensorFlow 代码中需要手动定义placeholder、Session、feed_dict和 one-hot 标签;而 PyTorch 更接近普通 Python 的编写方式。模型通过forward()定义前向传播,训练时依次执行“前向传播 → 计算损失 → 反向传播 → 更新参数”。
通过搭建卷积神经网络来实现sklearn库中的手写数字识别,搭建的卷积神经网络结构如下图所示:
import numpy as np # 导入 NumPy,用于处理数组数据 import torch # 导入 PyTorch 核心库 import torch.nn as nn # 导入神经网络模块 from sklearn.datasets import load_digits # 导入 sklearn 自带的手写数字数据集 from sklearn.metrics import accuracy_score # 导入准确率计算函数 from sklearn.preprocessing import MinMaxScaler # 导入数据归一化工具 from torch.utils.data import DataLoader, TensorDataset # 导入批量加载数据的工具 torch.manual_seed(42) # 固定 PyTorch 随机种子,便于复现结果 np.random.seed(42) # 固定 NumPy 随机种子,便于复现结果 digits = load_digits() # 读取 1797 张 8×8 的手写数字图片 X_data = digits.data.astype(np.float32) # 将图片数据转换为 float32 类型 Y_data = digits.target.astype(np.int64) # 将标签转换为 int64 类型,供交叉熵损失函数使用 print(X_data.shape) # 输出原始图片数据形状,结果为 (1797, 64) print(Y_data.shape) # 输出标签数据形状,结果为 (1797,) scaler = MinMaxScaler() # 创建 Min-Max 归一化器 X_data = scaler.fit_transform(X_data).astype(np.float32) # 将像素数据缩放到 0~1 范围 # PyTorch 图片格式为 [batch, channels, height, width],即 NCHW 格式 X_data = X_data.reshape(-1, 1, 8, 8) # 将 64 个像素还原为 [图片数, 1通道, 8高, 8宽] X = torch.from_numpy(X_data) # 将 NumPy 图片数组转换为 PyTorch 张量 Y = torch.from_numpy(Y_data) # 将 NumPy 标签数组转换为 PyTorch 张量 batch_size = 8 # 设置每一批训练图片数量为 8,与原代码相同 dataset = TensorDataset(X, Y) # 将图片张量和标签张量组合成数据集 train_loader = DataLoader(dataset, batch_size=batch_size, shuffle=False) # 按批次读取数据,与原代码顺序一致 class DigitsCNN(nn.Module): # 定义 CNN 网络类,必须继承 nn.Module def __init__(self): # 定义网络中的各个层 super().__init__() # 初始化父类 nn.Module self.conv1 = nn.Conv2d(1, 10, kernel_size=3, stride=1, padding=1) # 卷积层1:1通道输入,10通道输出,图片大小保持为 8×8 self.pool1 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 池化层1:将 8×8 特征图缩小为 4×4 self.conv2 = nn.Conv2d(10, 5, kernel_size=3, stride=2, padding=1) # 卷积层2:10通道输入,5通道输出,4×4 缩小为 2×2 self.pool2 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 池化层2:将 2×2 特征图缩小为 1×1 self.fc1 = nn.Linear(1 * 1 * 5, 50) # 第二池化后为 5×1×1,共 5 个特征 self.output = nn.Linear(50, 10) # 输出层:输出数字 0~9 对应的 10 个类别分数 def forward(self, x): # 定义数据从输入到输出的计算过程 relu_feature_maps1 = torch.relu(self.conv1(x)) # 卷积层1计算后使用 ReLU 激活函数 max_pool1 = self.pool1(relu_feature_maps1) # 第一个池化层:8×8 特征图缩小为 4×4 conv_out2 = self.conv2(max_pool1) # 第二卷积层接收池化后的特征图:4×4 缩小为 2×2 BN_out = self.bn2(conv_out2) # 对卷积层2输出进行 Batch Normalization relu_BN_maps2 = torch.relu(BN_out) # 对 BN 输出使用 ReLU 激活函数 max_pool2 = self.pool2(relu_BN_maps2) # 使用第二个最大池化层,输出形状为 [batch, 5, 2, 2] max_pool2_flat = torch.flatten(max_pool2, start_dim=1) # 将每张图片的 5×2×2 特征展平为长度 20 的向量 fc_out1 = torch.relu(self.fc1(max_pool2_flat)) # 全连接层计算后使用 ReLU 激活函数 logits = self.output(fc_out1) # 输出 10 个类别的原始预测分数 return logits # 返回预测分数,训练时交叉熵损失函数会自动完成 softmax 计算 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 如果有可用 GPU 则使用 GPU,否则使用 CPU print("使用设备:", device) # 输出当前使用的训练设备 model = DigitsCNN().to(device) # 创建 CNN 模型,并移动到指定设备 criterion = nn.CrossEntropyLoss() # 创建交叉熵损失函数,替代原代码的 softmax 和手写 loss optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # 创建 Adam 优化器,学习率与原代码相同 for epoch in range(1000): # 总共训练 1000 个周期,与原代码相同 model.train() # 切换到训练模式,使 Batch Normalization 使用当前批次统计量 for batch_x, batch_y in train_loader: # 依次读取每一个批次的图片和标签 batch_x = batch_x.to(device) # 将当前批次图片移动到 CPU 或 GPU batch_y = batch_y.to(device) # 将当前批次标签移动到 CPU 或 GPU optimizer.zero_grad() # 清空上一批数据留下的梯度 logits = model(batch_x) # 前向传播,得到当前批次的类别预测分数 loss = criterion(logits, batch_y) # 根据预测分数和真实标签计算损失 loss.backward() # 反向传播,自动计算所有参数的梯度 optimizer.step() # 根据梯度更新卷积核、偏置和全连接层参数 if epoch % 100 == 0: # 每训练 100 个周期输出一次准确率 model.eval() # 切换到预测模式,使 Batch Normalization 使用训练时累计的统计量 with torch.no_grad(): # 预测时关闭梯度计算,节省内存和计算时间 logits = model(X.to(device)) # 对全部图片进行预测 y_pred = torch.argmax(logits, dim=1) # 取得每张图片得分最高的类别编号 accuracy = (y_pred.cpu() == Y).float().mean().item() # 计算全部训练样本上的准确率 print(epoch, accuracy) # 输出当前训练周期和准确率 model.eval() # 最终预测前切换到预测模式 with torch.no_grad(): # 最终预测时不计算梯度 logits = model(X.to(device)) # 输入全部图片并得到最终类别分数 res_ypred = torch.argmax(logits, dim=1).cpu().numpy() # 取最高分的类别,并转换为 NumPy 数组 print(res_ypred) # 输出所有图片的预测结果 print(accuracy_score(Y_data, res_ypred)) # 使用 sklearn 计算并输出最终准确率