利用卷积神经网络实现手写字体识别

前文 利用神经网络实现手写字体识别 中构建的模型只单单使用了全连接层, 其中每一层的神经元都会与前一层的所有神经元相连接,这种结构其实更适合于结构化数据或一维数据。而像手写字体识别之类的图像结构数据,使用卷积神经网络其实会更合适一点,一方面通过卷积层可以提取局部特征,另一方面经过池化层还能减少参数量,提高处理效率。

1.卷积神经网络

卷积神经网络(Convolutional Neural Network,简称CNN)是一种特别适合处理图像、视频、语音、文本等数据的神经网络结构,它通过模仿生物视觉系统的工作原理,利用 卷积层池化层全连接层 来提取数据中的空间特征,并通过训练来优化参数,这在计算机视觉任务中有着广泛应用。

1.基本原理

CNN的核心思想是“卷积”操作。简单来说,卷积是用一个小的矩阵(称为卷积核)在输入图像上滑动,通过和图像的局部区域进行逐点相乘并求和,提取出图像的局部特征。卷积操作通常会在多个卷积层次上进行,从而能够识别图像中的简单特征(如边缘、纹理)和复杂特征(如物体、面部等)。

2.基本结构

  1. 卷积层(Convolutional Layer):也是CNN核心部分,通过卷积核在输入数据(如图像)上滑动,逐步提取边缘、纹理、角点等局部特征。卷积层会将输入数据映射到多个特征图(Feature Maps),每个卷积核生成一个特征图,即输出的通道数等于卷积核的数量。
  2. 池化层(Pooling Layer):用于 下采样,减少特征图的尺寸,从而减少计算量、提高模型的计算效率,并防止过拟合。常用的池化方法包括 最大池化(Max Pooling)和 平均池化(Average Pooling)。
  3. 激活函数:卷积层和全连接层的输出通常会通过 激活函数(如 ReLU、Sigmoid 或 Tanh)进行非线性变换,以帮助模型学习复杂的特征。
  4. 全连接层(Fully Connected Layer):一般放在CNN的最后,通常会通过一个或多个全连接层将提取到的特征转换为最终输出。比如,图像分类的类别概率。

3.特点

  1. 局部连接:每个卷积核只与输入数据的一个小区域进行连接(局部感受野),这样可以有效地捕捉数据的局部特征(如图像中的边缘、角点、纹理等)
  2. 共享权重:同一个卷积核在所有输入区域使用相同的权重参数,这大大减少了参数的数量,提高了计算效率
  3. 层次化特征提取:CNN 可以通过多层卷积操作逐层提取数据的特征,从低级特征(如边缘)到高级特征(如物体的形状),这种层次化的特征提取方式能够捕捉复杂的模式
  4. 平移不变性:CNN 通过卷积操作具备一定的 平移不变性,即如果图像中的物体发生平移,卷积层仍能有效地识别物体。这是因为卷积核在图像中滑动,每个区域的特征都有相同的权重
  5. 高效的参数共享:由于卷积核共享权重,CNN 的参数数量远低于传统的全连接神经网络,这样使得 CNN 更适合处理大型图像数据集,且训练时所需的计算和内存资源较少

2.数据集处理

加载 MNIST 数据集,并对数据进行预处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from torchvision import datasets, transforms

def load_dataset():
train_ds = datasets.MNIST(root='./data',
train=True,
transform=transforms.ToTensor(),
download=True)

test_ds = datasets.MNIST(root='./data',
train=False,
transform=transforms.ToTensor())
return train_ds, test_ds


def load_loader(train_ds, test_ds):
train_dl = torch.utils.data.DataLoader(dataset=train_ds,
batch_size=batch_size,
shuffle=True)
test_dl = torch.utils.data.DataLoader(dataset=test_ds,
batch_size=batch_size,
shuffle=True)
return train_dl, test_dl

1.加载数据

torchvision的datasets中导入自带的MNIST数据集并保存在root路径下,train=True,表示加载的是训练集(train=False 则表示加载测试集)。

常用的数据集:
图像分类:CIFAR-10、ImageNet等
目标检测与分割:COCO、PASCAL VOC、Cityscapes等
人脸识别:LFW(Labeled Faces in the Wild)
支持学习与小样本学习:Omniglot

这些数据集大大方便了研究人员、开发者进行计算机视觉任务的训练和测试。随着计算机视觉的发展,datasets模块也在不断更新扩展中。

数据加载器(DataLoader)为了高效批量加载数据,batch_size为每个批次的数据量,shuffle表示是否打乱数据

2.预处理

transforms.ToTensor() 会将加载的图像数据转换为 张量(Tensor),并将像素值从 [0, 255] 范围缩放到 [0, 1] 范围。此转换是为了方便后续神经网络训练。

3.模型定义

Mnist_CNN模型中,包括两部分构造函数和前向传播,前向传播函数会在模型训练的时候自动调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import torch.nn as nn  

class Mnist_CNN(nn.Module):
def __init__(self):
super(Mnist_CNN, self).__init__()

self.conv1 = nn.Sequential( # 输入大小 (1, 28, 28)
nn.Conv2d(in_channels=1, out_channels=16, kernel_size=5, stride=1, padding=2),
nn.ReLU(), # relu层
nn.MaxPool2d(kernel_size=2), # 进行池化操作
) # 输出(16, 14, 14)

self.conv2 = nn.Sequential(
nn.Conv2d(16, 32, 5, 1, 2), # 输出 (32, 14, 14)
nn.ReLU(), # relu层
nn.MaxPool2d(2), # 进行池化操作
) # 输出 (32, 7, 7)

self.conv3 = nn.Sequential(
nn.Conv2d(32, 64, 5, 1, 2),
nn.ReLU(),
) # 输出 (64, 7, 7)

self.out = nn.Linear(64 * 7 * 7, 10)# 全连接层得到的结果

def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
x = self.conv3(x)
x = x.view(x.size(0), -1) # flatten操作 (batch_size, 32 * 7 * 7)
output = self.out(x)
return output

1.构造函数

构造函数会初始化神经网络的各个层,模型中定义了三个卷积层和一个全连接层:

1.卷积层conv1

nn.Conv2d:这是一个二维卷积层,用来提取输入图像的局部特征

  • in_channels=1:输入图像的通道数,MNIST 图像是单通道(灰度图),所以是 1
  • out_channels=16:卷积层输出的特征图的通道数,设置为16,意味着该层将学习16个不同的特征
  • kernel_size=5:卷积核的大小为5x5
  • padding=2:在图像边缘添加2个像素的零填充,目的是让输出图像的大小与输入相对称

nn.ReLU():这是一个激活函数层,使用 ReLU(Rectified Linear Unit)激活函数来对卷积结果进行非线性映射,ReLU 的作用是将所有负值置为零。

nn.MaxPool2d:池化层,进行下采样(降维)操作。kernel_size=2:池化窗口大小是2x2,表示每个2x2的区域都会被池化成一个最大值(最大池化),池化操作可以降低计算量并提取最重要的特征。

输出尺寸:原始输入图像的尺寸是 (28, 28),经过卷积后,尺寸为 (28, 28),再加上池化操作,最终输出为 (16, 14, 14),也就是16个14x14的特征图。

2.卷积层conv2

作用类似conv1,只是输入和输出的通道数发生了变化:

  • 输入通道是16(来自上一层 conv1 的输出),输出通道是32
  • 卷积核大小、步长和填充方式保持不变

输出尺寸:经过卷积和池化后,输出特征图的尺寸是 (32, 7, 7),即32个7x7的特征图。

3.卷积层conv3

作用同conv1和conv1,输入通道是32,输出通道是64,卷积核大小、步长和填充方式保持不变。
输出尺寸:经过这一层后,输出的特征图尺寸是 (64, 7, 7),即64个7x7的特征图。

4.全连接层out

做一个10分类的操作,将卷积层提取的特征映射到最终的分类结果

  • 因为conv3输出的特征图的尺寸是 (64, 7, 7),输入特征的数量是 64 x 7 x 7 = 3136
  • 因为MNIST数据集有10个数字类别(0-9),输出特征的数量是 10

2.前向传播

数据从输入到输出的流动过程,依次通过三个卷积层和一个全连接层

  • x = self.conv1(x):输入数据首先通过 conv1 层,输出经过 ReLU 激活和池化后的特征
  • x = self.conv2(x):接着通过 conv2 层,继续提取特征
  • x = self.conv3(x):然后经过 conv3 层,进一步提取深层特征
  • x = x.view(x.size(0), -1):这里将卷积层输出的特征图展平(flatten)为一维向量,将(64, 7, 7)特征图转化为64 * 7 * 7 = 3136个特征。x.size(0) 是批次大小(batch_size),-1 代表根据批次大小自动计算展平后的大小
  • output = self.out(x):将展平后的向量通过全连接层,输出 10 个分类结果

4.模型训练和验证

1.计算准确率

返回预测正确的样本数和总样本数

1
2
3
4
def accuracy(predictions, labels):  
pred = torch.max(predictions.data, 1)[1]
rights = pred.eq(labels.data.view_as(pred)).sum()
return rights, len(labels)

实现:

  • torch.max(predictions.data, 1)[1]:返回每个样本在所有类别中最大概率的类别(即预测的类别)。torch.max 返回两个值,第一个是最大值,第二个是最大值对应的索引(即预测类别)
  • pred.eq(labels.data.view_as(pred)):eq 函数用于判断预测值和真实标签是否相等。如果相等返回 True,否则返回 False
  • sum():统计预测正确的样本数

2.训练验证模型

训练模型的主函数,进行模型训练评估

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def train(model, train_loader, test_loader, num_epochs):  
for epoch in range(num_epochs):
# 当前epoch的训练结果
train_rights = []
# 针对容器中的每一批进行循环
for batch_idx, (data, target) in enumerate(train_loader):
model.train()
output = model(data)
loss = criterion(output, target)
optimizer.zero_grad()
loss.backward()
optimizer.step()
right = accuracy(output, target)
train_rights.append(right)

# 每100个批次输出一次训练进度和损失
if batch_idx % 100 == 0:

model.eval()
val_rights = []

# 迭代测试集中的每个批次
for (data, target) in test_loader:
output = model(data)
right = accuracy(output, target)
val_rights.append(right)

# 准确率计算
train_r = (sum([tup[0] for tup in train_rights]), sum([tup[1] for tup in train_rights]))
val_r = (sum([tup[0] for tup in val_rights]), sum([tup[1] for tup in val_rights]))

# 训练进度
train_percent = 100 * batch_idx / len(train_loader)
# 训练集准确率
train_set_accurate = 100 * train_r[0].numpy() / train_r[1]
# 测试集正确率
test_set_accurate = 100 * val_r[0].numpy() / val_r[1]

print(f"""当前epoch:{epoch+1} 训练进度:{train_percent:.0f}% 损失:{loss.data:.6f} 训练集准确率: {train_set_accurate:.2f}% 测试集正确率: {test_set_accurate:.2f}% """)

每个 epoch 中会执行以下步骤,每次迭代叫做一个epoch:

训练

  • for batch_idx, (data, target) in enumerate(train_loader)::对于训练数据集(通过 train_loader 加载),每次获取一个批次的输入数据 data 和目标标签 target
  • model.train():将模型设置为训练模式(启用 dropout 和 batch normalization 等层)
  • output = model(data):通过模型对输入 data 进行前向传播,得到输出 output
  • loss = criterion(output, target):计算预测值 output 与真实标签 target 之间的损失(如交叉熵损失)
  • optimizer.zero_grad():在每个步骤之前清空梯度,以避免梯度累积
  • loss.backward():做反向传播,计算损失的梯度
  • optimizer.step():更新模型的参数(使用梯度下降或其他优化算法)

准确率计算

  • right = accuracy(output, target):通过 accuracy 函数计算当前批次的正确预测数量
  • train_rights.append(right):将当前批次的正确预测数目添加到 train_rights 列表中

验证

  • model.eval():将模型设置为评估模式(禁用 dropout 和 batch normalization)
  • val_rights = []:初始化 val_rights 列表来存储验证集的准确率
  • output = model(data):计算模型对测试集的预测
  • right = accuracy(output, target):计算当前批次的正确预测数
  • val_rights.append(right):将当前批次的预测结果添加到 val_rights

输出

三次epoch输出,测试集正确率相比单纯利用神经网络构建的97%要高一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
当前epoch:1 训练进度:0% 损失:2.298599 训练集准确率: 14.06% 测试集正确率: 10.32% 
当前epoch:1 训练进度:11% 损失:0.243815 训练集准确率: 78.31% 测试集正确率: 93.28%
当前epoch:1 训练进度:21% 损失:0.137110 训练集准确率: 86.57% 测试集正确率: 96.24%
当前epoch:1 训练进度:32% 损失:0.146362 训练集准确率: 89.68% 测试集正确率: 96.94%
当前epoch:1 训练进度:43% 损失:0.161816 训练集准确率: 91.50% 测试集正确率: 97.73%
当前epoch:1 训练进度:53% 损失:0.059365 训练集准确率: 92.69% 测试集正确率: 97.47%
当前epoch:1 训练进度:64% 损失:0.114041 训练集准确率: 93.52% 测试集正确率: 98.34%
当前epoch:1 训练进度:75% 损失:0.100607 训练集准确率: 94.16% 测试集正确率: 98.22%
当前epoch:1 训练进度:85% 损失:0.006606 训练集准确率: 94.65% 测试集正确率: 98.47%
当前epoch:1 训练进度:96% 损失:0.043186 训练集准确率: 95.04% 测试集正确率: 98.83%
当前epoch:2 训练进度:0% 损失:0.003247 训练集准确率: 100.00% 测试集正确率: 98.75%
当前epoch:2 训练进度:11% 损失:0.077238 训练集准确率: 98.47% 测试集正确率: 98.81%
当前epoch:2 训练进度:21% 损失:0.032691 训练集准确率: 98.39% 测试集正确率: 98.75%
当前epoch:2 训练进度:32% 损失:0.019059 训练集准确率: 98.47% 测试集正确率: 98.61%
当前epoch:2 训练进度:43% 损失:0.172091 训练集准确率: 98.46% 测试集正确率: 98.73%
当前epoch:2 训练进度:53% 损失:0.023476 训练集准确率: 98.50% 测试集正确率: 98.76%
当前epoch:2 训练进度:64% 损失:0.013946 训练集准确率: 98.53% 测试集正确率: 98.97%
当前epoch:2 训练进度:75% 损失:0.011354 训练集准确率: 98.57% 测试集正确率: 98.91%
当前epoch:2 训练进度:85% 损失:0.024460 训练集准确率: 98.60% 测试集正确率: 98.98%
当前epoch:2 训练进度:96% 损失:0.021631 训练集准确率: 98.63% 测试集正确率: 98.97%
当前epoch:3 训练进度:0% 损失:0.055015 训练集准确率: 96.88% 测试集正确率: 98.94%
当前epoch:3 训练进度:11% 损失:0.060590 训练集准确率: 99.12% 测试集正确率: 99.07%
当前epoch:3 训练进度:21% 损失:0.066504 训练集准确率: 99.04% 测试集正确率: 98.85%
当前epoch:3 训练进度:32% 损失:0.004511 训练集准确率: 99.03% 测试集正确率: 98.95%
当前epoch:3 训练进度:43% 损失:0.019243 训练集准确率: 99.00% 测试集正确率: 99.17%
当前epoch:3 训练进度:53% 损失:0.037509 训练集准确率: 99.01% 测试集正确率: 99.08%
当前epoch:3 训练进度:64% 损失:0.004599 训练集准确率: 99.03% 测试集正确率: 98.95%
当前epoch:3 训练进度:75% 损失:0.089442 训练集准确率: 99.01% 测试集正确率: 99.12%
当前epoch:3 训练进度:85% 损失:0.018963 训练集准确率: 99.04% 测试集正确率: 98.58%
当前epoch:3 训练进度:96% 损失:0.000661 训练集准确率: 99.04% 测试集正确率: 99.12%

3.调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
input_size = 28  # 图像的总尺寸28*28  
num_epochs = 3 # 训练的总循环周期
batch_size = 64 # 一个批次的大小,64张图片

# 训练集 测试机
train_dataset, test_dataset = load_dataset()
train_loader, test_loader = load_loader(train_dataset, test_dataset)

# 模型
model = Mnist_CNN()
# 损失函数
criterion = nn.CrossEntropyLoss()
# 优化器
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 训练
train(model, train_loader, test_loader, num_epochs)

损失函数

CrossEntropyLoss是PyTorch 中一个常用的损失函数,专门用于 分类任务,尤其是多类别分类。它结合了 Softmax 函数和 负对数似然损失(Negative Log-Likelihood Loss),用于处理分类任务中 概率输出 的问题。
适用范围:真实标签应该是一个 整数,表示每个样本的真实类别索引(而不是 one-hot 编码)

优化器

Adam 是一种常用的优化算法,全名为 Adaptive Moment Estimation,它结合了 MomentumRMSprop 的优点,能够自动调整学习率来提高训练效果,Adam 优化器会通过计算一阶矩(梯度的均值)和二阶矩(梯度的平方均值)来动态调整每个参数的学习率,从而提高训练的效率和稳定性。
适用范围:一种自适应优化算法,非常适用于大多数任务,尤其是当模型参数多,训练过程复杂的时候。

5.总结

本文主要讲通过使用卷积神经网络来实现手写数字识别,相较于神经网络使用卷积神经网络更适合处理图像、视频、语音、文本等的数据,正确率也相对高一点。

6.备注

环境:

  • mac: 15.2
  • python: 3.12.4
  • pytorch: 2.5.1

完整代码:
https://github.com/keychankc/dl_code_for_blog/tree/main/003_cnn_digital_recognition