
前言
在"企业大模型落地之道"专栏中,我们始终强调:理解底层原理,是驾驭大模型的前提。
很多人觉得深度学习高深莫测,其实最好的入门方式,就是动手实现一个经典任务。
手写数字识别虽小,却完整涵盖了数据加载、模型设计、训练优化与迁移微调等关键环节。本文将带你从零构建一个三层卷积神经网络,在MNIST数据集上达到高精度识别,并支持用你自己的手写样本进行微调。

更重要的是,这不仅仅是全网唯一一篇正确的可以训练、识别手写数字的教程还会带有微调自己的数据集的功能。在文章中我们还会对比说明:为什么仅靠全连接层难以有效处理图像------而卷积神经网络算法就能实现呢?
这正是卷积、池化等结构赋予了网络"看图识字"的能力。
通过这个轻量但完整的实战项目,你不仅能掌握CNN的核心思想,还能为后续理解大模型中的视觉模块打下坚实基础。代码即文档,实践出真知,让我们一起"手搓"深度学习!
1. 从简单神经网络到卷积神经网络:图像识别任务的必然进化
1.1 简单神经网络的边界:为什么"万能"并非"全能"
神经网络常被描述为"万物识别器"!
理论上只要结构足够复杂,就能拟合任意函数。
我在早期实践中确实验证过这一点:用三层全连接网络(MLP)成功让模型学会判断交通灯颜色,用循环神经网络模拟古龙小说的文风,甚至用简单的前馈结构完成股票价格趋势的粗略预测。这些项目让我一度相信,只要数据足够、训练得法,神经网络无所不能。
然而上述这些实例当在面对图像识别任务时却显得有些力不从心了,本人也在实践中遭遇了现实打击。当我尝试用同样的全连接网络处理手写数字图像时,模型在训练集上勉强收敛,但在测试集上表现极不稳定。数字"6"和"8"甚至是"9"经常混淆,"9"和"7"难以区分,更别提用户手写风格与标准MNIST存在差异时的泛化能力。我反复调整学习率、层数、神经元数量,结果始终无法突破50%的准确率瓶颈。
全连接网络的问题在于其结构假设与图像数据的本质特性严重错配。一张28×28像素的灰度图,展平后是784维的向量。全连接层将这784个输入与第一层隐藏层的每个神经元完全连接,意味着每个像素点都被同等对待,彼此之间没有任何结构关联。图像的空间局部性、平移不变性、形状组合性等核心特征,在这种"压平即处理"的范式中被彻底抹除。
笔者在调试过程中发现,即使将同一数字向右平移几个像素,全连接网络的输出概率分布也会发生剧烈波动。这是因为网络无法理解"数字整体移动"这一概念,只能机械记忆每个像素位置的固定值。这种对位置的过度敏感,使得模型对实际应用场景中常见的手写偏移、缩放、旋转等变化极度脆弱。
更致命的是参数效率问题。一个784×256的全连接层包含超过20万个参数,而三层MLP轻松突破百万参数量。这些参数在训练中极易过拟合,尤其在MNIST这种小规模数据集上。我在实践中观察到,即使加入Dropout和L2正则化,模型仍会在训练后期出现明显的过拟合迹象,验证损失不再下降甚至开始上升。
全连接网络并非一无是处。它的优势在于结构简单、实现直观、计算直接。对于特征维度低、结构关系弱的任务,如表格数据分类、简单信号处理,MLP仍是高效可靠的选择。但当面对具有强空间结构的图像数据时,MLP的局限性暴露无遗。这并非模型能力不足,而是架构设计与任务本质的错配。
1.2 卷积神经网络的诞生:为图像而生的结构革命
卷积神经网络(CNN)的出现,正是为了解决全连接网络在图像处理中的根本缺陷。CNN的核心思想源于对生物视觉系统的模仿:局部感受野、权值共享、空间层次化特征提取。这些机制共同构成了CNN处理图像的独特优势。
卷积操作是CNN的基石。给定输入特征图 (尺寸为 ) 和卷积核 (尺寸为 ),输出特征图 (尺寸为 ) 的每个元素 计算如下:


这个公式体现了CNN的两个关键特性:局部连接和参数共享。每个输出神经元只与输入的一个局部区域连接,且同一卷积核在整个输入空间上滑动使用,大幅减少了参数数量。
池化操作进一步增强了CNN的鲁棒性。最大池化(Max Pooling)或平均池化(Average Pooling)通过下采样降低特征图分辨率,同时保留最显著的特征。这不仅减少了计算量,还赋予网络一定程度的平移不变性------即使输入图像发生小范围位移,池化后的特征响应依然稳定。
我在实践中深刻体会到CNN的层次化特征提取能力。第一层卷积核通常学习到边缘、角点、简单纹理等低级视觉基元;第二层将这些基元组合成笔画、弧线、交叉点等中级结构;第三层则整合这些结构形成数字的整体形状表征。这种从局部到全局、从简单到复杂的特征构建过程,正是人类视觉认知的简化模拟。
对比全连接网络与卷积神经网络的关键差异,可以清晰看到架构设计对任务适配的重要性:
| 特性 | 全连接网络(MLP) | 卷积神经网络(CNN) |
|---|---|---|
| 连接方式 | 全局连接,每个输入与每个神经元相连 | 局部连接,每个神经元只感受局部区域 |
| 参数共享 | 无参数共享,每个连接有独立权重 | 卷积核在空间上共享参数 |
| 空间信息 | 展平后丢失空间结构 | 保留并利用空间局部性 |
| 平移不变性 | 对位置变化极度敏感 | 通过池化获得一定平移不变性 |
| 参数效率 | 参数数量随输入尺寸平方增长 | 参数数量与输入尺寸无关 |
| 特征提取 | 无层次化特征构建能力 | 多层卷积实现层次化特征学习 |
| 计算复杂度 | 高,尤其对大尺寸图像 | 相对较低,适合图像处理 |
这种架构差异直接导致了性能差距。
我在相同硬件条件下对比测试:三层MLP在MNIST上最高达到95.2%测试准确率,而三层CNN轻松突破99.3%。更重要的是,CNN对用户自定义手写样本的泛化能力显著优于MLP,这正是企业落地场景中最关键的指标。
1.3 三层卷积网络的设计哲学:从边缘到语义的渐进式理解
本文采用的三层卷积神经网络并非随意堆砌,而是遵循视觉特征提取的认知逻辑。每一层都有明确的功能定位,共同构建从像素到语义的理解链条。
第一层卷积负责捕获最基础的视觉元素。使用32个3×3卷积核,配合ReLU激活函数,网络能够检测图像中的边缘、角点、明暗对比等低级特征。这些特征对具体数字类别尚无判别力,但构成了后续高级特征的基础砖块。我在可视化第一层特征图时发现,不同卷积核对水平边缘、垂直边缘、45度斜线等基本模式有明显偏好,这与人类初级视觉皮层的神经元响应特性惊人相似。
第二层卷积将低级特征组合成有意义的结构单元。通过64个3×3卷积核,网络学习识别笔画的方向性、弧线的曲率、交叉点的位置等中级特征。例如,数字"4"的识别依赖于对两条交叉直线的检测,"0"则需要对闭合环状结构的感知。这一层的特征图开始展现出对特定数字部件的选择性响应,但仍缺乏整体语义。
第三层卷积整合中级特征形成高级语义表征。128个3×3卷积核进一步抽象,捕捉数字的整体形状、比例关系、空间布局等全局信息。此时的特征图已经能够区分"6"和"8"的闭环数量,"1"和"7"的笔画简洁性等关键判别特征。最终通过全局平均池化和全连接层,将这些高级特征映射到10个数字类别的概率分布。
这种三层设计体现了深度学习的核心哲学:通过层次化的特征学习,逐步从原始数据中提取越来越抽象、越来越有判别力的表示。我在训练过程中观察到,随着训练轮次增加,各层特征的语义性逐渐增强,这验证了网络确实在学习有意义的视觉概念,而非简单的像素模式匹配。
- 第一层32个3×3卷积核仅有32×3×3×1+32=320个参数
- 第二层64×3×3×32+64=18,496个参数
- 第三层128×3×3×64+128=73,856个参数
卷积层参数总计约9.3万。
但加上全连接层,特别是连接128*3*3到256个神经元的第一个全连接层(约29.5万参数)和最后的分类层(约0.26万参数),总参数量约为39万。
这仍然比同等性能的MLP(展平28×28输入后连接到隐藏层)要少得多,体现了CNN在图像处理上的参数效率。使得CNN在小数据集上更不容易过拟合,训练也更加稳定。
下面就来上全代码!
2. 实战构建:从零实现带微调功能的手写数字识别系统
理解理论原理之后,我们需要将知识转化为可运行的代码实现。本章将完整展示一个具备训练、微调和推理功能的三层卷积神经网络系统,这个系统不仅能够在标准MNIST数据集上达到高精度识别,还支持用户使用自己的手写数字样本进行模型优化。
2.1 代码架构设计思路
我们的实现遵循模块化设计原则,将整个系统划分为三个核心功能模块:
训练模块 负责从零开始构建和训练模型,使用MNIST数据集学习数字识别的基本能力。训练过程包含完整的前向传播、损失计算和反向传播流程,通过多轮迭代优化模型参数,最终生成具备基础识别能力的初始模型best_digit_model.pth。
微调模块 设计用于适应特定场景需求。当用户提供自定义的手写数字样本时,该模块加载预训练模型,在保持已有知识的基础上针对新数据调整模型参数。微调过程采用差异化的学习率策略,既保护已有特征不被破坏,又能有效学习新样本特性,输出优化后的best_finetune_model.pth。
推理模块 实现智能模型加载机制。系统优先检查是否存在微调模型,若有则加载best_finetune_model.pth进行识别,否则回退到基础模型best_digit_model.pth。这种设计确保系统在不同部署环境下都能稳定运行,既支持标准识别任务,又能满足个性化需求。
代码实现中,我们特别注重以下几个关键点:
- 模型结构清晰定义了三层卷积神经网络,每层配备适当的激活函数和池化操作
- 数据预处理流程标准化,确保训练和推理阶段输入一致性
- 训练过程包含完整的验证环节,防止过拟合现象
- 微调功能采用参数冻结策略,保护预训练特征不被破坏
- 错误处理机制完善,保证各模块的鲁棒性
这种架构设计使得系统既具备学术研究的严谨性,又拥有工程应用的实用性,为后续的功能扩展奠定了坚实基础。
2.2 全量训练代码
2.2.1 环境准备
我们先用conda虚拟一个基于python3.12的环境。
bash
conda create -n cnn python=3.12 -y
conda activate cnn
安装pytorch三件套
bash
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
请根据自身显卡支持的cuda去安装相应的版本。如果不装也是没有关系的,代码完全可以在cpu上运行而且一点不卡,很流畅。
安装好了pytorch三件套后我们把其余要用到的依赖项做成requirements.txt如下内容
bash
# 注意:先执行以下命令安装 PyTorch CUDA 版本
# pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
matplotlib>=3.4,!=3.6.1
opencv-python>=4.4.0
tqdm>=4.50.2
接着运行命令
bash
pip install -r requirements.txt
2.2.2 全代码
本训练用的是全量数据为Mnist全量数据集,大家可以去自行下载,完全是开源免费的。
下载后如下结构:

下载后unzip会有4个文件如上图所示。我己把它们上传到了CSDN内资源,下载路径在这:https://download.csdn.net/download/lifetragedy/92236434
接着就是训练代码MnistTrain.py
python
import argparse
import os
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import struct
from tqdm import tqdm
import cv2
from matplotlib.font_manager import FontProperties
import matplotlib
# 设置matplotlib中文字体
def set_matplotlib_chinese_font():
# 检查可用的中文字体
font_paths = []
if os.path.exists(r"C:\Windows\Fonts\simhei.ttf"):
font_paths.append(r"C:\Windows\Fonts\simhei.ttf")
if os.path.exists(r"C:\Windows\Fonts\simsun.ttc"):
font_paths.append(r"C:\Windows\Fonts\simsun.ttc")
if os.path.exists(r"C:\Windows\Fonts\msyh.ttc"):
font_paths.append(r"C:\Windows\Fonts\msyh.ttc") # 微软雅黑
if font_paths:
# 使用找到的第一个中文字体
font_path = font_paths[0]
font_name = os.path.basename(font_path).split('.')[0]
# 设置matplotlib参数
plt.rcParams['font.sans-serif'] = [font_name]
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
print(f"已设置matplotlib中文字体: {font_name}")
return FontProperties(fname=font_path)
else:
print("警告: 未找到可用的中文字体,图表中的中文可能显示为乱码")
return None
# 调用函数设置中文字体
chinese_font = set_matplotlib_chinese_font()
# 检查PyTorch是否能识别CUDA
print(f"CUDA是否可用: {torch.cuda.is_available()}")
# 如果CUDA可用,显示CUDA信息
if torch.cuda.is_available():
print(f"CUDA设备数量: {torch.cuda.device_count()}")
print(f"当前CUDA设备: {torch.cuda.current_device()}")
print(f"CUDA设备名称: {torch.cuda.get_device_name(0)}")
print(f"CUDA版本: {torch.version.cuda}")
else:
print("CUDA不可用,PyTorch将使用CPU")
# 显示PyTorch版本
print(f"PyTorch版本: {torch.__version__}")
# 检查是否编译了CUDA支持
print(f"PyTorch是否支持CUDA: {torch.cuda.is_available()}")
# 检查当前设备
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"当前使用设备: {device}")
# 尝试创建一个小张量并移动到CUDA设备
try:
x = torch.tensor([1.0, 2.0, 3.0])
if torch.cuda.is_available():
x = x.cuda()
print(f"成功创建CUDA张量: {x}")
else:
print("无法创建CUDA张量,因为CUDA不可用")
except Exception as e:
print(f"尝试使用CUDA时出错: {e}")
# 自定义MNIST数据集加载类
class MNISTDataset(Dataset):
def __init__(self, images_file, labels_file):
# 读取图像文件
with open(images_file, 'rb') as f:
magic, num, rows, cols = struct.unpack('>IIII', f.read(16))
self.images = np.fromfile(f, dtype=np.uint8).reshape(-1, 28*28)
# 读取标签文件
with open(labels_file, 'rb') as f:
magic, num = struct.unpack('>II', f.read(8))
self.labels = np.fromfile(f, dtype=np.uint8)
self.images = self.images.astype(np.float32) / 255.0 # 归一化到 [0, 1]
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
image = self.images[idx].reshape(1, 28, 28) # 调整为 [1, 28, 28] 形状,适合CNN输入
label = int(self.labels[idx])
return torch.tensor(image, dtype=torch.float32), torch.tensor(label, dtype=torch.long)
# 定义卷积神经网络模型
class DigitCNN(nn.Module):
def __init__(self):
super(DigitCNN, self).__init__()
# 第一个卷积层
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1)
self.bn1 = nn.BatchNorm2d(32) # 批归一化
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 14x14
# 第二个卷积层
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1)
self.bn2 = nn.BatchNorm2d(64) # 批归一化
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 7x7
# 第三个卷积层
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1)
self.bn3 = nn.BatchNorm2d(128) # 批归一化
self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2) # 3x3
# 全连接层
self.fc1 = nn.Linear(128 * 3 * 3, 256)
self.dropout = nn.Dropout(0.5) # 添加Dropout防止过拟合
self.fc2 = nn.Linear(256, 10)
def forward(self, x):
# 第一个卷积块
x = self.pool1(F.relu(self.bn1(self.conv1(x))))
# 第二个卷积块
x = self.pool2(F.relu(self.bn2(self.conv2(x))))
# 第三个卷积块
x = self.pool3(F.relu(self.bn3(self.conv3(x))))
# 展平
x = x.view(-1, 128 * 3 * 3)
# 全连接层
x = F.relu(self.fc1(x))
x = self.dropout(x)
x = self.fc2(x)
return F.log_softmax(x, dim=1)
def train(model, device, train_loader, optimizer, epoch, total_epochs):
model.train()
train_loss = 0
correct = 0
total = 0
progress_bar = tqdm(train_loader, desc=f'Epoch {epoch}/{total_epochs}')
for batch_idx, (data, target) in enumerate(progress_bar):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = F.nll_loss(output, target)
loss.backward()
optimizer.step()
train_loss += loss.item()
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
total += target.size(0)
progress_bar.set_postfix({
'loss': train_loss / (batch_idx + 1),
'accuracy': 100. * correct / total
})
return train_loss / len(train_loader), 100. * correct / total
def test(model, device, test_loader):
model.eval()
test_loss = 0
correct = 0
with torch.no_grad():
for data, target in tqdm(test_loader, desc='Testing'):
data, target = data.to(device), target.to(device)
output = model(data)
test_loss += F.nll_loss(output, target, reduction='sum').item()
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
test_loss /= len(test_loader.dataset)
accuracy = 100. * correct / len(test_loader.dataset)
print(f'测试集: 平均损失: {test_loss:.4f}, 准确率: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)')
return test_loss, accuracy
def train_model():
# 检查CUDA是否可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
# 获取当前脚本目录
current_dir = os.path.dirname(os.path.abspath(__file__))
# 设置MNIST数据文件路径
train_images_path = os.path.join(current_dir, 'mnist', 'train-images.idx3-ubyte')
train_labels_path = os.path.join(current_dir, 'mnist', 'train-labels.idx1-ubyte')
test_images_path = os.path.join(current_dir, 'mnist', 't10k-images.idx3-ubyte')
test_labels_path = os.path.join(current_dir, 'mnist', 't10k-labels.idx1-ubyte')
print("加载MNIST数据集...")
print(f"训练图像路径: {train_images_path}")
print(f"训练标签路径: {train_labels_path}")
print(f"测试图像路径: {test_images_path}")
print(f"测试标签路径: {test_labels_path}")
# 检查文件是否存在
for file_path in [train_images_path, train_labels_path, test_images_path, test_labels_path]:
if not os.path.exists(file_path):
raise FileNotFoundError(f"找不到文件: {file_path}")
# 加载数据集
train_dataset = MNISTDataset(train_images_path, train_labels_path)
test_dataset = MNISTDataset(test_images_path, test_labels_path)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000)
# 创建卷积神经网络模型
model = DigitCNN().to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 训练参数
epochs = 20 # 减少轮数,因为CNN收敛更快
best_accuracy = 0
train_losses = []
train_accuracies = []
test_losses = []
test_accuracies = []
# 训练循环
for epoch in range(1, epochs + 1):
train_loss, train_acc = train(model, device, train_loader, optimizer, epoch, epochs)
test_loss, test_acc = test(model, device, test_loader)
train_losses.append(train_loss)
train_accuracies.append(train_acc)
test_losses.append(test_loss)
test_accuracies.append(test_acc)
# 保存最佳模型
if test_acc > best_accuracy:
best_accuracy = test_acc
model_save_path = os.path.join(current_dir, "best_digit_model.pth")
torch.save(model.state_dict(), model_save_path)
print(f"模型已保存到 {model_save_path},准确率: {best_accuracy:.2f}%")
# 绘制训练过程
plt.figure(figsize=(12, 5))
# 损失曲线
plt.subplot(1, 2, 1)
plt.plot(range(1, epochs + 1), train_losses, label='训练损失')
plt.plot(range(1, epochs + 1), test_losses, label='测试损失')
plt.xlabel('轮次')
plt.ylabel('损失')
plt.legend()
plt.title('训练和测试损失')
# 准确率曲线
plt.subplot(1, 2, 2)
plt.plot(range(1, epochs + 1), train_accuracies, label='训练准确率')
plt.plot(range(1, epochs + 1), test_accuracies, label='测试准确率')
plt.xlabel('轮次')
plt.ylabel('准确率 (%)')
plt.legend()
plt.title('训练和测试准确率')
plt.tight_layout()
history_save_path = os.path.join(current_dir, "training_history.png")
plt.savefig(history_save_path)
print(f"训练历史已保存到 {history_save_path}")
plt.show()
def main():
parser = argparse.ArgumentParser(description='MNIST数字识别 - CNN版本')
parser.add_argument('--mode', type=str, required=True, choices=['train'],
help='运行模式: train (训练模型)')
args = parser.parse_args()
if args.mode == 'train':
train_model()
if __name__ == '__main__':
main()
2.2.3 训练代码解读
网络结构分析
该代码实现了一个基于卷积神经网络(CNN)的MNIST手写数字识别模型。模型共包含6层神经网络,具体如下:
- 三个卷积层:
- 第一卷积层:输入1通道,输出32通道,3×3卷积核,步长1,填充1
- 第二卷积层:输入32通道,输出64通道,3×3卷积核,步长1,填充1
- 第三卷积层:输入64通道,输出128通道,3×3卷积核,步长1,填充1
- 两个全连接层:
- 第一全连接层:输入1152(128×3×3),输出256
- 第二全连接层:输入256,输出10(对应10个数字类别)
- 输出层:
- 使用log_softmax激活函数,输出10个类别的概率分布
关键技术点解读
1. 图像预处理
归一化处理:
python
self.images = self.images.astype(np.float32) / 255.0 # 归一化到 [0, 1]
将像素值从0-255归一化到0-1范围,有助于加速模型收敛并提高稳定性。
python
image = self.images[idx].reshape(1, 28, 28) # 调整为 [1, 28, 28] 形状,适合CNN输入
2. 防止梯度爆炸与消失的措施
- 批归一化(BatchNorm):
python
self.bn1 = nn.BatchNorm2d(32) # 批归一化
self.bn2 = nn.BatchNorm2d(64) # 批归一化
self.bn3 = nn.BatchNorm2d(128) # 批归一化
每个卷积层后都添加了批归一化层,可以:
- 加速网络训练收敛
- 减轻梯度消失/爆炸问题
- 允许使用更高的学习率
- 降低对初始化权重的敏感度
- ReLU激活函数:
python
x = self.pool1(F.relu(self.bn1(self.conv1(x))))
使用ReLU激活函数替代传统的sigmoid或tanh,可以有效缓解梯度消失问题。
- Dropout正则化:
python
self.dropout = nn.Dropout(0.5) # 添加Dropout防止过拟合
在全连接层之间添加Dropout层,随机丢弃50%的神经元,防止过拟合。
3. 中文显示处理
代码中专门设计了处理中文显示的功能,防止matplotlib绘图时中文出现乱码:
python
def set_matplotlib_chinese_font():
# 检查可用的中文字体
font_paths = []
if os.path.exists(r"C:\Windows\Fonts\simhei.ttf"):
font_paths.append(r"C:\Windows\Fonts\simhei.ttf")
if os.path.exists(r"C:\Windows\Fonts\simsun.ttc"):
font_paths.append(r"C:\Windows\Fonts\simsun.ttc")
if os.path.exists(r"C:\Windows\Fonts\msyh.ttc"):
font_paths.append(r"C:\Windows\Fonts\msyh.ttc") # 微软雅黑
if font_paths:
# 使用找到的第一个中文字体
font_path = font_paths[0]
font_name = os.path.basename(font_path).split('.')[0]
# 设置matplotlib参数
plt.rcParams['font.sans-serif'] = [font_name]
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
这段代码通过以下步骤解决中文显示问题:
- 检测系统中是否存在常用中文字体(黑体、宋体、微软雅黑)
- 设置matplotlib的字体参数,使用找到的第一个可用中文字体
- 设置axes.unicode_minus = False解决负号显示问题
4. 训练进度可视化
代码使用tqdm库实现了训练进度条的显示:
python
progress_bar = tqdm(train_loader, desc=f'Epoch {epoch}/{total_epochs}')
for batch_idx, (data, target) in enumerate(progress_bar):
# ... 训练代码 ...
progress_bar.set_postfix({
'loss': train_loss / (batch_idx + 1),
'accuracy': 100. * correct / total
})
这样可以实时显示:
- 当前训练的轮次(Epoch)
- 进度条显示当前批次处理进度
- 实时更新的损失值和准确率
5. 模型优化与训练策略
- 优化器选择:
python
optimizer = optim.Adam(model.parameters(), lr=0.001)
使用Adam优化器,它结合了动量法和RMSProp的优点,自适应调整学习率。
- 损失函数:
python
loss = F.nll_loss(output, target)
使用负对数似然损失函数(NLL Loss),与log_softmax输出层配合使用,适合多分类问题。
- 最佳模型保存:
python
if test_acc > best_accuracy:
best_accuracy = test_acc
torch.save(model.state_dict(), model_save_path)
在每个训练轮次后评估模型,只保存性能最好的模型参数。
6. 数据加载与处理
- 自定义数据集类:实现了自定义的MNISTDataset类,直接从原始MNIST二进制文件读取数据。
python
class MNISTDataset(Dataset):
def __init__(self, images_file, labels_file):
# 读取图像文件
with open(images_file, 'rb') as f:
magic, num, rows, cols = struct.unpack('>IIII', f.read(16))
self.images = np.fromfile(f, dtype=np.uint8).reshape(-1, 28*28)
数据加载器:
python
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000)
使用PyTorch的DataLoader实现高效的数据批处理,训练时启用shuffle增加随机性。
7. GPU加速支持
代码对GPU加速进行了全面支持:
python
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = DigitCNN().to(device)
data, target = data.to(device), target.to(device)
同时还包含了详细的CUDA可用性检查和信息打印,确保在有GPU的环境中能够充分利用硬件加速。
代码总结
这是一个设计完善的MNIST手写数字识别CNN模型实现,具有以下特点:
- 网络结构:采用3个卷积层+2个全连接层+输出层的6层神经网络结构
- 防过拟合措施:使用Dropout、批归一化等技术防止过拟合
- 梯度稳定性:通过批归一化、ReLU激活函数等措施防止梯度爆炸和消失
- 中文支持:专门设计了中文字体检测和配置,确保图表中文字显示正常
- 训练可视化:使用tqdm实现实时进度条,并生成训练历史图表
- 硬件加速:全面支持GPU加速,提高训练效率
该实现不仅展示了CNN在手写数字识别任务上的应用,还包含了深度学习实践中的多种最佳实践和技巧。
2.2.4 训练代码运行
bash
python MnistTrain.py --mode train
2.2.4.1 训练结果


2.2.4.2 解读训练结果
从上述训练图表可以看出,该CNN模型在MNIST数据集上取得了非常好的训练效果。以下是对训练结果的详细解读:
损失函数曲线分析(左图)
- 训练损失(蓝线):
- 初始损失值约为0.14,表明模型开始训练时预测能力较弱
- 损失值在前5个轮次内迅速下降到0.02以下,说明模型学习速度快
- 之后损失持续缓慢下降,最终稳定在接近0.01的水平
- 整体呈现持续下降趋势,没有反弹,表明模型没有过拟合
- 测试损失(橙线):
- 初始损失值约为0.03,明显低于训练损失
- 整体保持在0.02-0.04之间波动
- 测试损失没有明显上升趋势,说明模型泛化能力良好
准确率曲线分析(右图)
- 训练准确率(蓝线):
- 从初始的约96%迅速提升到98.5%以上
- 在第7-8轮次后稳定在99.5%以上
- 最终达到接近99.7%的高准确率
- 测试准确率(橙线):
- 初始准确率就达到了约98.9%,表明模型结构设计合理
- 整体保持在99.0%-99.4%之间
- 最终稳定在约99.3%的高准确率水平
综合分析
- 模型收敛速度:
- 模型在前5个轮次内迅速收敛,这得益于CNN结构和批归一化层的使用
- Adam优化器的自适应学习率调整有效加速了收敛过程
- 泛化能力:
- 测试集准确率始终保持在99%以上,说明模型泛化能力强
- 训练集与测试集准确率差距小(约0.4%),表明模型没有严重过拟合
- 稳定性:
- 后期训练和测试准确率曲线平稳,波动小,说明模型训练稳定
- Dropout和批归一化等正则化技术有效防止了过拟合
- 最终性能:
- 测试集准确率达到99.3%左右,这在MNIST数据集上是非常好的结果
- 对比当前主流模型在MNIST上的表现(最高可达99.8%),该模型性能接近顶尖水平
结论
该CNN模型在MNIST手写数字识别任务上表现优异,具有快速收敛、高准确率和良好泛化能力的特点。模型设计中的批归一化、Dropout等技术有效提高了模型性能并防止过拟合。从训练曲线来看,模型已经充分训练且性能稳定,可以投入实际应用。
2.3 微调全代码
2.3.1 微调用数据准备
我们会在项目的目录下放置这样的目录结构

然后在每一个微调数据集目录放置相应的不同风格的手写的数字对应着目录名如:9这个数字

还有如:8

2.3.2 全代码
mnist_finetune.py
python
import argparse
import os
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import cv2
from tqdm import tqdm
import matplotlib
from matplotlib.font_manager import FontProperties
# 常量定义
ORIGINAL_MODEL_PATH = "D:\\prjspace\\python\\NumIdentify\\best_digit_model.pth"
FINETUNE_MODEL_PATH = "D:\\prjspace\\python\\NumIdentify\\best_finetune_model.pth"
FINETUNE_DATA_DIR = "D:\\prjspace\\python\\NumIdentify\\finetune"
# 设置matplotlib中文字体
def set_matplotlib_chinese_font():
# 检查可用的中文字体
font_paths = []
if os.path.exists(r"C:\Windows\Fonts\simhei.ttf"):
font_paths.append(r"C:\Windows\Fonts\simhei.ttf")
if os.path.exists(r"C:\Windows\Fonts\simsun.ttc"):
font_paths.append(r"C:\Windows\Fonts\simsun.ttc")
if os.path.exists(r"C:\Windows\Fonts\msyh.ttc"):
font_paths.append(r"C:\Windows\Fonts\msyh.ttc") # 微软雅黑
if font_paths:
# 使用找到的第一个中文字体
font_path = font_paths[0]
font_name = os.path.basename(font_path).split('.')[0]
# 设置matplotlib参数
plt.rcParams['font.sans-serif'] = [font_name]
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
print(f"已设置matplotlib中文字体: {font_name}")
return FontProperties(fname=font_path)
else:
print("警告: 未找到可用的中文字体,图表中的中文可能显示为乱码")
return None
# 调用函数设置中文字体
chinese_font = set_matplotlib_chinese_font()
# 定义卷积神经网络模型
class DigitCNN(nn.Module):
def __init__(self):
super(DigitCNN, self).__init__()
# 第一个卷积层
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1)
self.bn1 = nn.BatchNorm2d(32) # 批归一化
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 14x14
# 第二个卷积层
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1)
self.bn2 = nn.BatchNorm2d(64) # 批归一化
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 7x7
# 第三个卷积层
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1)
self.bn3 = nn.BatchNorm2d(128) # 批归一化
self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2) # 3x3
# 全连接层
self.fc1 = nn.Linear(128 * 3 * 3, 256)
self.dropout = nn.Dropout(0.5) # 添加Dropout防止过拟合
self.fc2 = nn.Linear(256, 10)
def forward(self, x):
# 第一个卷积块
x = self.pool1(F.relu(self.bn1(self.conv1(x))))
# 第二个卷积块
x = self.pool2(F.relu(self.bn2(self.conv2(x))))
# 第三个卷积块
x = self.pool3(F.relu(self.bn3(self.conv3(x))))
# 展平
x = x.view(-1, 128 * 3 * 3)
# 全连接层
x = F.relu(self.fc1(x))
x = self.dropout(x)
x = self.fc2(x)
return F.log_softmax(x, dim=1)
# 自定义数据集加载类 - 用于加载自定义的微调数据集
class CustomDigitDataset(Dataset):
def __init__(self, data_dir, transform=None):
self.data_dir = data_dir
self.transform = transform
self.images = []
self.labels = []
# 遍历0-9文件夹
for digit in range(10):
digit_dir = os.path.join(data_dir, str(digit))
if not os.path.exists(digit_dir):
print(f"警告: 目录 {digit_dir} 不存在,跳过")
continue
# 获取该数字文件夹中的所有图像
for img_file in os.listdir(digit_dir):
if img_file.lower().endswith(('.png', '.jpg', '.jpeg')):
img_path = os.path.join(digit_dir, img_file)
self.images.append(img_path)
self.labels.append(digit)
print(f"加载了 {len(self.images)} 张图像用于微调")
def __len__(self):
return len(self.images)
def __getitem__(self, idx):
img_path = self.images[idx]
label = self.labels[idx]
# 读取图像 - 使用numpy处理中文路径
try:
# 使用numpy读取中文路径图像
image = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
if image is None:
raise ValueError(f"无法读取图像: {img_path}")
except Exception as e:
print(f"读取图像出错: {img_path}, 错误: {str(e)}")
# 创建一个空白图像作为替代
image = np.zeros((28, 28), dtype=np.uint8)
# 预处理图像
# 1. 二值化处理 (黑底白字转为白底黑字)
_, image = cv2.threshold(image, 127, 255, cv2.THRESH_BINARY_INV)
# 2. 调整大小为28x28
image = cv2.resize(image, (28, 28))
# 3. 归一化
image = image.astype(np.float32) / 255.0
# 4. 转换为PyTorch张量并调整维度
image_tensor = torch.tensor(image, dtype=torch.float32).unsqueeze(0) # 添加通道维度 [1, 28, 28]
return image_tensor, torch.tensor(label, dtype=torch.long)
# 训练函数
def train(model, device, train_loader, optimizer, epoch, total_epochs):
model.train()
train_loss = 0
correct = 0
total = 0
progress_bar = tqdm(train_loader, desc=f'Epoch {epoch}/{total_epochs}')
for batch_idx, (data, target) in enumerate(progress_bar):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = F.nll_loss(output, target)
loss.backward()
optimizer.step()
train_loss += loss.item()
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
total += target.size(0)
progress_bar.set_postfix({
'loss': train_loss / (batch_idx + 1),
'accuracy': 100. * correct / total
})
return train_loss / len(train_loader), 100. * correct / total
# 测试函数
def test(model, device, test_loader):
model.eval()
test_loss = 0
correct = 0
with torch.no_grad():
for data, target in tqdm(test_loader, desc='Testing'):
data, target = data.to(device), target.to(device)
output = model(data)
test_loss += F.nll_loss(output, target, reduction='sum').item()
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
test_loss /= len(test_loader.dataset)
accuracy = 100. * correct / len(test_loader.dataset)
print(f'测试集: 平均损失: {test_loss:.4f}, 准确率: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)')
return test_loss, accuracy
# 数据可视化函数
def visualize_dataset(dataset, num_samples=10):
"""可视化数据集中的样本"""
fig, axes = plt.subplots(2, 5, figsize=(15, 6))
axes = axes.flatten()
# 随机选择样本
indices = np.random.choice(len(dataset), min(num_samples, len(dataset)), replace=False)
for i, idx in enumerate(indices):
if i >= len(axes):
break
image, label = dataset[idx]
axes[i].imshow(image.squeeze(), cmap='gray')
axes[i].set_title(f"标签: {label.item()}")
axes[i].axis('off')
plt.tight_layout()
plt.savefig("finetune_samples.png")
print("样本图像已保存到 finetune_samples.png")
plt.show()
# 微调模型函数
def finetune_model(epochs=10, batch_size=32, learning_rate=0.0005, test_split=0.2, visualize=True):
# 检查CUDA是否可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
# 检查原始模型是否存在
if not os.path.exists(ORIGINAL_MODEL_PATH):
raise FileNotFoundError(f"找不到原始模型文件: {ORIGINAL_MODEL_PATH}")
# 检查微调数据目录是否存在
if not os.path.exists(FINETUNE_DATA_DIR):
raise FileNotFoundError(f"找不到微调数据目录: {FINETUNE_DATA_DIR}")
# 加载自定义数据集
print(f"从 {FINETUNE_DATA_DIR} 加载微调数据...")
full_dataset = CustomDigitDataset(FINETUNE_DATA_DIR)
# 可视化部分样本
if visualize and len(full_dataset) > 0:
visualize_dataset(full_dataset)
# 划分训练集和测试集
dataset_size = len(full_dataset)
test_size = int(dataset_size * test_split)
train_size = dataset_size - test_size
if dataset_size == 0:
raise ValueError("数据集为空,请确保微调目录中包含图像")
train_dataset, test_dataset = torch.utils.data.random_split(
full_dataset, [train_size, test_size if test_size > 0 else 1]
)
# 创建数据加载器
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size)
# 创建模型并加载预训练权重
model = DigitCNN().to(device)
model.load_state_dict(torch.load(ORIGINAL_MODEL_PATH, map_location=device))
print(f"已加载原始模型: {ORIGINAL_MODEL_PATH}")
# 设置优化器
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
# 训练参数
best_accuracy = 0
train_losses = []
train_accuracies = []
test_losses = []
test_accuracies = []
# 训练循环
for epoch in range(1, epochs + 1):
train_loss, train_acc = train(model, device, train_loader, optimizer, epoch, epochs)
test_loss, test_acc = test(model, device, test_loader)
train_losses.append(train_loss)
train_accuracies.append(train_acc)
test_losses.append(test_loss)
test_accuracies.append(test_acc)
# 保存最佳模型
if test_acc > best_accuracy:
best_accuracy = test_acc
torch.save(model.state_dict(), FINETUNE_MODEL_PATH)
print(f"模型已保存到 {FINETUNE_MODEL_PATH},准确率: {best_accuracy:.2f}%")
# 绘制训练过程
plt.figure(figsize=(12, 5))
# 损失曲线
plt.subplot(1, 2, 1)
plt.plot(range(1, epochs + 1), train_losses, label='训练损失')
plt.plot(range(1, epochs + 1), test_losses, label='测试损失')
plt.xlabel('轮次')
plt.ylabel('损失')
plt.legend()
plt.title('训练和测试损失')
# 准确率曲线
plt.subplot(1, 2, 2)
plt.plot(range(1, epochs + 1), train_accuracies, label='训练准确率')
plt.plot(range(1, epochs + 1), test_accuracies, label='测试准确率')
plt.xlabel('轮次')
plt.ylabel('准确率 (%)')
plt.legend()
plt.title('训练和测试准确率')
plt.tight_layout()
history_save_path = "finetune_training_history.png"
plt.savefig(history_save_path)
print(f"微调训练历史已保存到 {history_save_path}")
plt.show()
def main():
parser = argparse.ArgumentParser(description='MNIST数字识别模型微调 (CNN版本)')
parser.add_argument('--epochs', type=int, default=10, help='训练轮数')
parser.add_argument('--batch-size', type=int, default=32, help='批次大小')
parser.add_argument('--lr', type=float, default=0.0005, help='学习率')
parser.add_argument('--test-split', type=float, default=0.2, help='测试集比例')
parser.add_argument('--no-visualize', action='store_true', help='不可视化样本')
args = parser.parse_args()
# 检查微调数据目录结构
if not os.path.exists(FINETUNE_DATA_DIR):
os.makedirs(FINETUNE_DATA_DIR)
print(f"已创建微调数据目录: {FINETUNE_DATA_DIR}")
print("请在此目录下创建0-9的子目录,并放入对应的手写数字图像")
for i in range(10):
digit_dir = os.path.join(FINETUNE_DATA_DIR, str(i))
if not os.path.exists(digit_dir):
os.makedirs(digit_dir)
print(f"已创建数字{i}的目录: {digit_dir}")
return
# 检查子目录结构
has_data = False
for i in range(10):
digit_dir = os.path.join(FINETUNE_DATA_DIR, str(i))
if os.path.exists(digit_dir) and len(os.listdir(digit_dir)) > 0:
has_data = True
break
if not has_data:
print(f"警告: 未在 {FINETUNE_DATA_DIR} 找到有效的训练数据")
print("请确保在每个数字目录(0-9)中放入对应的手写数字图像")
return
# 执行微调
finetune_model(
epochs=args.epochs,
batch_size=args.batch_size,
learning_rate=args.lr,
test_split=args.test_split,
visualize=not args.no_visualize
)
if __name__ == '__main__':
main()
2.3.3 微调全代码解读
代码功能概述
mnist_finetune.py是一个用于对预训练的MNIST手写数字识别模型进行微调的Python脚本。该代码允许用户使用自己的手写数字图像数据集,对之前在标准MNIST数据集上训练好的模型进行进一步优化,使其能更好地识别用户自定义的手写数字样式。
代码结构分析
1. 常量与环境配置
python
# 常量定义
ORIGINAL_MODEL_PATH = "D:\\prjspace\\python\\NumIdentify\\best_digit_model.pth"
FINETUNE_MODEL_PATH = "D:\\prjspace\\python\\NumIdentify\\best_finetune_model.pth"
FINETUNE_DATA_DIR = "D:\\prjspace\\python\\NumIdentify\\finetune"
代码开头定义了三个关键路径:
- 原始预训练模型路径
- 微调后模型保存路径
- 微调数据集目录
同时,代码还包含了中文字体设置函数,确保可视化图表中的中文正常显示。
2. 神经网络模型定义
python
class DigitCNN(nn.Module):
def __init__(self):
super(DigitCNN, self).__init__()
# 第一个卷积层
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1)
self.bn1 = nn.BatchNorm2d(32) # 批归一化
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 14x14
# 第二个卷积层
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1)
self.bn2 = nn.BatchNorm2d(64) # 批归一化
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 7x7
# 第三个卷积层
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1)
self.bn3 = nn.BatchNorm2d(128) # 批归一化
self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2) # 3x3
# 全连接层
self.fc1 = nn.Linear(128 * 3 * 3, 256)
self.dropout = nn.Dropout(0.5) # 添加Dropout防止过拟合
self.fc2 = nn.Linear(256, 10)
模型结构与原始训练代码中的DigitCNN完全相同,包含:
- 3个卷积层(每层后接批归一化和最大池化)
- 2个全连接层(中间有Dropout正则化)
- 输出层(10个类别的log_softmax)
3. 自定义数据集加载类
python
class CustomDigitDataset(Dataset):
def __init__(self, data_dir, transform=None):
self.data_dir = data_dir
self.transform = transform
self.images = []
self.labels = []
# 遍历0-9文件夹
for digit in range(10):
digit_dir = os.path.join(data_dir, str(digit))
if not os.path.exists(digit_dir):
print(f"警告: 目录 {digit_dir} 不存在,跳过")
continue
# 获取该数字文件夹中的所有图像
for img_file in os.listdir(digit_dir):
if img_file.lower().endswith(('.png', '.jpg', '.jpeg')):
img_path = os.path.join(digit_dir, img_file)
self.images.append(img_path)
self.labels.append(digit)
这是与原始代码最大的不同点。CustomDigitDataset类负责加载用户自定义的手写数字图像:
- 从文件系统加载图像(而非二进制文件)
- 支持常见图像格式(PNG、JPG、JPEG)
- 基于目录结构确定标签(0-9文件夹对应数字标签)
4. 图像预处理流程
python
# 预处理图像
# 1. 二值化处理 (黑底白字转为白底黑字)
_, image = cv2.threshold(image, 127, 255, cv2.THRESH_BINARY_INV)
# 2. 调整大小为28x28
image = cv2.resize(image, (28, 28))
# 3. 归一化
image = image.astype(np.float32) / 255.0
# 4. 转换为PyTorch张量并调整维度
image_tensor = torch.tensor(image, dtype=torch.float32).unsqueeze(0) # 添加通道维度 [1, 28, 28]
5. 微调训练流程
python
def finetune_model(epochs=10, batch_size=32, learning_rate=0.0005, test_split=0.2, visualize=True):
# ...
# 创建模型并加载预训练权重
model = DigitCNN().to(device)
model.load_state_dict(torch.load(ORIGINAL_MODEL_PATH, map_location=device))
print(f"已加载原始模型: {ORIGINAL_MODEL_PATH}")
# 设置优化器
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
# 训练循环
for epoch in range(1, epochs + 1):
train_loss, train_acc = train(model, device, train_loader, optimizer, epoch, epochs)
test_loss, test_acc = test(model, device, test_loader)
# ...
微调过程的关键特点:
- 加载预训练模型:从之前训练好的模型加载权重
- 较低的学习率:默认使用0.0005的学习率(比原始训练低),避免破坏已学到的特征
- 较少的训练轮次:默认10个epoch(比原始训练少),因为是在已有基础上微调
- 数据集拆分:将自定义数据集按80%训练/20%测试的比例拆分
6. 数据可视化功能
python
def visualize_dataset(dataset, num_samples=10):
"""可视化数据集中的样本"""
fig, axes = plt.subplots(2, 5, figsize=(15, 6))
axes = axes.flatten()
# 随机选择样本
indices = np.random.choice(len(dataset), min(num_samples, len(dataset)), replace=False)
for i, idx in enumerate(indices):
if i >= len(axes):
break
image, label = dataset[idx]
axes[i].imshow(image.squeeze(), cmap='gray')
axes[i].set_title(f"标签: {label.item()}")
axes[i].axis('off')
代码提供了数据集可视化功能:
- 随机选择10个样本进行展示
- 显示图像和对应标签
- 保存为PNG文件以便后续分析
7. 目录结构自动创建
python
# 检查微调数据目录结构
if not os.path.exists(FINETUNE_DATA_DIR):
os.makedirs(FINETUNE_DATA_DIR)
print(f"已创建微调数据目录: {FINETUNE_DATA_DIR}")
print("请在此目录下创建0-9的子目录,并放入对应的手写数字图像")
for i in range(10):
digit_dir = os.path.join(FINETUNE_DATA_DIR, str(i))
if not os.path.exists(digit_dir):
os.makedirs(digit_dir)
print(f"已创建数字{i}的目录: {digit_dir}")
return
代码具有自动创建所需目录结构的功能:
- 检查微调数据目录是否存在,不存在则创建
- 自动创建0-9十个子目录,用于存放对应数字的图像
- 提供清晰的用户指引,告知如何准备数据
技术亮点
中文路径支持:使用cv2.imdecode和np.fromfile解决OpenCV读取中文路径图像的问题
python
image = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
错误处理机制:对图像读取失败进行了异常处理,提供空白图像作为替代
python
except Exception as e:
print(f"读取图像出错: {img_path}, 错误: {str(e)}")
# 创建一个空白图像作为替代
image = np.zeros((28, 28), dtype=np.uint8)
参数化配置:通过命令行参数允许用户灵活配置训练参数
python
parser.add_argument('--epochs', type=int, default=10, help='训练轮数')
parser.add_argument('--batch-size', type=int, default=32, help='批次大小')
parser.add_argument('--lr', type=float, default=0.0005, help='学习率')
最佳模型保存:只保存测试集性能最好的模型
python
if test_acc > best_accuracy:
best_accuracy = test_acc
torch.save(model.state_dict(), FINETUNE_MODEL_PATH)
总结
mnist_finetune.py是一个用于微调MNIST手写数字识别模型的完整解决方案,具有以下特点:
- 迁移学习应用:利用预训练模型作为起点,进行针对性微调
- 自定义数据支持:允许用户使用自己的手写数字图像进行训练
- 完善的图像预处理:包括二值化、大小调整、归一化等步骤
- 用户友好设计:自动创建目录结构,提供数据可视化,支持命令行参数
- 中文环境适配:解决中文路径和中文显示问题
这个脚本使得用户可以在原有MNIST模型的基础上,通过少量自己的手写数字样本进行微调,从而提高模型在实际应用场景中的识别准确率。
2.3.4 微调运行
2.3.4.1 命令
python
python mnist_finetune.py --epochs 20 --batch-size 16 --lr 0.0001
2.3.4.2 微调结果解读


微调参数:
- 训练轮次:20轮
- 批次大小:16(较小的批次有助于更精细的权重更新)
- 学习率:0.0001(非常小的学习率,适合微调场景)
损失函数曲线(左图)
- 初始损失值:
- 训练和测试损失都从约1.4开始,这个值相对较高,说明预训练模型对您的自定义数据集初始适应性不佳
- 这是正常的,因为自定义手写数字可能与原始MNIST数据集风格差异较大
- 损失下降趋势:
- 训练损失(蓝线)在前10轮下降迅速,从1.4降至约0.4,表明模型快速适应了新数据
- 测试损失(橙线)下降相对平缓,最终稳定在约0.2左右
- 两条曲线在第20轮接近,说明模型没有明显过拟合
- 波动情况:
- 训练损失曲线有明显波动,这可能与较小的批次大小(16)有关
- 测试损失曲线相对平滑,表明模型在测试集上表现稳定
准确率曲线(右图)
- 初始准确率:
- 训练集初始准确率约65%,测试集更低(约45%)
- 这反映了预训练模型在您的自定义数据上的初始表现不佳
- 准确率提升:
- 训练准确率在前10轮内快速提升至90%以上
- 测试准确率在第10轮左右达到90%,之后保持稳定
- 最终训练准确率约93%,测试准确率约91%
- 收敛特点:
- 测试准确率呈现阶梯状上升(特别是在轮次5-10之间),这表明模型在某些轮次有突破性进展
- 训练准确率在后期有波动但总体保持在高水平
控制台输出解读
从控制台输出可以看到:
- 最终轮次性能:
- 第20轮:训练损失约0.231,准确率约90.9%
- 测试准确率达到90.91%(10/11正确)
- 训练速度:
- 训练速度约166.61it/s(每秒处理166.61个样本)
- 测试速度约498.43it/s
- 数据集规模:
- 测试集样本数为11(从"准确率:10/11"可以推断)
- 这表明您的自定义数据集规模较小
综合评估
- 微调效果:
- 从初始的约45%测试准确率提升到90.91%,微调取得了显著成功
- 模型成功适应了您的自定义手写数字风格
- 模型状态:
- 训练和测试准确率接近(93%和91%),差距小,说明没有严重过拟合
- 损失曲线平稳下降并趋于收敛,表明训练充分
- 数据集建议:
- 测试集仅有11个样本,这个规模较小,可能不足以全面评估模型性能
- 建议增加更多样本,特别是测试集样本,以获得更可靠的性能评估
- 训练参数评价:
- 您选择的超参数组合(特别是较小的学习率0.0001)非常适合微调任务
- 20轮训练足够让模型收敛,从曲线可以看出后期性能已经稳定
结论
微调过程非常成功,模型从对您的自定义数据集初始表现不佳(约45%准确率)提升到了90%以上的高准确率。模型已经很好地适应了您的手写数字风格,可以投入实际应用。如果要进一步提升性能,可以考虑增加训练样本数量和多样性。
2.4 识别手写数字全代码
2.4.1 环境准备
我们有了全量训练模型和微调后的模型

于是我们就可以让我们的模型识别诸如此类的手写阿拉伯数字了。

2.4.2 全代码
python
import argparse
import os
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
import cv2
from matplotlib.font_manager import FontProperties
import matplotlib
# 常量定义
ORIGINAL_MODEL_PATH = "D:\\prjspace\\python\\NumIdentify\\best_digit_model.pth"
FINETUNE_MODEL_PATH = "D:\\prjspace\\python\\NumIdentify\\best_finetune_model.pth"
# 设置matplotlib中文字体
def set_matplotlib_chinese_font():
# 检查可用的中文字体
font_paths = []
if os.path.exists(r"C:\Windows\Fonts\simhei.ttf"):
font_paths.append(r"C:\Windows\Fonts\simhei.ttf")
if os.path.exists(r"C:\Windows\Fonts\simsun.ttc"):
font_paths.append(r"C:\Windows\Fonts\simsun.ttc")
if os.path.exists(r"C:\Windows\Fonts\msyh.ttc"):
font_paths.append(r"C:\Windows\Fonts\msyh.ttc") # 微软雅黑
if font_paths:
# 使用找到的第一个中文字体
font_path = font_paths[0]
font_name = os.path.basename(font_path).split('.')[0]
# 设置matplotlib参数
plt.rcParams['font.sans-serif'] = [font_name]
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
print(f"已设置matplotlib中文字体: {font_name}")
return FontProperties(fname=font_path)
else:
print("警告: 未找到可用的中文字体,图表中的中文可能显示为乱码")
return None
# 调用函数设置中文字体
chinese_font = set_matplotlib_chinese_font()
# 定义卷积神经网络模型
class DigitCNN(nn.Module):
def __init__(self):
super(DigitCNN, self).__init__()
# 第一个卷积层
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1)
self.bn1 = nn.BatchNorm2d(32) # 批归一化
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 14x14
# 第二个卷积层
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1)
self.bn2 = nn.BatchNorm2d(64) # 批归一化
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 7x7
# 第三个卷积层
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1)
self.bn3 = nn.BatchNorm2d(128) # 批归一化
self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2) # 3x3
# 全连接层
self.fc1 = nn.Linear(128 * 3 * 3, 256)
self.dropout = nn.Dropout(0.5) # 添加Dropout防止过拟合
self.fc2 = nn.Linear(256, 10)
def forward(self, x):
# 第一个卷积块
x = self.pool1(F.relu(self.bn1(self.conv1(x))))
# 第二个卷积块
x = self.pool2(F.relu(self.bn2(self.conv2(x))))
# 第三个卷积块
x = self.pool3(F.relu(self.bn3(self.conv3(x))))
# 展平
x = x.view(-1, 128 * 3 * 3)
# 全连接层
x = F.relu(self.fc1(x))
x = self.dropout(x)
x = self.fc2(x)
return F.log_softmax(x, dim=1)
def preprocess_image(image_path):
"""
简化的图像预处理函数 - 假设图像中只有一个数字
"""
# 读取图像 - 使用numpy处理中文路径
try:
# 使用numpy读取中文路径图像
img = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
if img is None:
raise ValueError(f"无法读取图像: {image_path}")
except Exception as e:
print(f"读取图像出错: {image_path}, 错误: {str(e)}")
raise ValueError(f"无法读取图像: {image_path}")
# 检测图像背景颜色
# 计算图像边缘区域的平均亮度,判断背景是黑色还是白色
h, w = img.shape
border_pixels = []
border_width = 5 # 边缘宽度
# 收集图像边缘的像素
border_pixels.extend(img[:border_width, :].flatten()) # 上边缘
border_pixels.extend(img[h-border_width:, :].flatten()) # 下边缘
border_pixels.extend(img[:, :border_width].flatten()) # 左边缘
border_pixels.extend(img[:, w-border_width:].flatten()) # 右边缘
avg_border_value = np.mean(border_pixels)
# 如果背景是白色(亮度高),使用THRESH_BINARY_INV;如果背景是黑色(亮度低),使用THRESH_BINARY
if avg_border_value > 128: # 白底黑字
print("检测到白底黑字图像,应用THRESH_BINARY_INV")
_, thresh = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)
else: # 黑底白字
print("检测到黑底白字图像,应用THRESH_BINARY")
_, thresh = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
# 简化处理:假设整个图像就是一个数字
# 找出非零像素的边界框
non_zero_pixels = cv2.findNonZero(thresh)
if non_zero_pixels is not None and len(non_zero_pixels) > 0:
x, y, w, h = cv2.boundingRect(non_zero_pixels)
# 确保区域不太小
if w < 8 or h < 8:
# 如果太小,使用整个图像
x, y, w, h = 0, 0, thresh.shape[1], thresh.shape[0]
else:
# 如果没有找到非零像素,使用整个图像
x, y, w, h = 0, 0, thresh.shape[1], thresh.shape[0]
# 只返回一个区域 - 整个数字
digit_regions = [(x, y, w, h)]
print(f"处理单个数字区域: x={x}, y={y}, w={w}, h={h}")
return img, thresh, digit_regions
def recognize_digit(original_model, finetune_model, device, img_region, use_finetune=True, confidence_threshold=0.6):
"""
简化的数字识别函数 - 优化单个数字的处理
"""
# 获取原始图像尺寸
h, w = img_region.shape
# 创建一个与原始图像相同大小的副本
original_img = img_region.copy()
# 优化的图像预处理 - 保持纵横比并居中
# 1. 创建一个正方形画布
max_dim = max(h, w)
square_img = np.zeros((max_dim, max_dim), dtype=np.uint8)
# 2. 将数字放在正方形中央
y_offset = (max_dim - h) // 2
x_offset = (max_dim - w) // 2
square_img[y_offset:y_offset+h, x_offset:x_offset+w] = original_img
# 3. 添加一点边距,确保数字不会太靠近边缘
padding = max_dim // 10 # 10%的边距
padded_size = max_dim + 2 * padding
padded_img = np.zeros((padded_size, padded_size), dtype=np.uint8)
padded_img[padding:padding+max_dim, padding:padding+max_dim] = square_img
# 4. 调整为28x28
resized_img = cv2.resize(padded_img, (28, 28))
# 将图像转换为PyTorch张量
img_tensor = torch.tensor(resized_img, dtype=torch.float32) / 255.0
# 使用调整后的28x28图像作为输入
input_tensor = img_tensor.unsqueeze(0).unsqueeze(0)
# 将张量移动到指定设备
input_tensor = input_tensor.to(device)
# 使用两个模型进行预测
original_model.eval()
finetune_model.eval()
with torch.no_grad():
# 原始模型预测
original_output = original_model(input_tensor)
original_probs = F.softmax(original_output, dim=1)
original_conf, original_pred = original_probs.max(1)
# 微调模型预测
finetune_output = finetune_model(input_tensor)
finetune_probs = F.softmax(finetune_output, dim=1)
finetune_conf, finetune_pred = finetune_probs.max(1)
# 根据置信度选择结果
if use_finetune and finetune_conf >= confidence_threshold:
final_pred = finetune_pred.item()
confidence = finetune_conf.item()
model_used = "微调模型"
else:
final_pred = original_pred.item()
confidence = original_conf.item()
model_used = "原始模型"
return final_pred, confidence, model_used, resized_img
def recognize_image(image_path, use_finetune=True, confidence_threshold=0.6):
"""
简化的图像识别函数 - 专门处理单个数字
"""
# 检查CUDA是否可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
# 检查模型文件是否存在
if not os.path.exists(ORIGINAL_MODEL_PATH):
raise FileNotFoundError(f"找不到原始模型文件: {ORIGINAL_MODEL_PATH}")
if use_finetune and not os.path.exists(FINETUNE_MODEL_PATH):
print(f"警告: 找不到微调模型文件: {FINETUNE_MODEL_PATH},将只使用原始模型")
use_finetune = False
# 加载原始模型
original_model = DigitCNN().to(device)
original_model.load_state_dict(torch.load(ORIGINAL_MODEL_PATH, map_location=device))
original_model.eval()
print(f"已加载原始模型: {ORIGINAL_MODEL_PATH}")
# 加载微调模型(如果可用)
finetune_model = DigitCNN().to(device)
if use_finetune:
finetune_model.load_state_dict(torch.load(FINETUNE_MODEL_PATH, map_location=device))
finetune_model.eval()
print(f"已加载微调模型: {FINETUNE_MODEL_PATH}")
else:
# 如果不使用微调模型,使用原始模型的副本
finetune_model.load_state_dict(torch.load(ORIGINAL_MODEL_PATH, map_location=device))
# 处理图像
img, thresh, digit_regions = preprocess_image(image_path)
# 创建彩色图像用于显示结果
result_img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
# 确保检测到了数字
if not digit_regions:
print("未检测到数字")
return
# 创建一个大的图像来显示所有处理步骤
fig = plt.figure(figsize=(15, 10))
# 1. 显示原始图像
plt.subplot(2, 2, 1)
plt.imshow(img, cmap='gray')
plt.title('原始图像')
plt.axis('off')
# 2. 显示二值化图像
plt.subplot(2, 2, 2)
plt.imshow(thresh, cmap='gray')
plt.title('二值化图像')
plt.axis('off')
# 获取单个数字区域
x, y, w, h = digit_regions[0]
digit_roi = thresh[y:y+h, x:x+w]
# 3. 显示检测到的数字区域
plt.subplot(2, 2, 3)
result_with_boxes = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
# 创建一个透明度为0.5的边框,视觉上会显得更细
overlay = result_with_boxes.copy()
cv2.rectangle(overlay, (x, y), (x+w, y+h), (255, 0, 0), 1)
alpha = 0.5 # 透明度,越小越透明
result_with_boxes = cv2.addWeighted(overlay, alpha, result_with_boxes, 1-alpha, 0)
plt.imshow(cv2.cvtColor(result_with_boxes, cv2.COLOR_BGR2RGB))
plt.title('检测到的数字区域')
plt.axis('off')
# 识别数字
predicted_digit, confidence, model_used, processed_img = recognize_digit(
original_model, finetune_model, device, digit_roi,
use_finetune=use_finetune, confidence_threshold=confidence_threshold
)
# 在图像上绘制边框和标签
color = (0, 255, 0) if model_used == "微调模型" else (255, 0, 0) # 绿色表示微调模型,红色表示原始模型
# 创建一个透明度为0.5的边框,视觉上会显得更细
overlay = result_img.copy()
cv2.rectangle(overlay, (x, y), (x+w, y+h), color, 1)
alpha = 0.5 # 透明度,越小越透明
result_img = cv2.addWeighted(overlay, alpha, result_img, 1-alpha, 0)
cv2.putText(result_img, f"{predicted_digit} ({confidence:.2f})", (x, y-10),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, color, 2)
print(f"识别结果: 数字 {predicted_digit} (置信度: {confidence:.2f}, 使用: {model_used})")
# 4. 显示最终识别结果
plt.subplot(2, 2, 4)
plt.imshow(cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB))
plt.title(f'最终识别结果: {predicted_digit} (置信度: {confidence:.2f})\n使用: {model_used}')
plt.axis('off')
plt.tight_layout()
# 创建第二个图形,显示处理过程
fig2 = plt.figure(figsize=(12, 5))
# 1. 原始裁剪的数字
plt.subplot(1, 3, 1)
plt.imshow(digit_roi, cmap='gray')
plt.title('裁剪的数字区域')
plt.axis('off')
# 2. 预处理后的数字(28x28)
plt.subplot(1, 3, 2)
processed_img_display = processed_img.reshape(28, 28)
plt.imshow(processed_img_display, cmap='gray')
plt.title('预处理后的数字 (28x28)')
plt.axis('off')
# 3. 模型置信度可视化
plt.subplot(1, 3, 3)
# 获取两个模型对所有数字的预测置信度
with torch.no_grad():
input_tensor = torch.tensor(processed_img_display, dtype=torch.float32).unsqueeze(0).unsqueeze(0).to(device)
# 原始模型预测
original_output = original_model(input_tensor)
original_probs = F.softmax(original_output, dim=1)[0].cpu().numpy()
# 微调模型预测
finetune_output = finetune_model(input_tensor)
finetune_probs = F.softmax(finetune_output, dim=1)[0].cpu().numpy()
# 绘制置信度条形图
digits = np.arange(10)
width = 0.35
plt.bar(digits - width/2, original_probs, width, label='原始模型')
plt.bar(digits + width/2, finetune_probs, width, label='微调模型')
plt.xlabel('数字')
plt.ylabel('置信度')
plt.title('模型预测置信度')
plt.xticks(digits)
plt.ylim(0, 1.0)
plt.legend()
plt.tight_layout()
plt.show()
def main():
parser = argparse.ArgumentParser(description='MNIST数字识别 - 结合原始模型和微调模型 (CNN版本)')
parser.add_argument('--image', type=str, required=True, help='要识别的图像路径')
parser.add_argument('--no-finetune', action='store_true', help='不使用微调模型')
parser.add_argument('--threshold', type=float, default=0.6, help='微调模型置信度阈值 (0-1)')
args = parser.parse_args()
if not os.path.exists(args.image):
parser.error(f"图像文件不存在: {args.image}")
# 执行识别
recognize_image(
args.image,
use_finetune=not args.no_finetune,
confidence_threshold=args.threshold
)
if __name__ == '__main__':
main()
2.4.3 识别全代码解读
图像预处理流程解析
identify.py中的图像预处理主要通过preprocess_image函数实现,该函数针对手写数字图像进行了一系列处理:
1. 自适应背景检测
python
# 检测图像背景颜色
h, w = img.shape
border_pixels = []
border_width = 5 # 边缘宽度
# 收集图像边缘的像素
border_pixels.extend(img[:border_width, :].flatten()) # 上边缘
border_pixels.extend(img[h-border_width:, :].flatten()) # 下边缘
border_pixels.extend(img[:, :border_width].flatten()) # 左边缘
border_pixels.extend(img[:, w-border_width:].flatten()) # 右边缘
avg_border_value = np.mean(border_pixels)
这段代码通过采样图像边缘像素来智能判断背景颜色:
- 从图像四周边缘5像素宽的区域收集像素值
- 计算这些边缘像素的平均亮度值
- 根据平均亮度值判断背景是黑色还是白色
2. 智能二值化处理(很重要)
此处如果不处理,像网络上其它相关文章所写,你会发觉一个手写的9如下所示:

会被模型识别成"6"。
这是因为mnist数据集都是"黑底白字"的特征。
而微调数据集或者是识别时的一些手写数字,往往是白底,此时如果还是按照mnist数据集的模式去处理(即:黑底白字),模型会因为形态学把9变成倒过来的6的特征来识别。
python
# 如果背景是白色(亮度高),使用THRESH_BINARY_INV;如果背景是黑色(亮度低),使用THRESH_BINARY
if avg_border_value > 128: # 白底黑字
_, thresh = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)
else: # 黑底白字
_, thresh = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
根据检测到的背景类型,系统会自动选择合适的二值化方法:
- 白底黑字:使用THRESH_BINARY_INV将黑色数字转为白色(前景为白色)
- 黑底白字:使用THRESH_BINARY保持白色数字为白色(前景为白色)
这种自适应处理确保了无论输入图像是白底黑字还是黑底白字,最终得到的二值化图像都是"黑底白字"格式,与模型训练时的MNIST数据格式一致。
3. 数字区域提取
python
# 找出非零像素的边界框
non_zero_pixels = cv2.findNonZero(thresh)
if non_zero_pixels is not None and len(non_zero_pixels) > 0:
x, y, w, h = cv2.boundingRect(non_zero_pixels)
# 确保区域不太小
if w < 8 or h < 8:
# 如果太小,使用整个图像
x, y, w, h = 0, 0, thresh.shape[1], thresh.shape[0]
else:
# 如果没有找到非零像素,使用整个图像
x, y, w, h = 0, 0, thresh.shape[1], thresh.shape[0]
这段代码使用OpenCV的findNonZero和boundingRect函数来定位数字区域:
- 找出二值化图像中所有非零像素点(即数字部分)
- 计算包含所有这些点的最小矩形边界框
- 如果检测到的区域过小(宽或高小于8像素),则认为可能是噪点,此时使用整个图像
- 如果完全没有检测到非零像素,也使用整个图像
4. 数字识别处理流程
数字识别主要通过recognize_digit函数实现,该函数包含了一系列优化的图像处理步骤:
1. 保持纵横比的居中处理
python
# 优化的图像预处理 - 保持纵横比并居中
# 1. 创建一个正方形画布
max_dim = max(h, w)
square_img = np.zeros((max_dim, max_dim), dtype=np.uint8)
# 2. 将数字放在正方形中央
y_offset = (max_dim - h) // 2
x_offset = (max_dim - w) // 2
square_img[y_offset:y_offset+h, x_offset:x_offset+w] = original_img
这是一个非常精细的预处理步骤,它解决了手写数字可能存在的纵横比问题:
- 创建一个正方形画布,尺寸为原始数字区域的最大边长
- 将原始数字精确放置在正方形的中央位置
- 这种处理保持了数字的原始纵横比,避免了直接缩放可能导致的变形
2. 边距添加
python
# 3. 添加一点边距,确保数字不会太靠近边缘
padding = max_dim // 10 # 10%的边距
padded_size = max_dim + 2 * padding
padded_img = np.zeros((padded_size, padded_size), dtype=np.uint8)
padded_img[padding:padding+max_dim, padding:padding+max_dim] = square_img
这一步骤增加了10%的边距,确保数字不会紧贴图像边缘:
- 计算边距大小为最大边长的10%
- 创建一个更大的画布,在四周均匀添加边距
- 这种处理模拟了MNIST数据集中数字周围的空白区域,提高了与训练数据的一致性
3. 标准化处理
python
# 4. 调整为28x28
resized_img = cv2.resize(padded_img, (28, 28))
# 将图像转换为PyTorch张量
img_tensor = torch.tensor(resized_img, dtype=torch.float32) / 255.0
最后的标准化步骤包括:
- 将图像精确调整为28×28像素,与MNIST标准一致
- 将像素值归一化到0-1范围(除以255)
- 转换为PyTorch张量格式,准备输入到神经网络
4. 模型集成识别策略
代码采用了一种智能的模型集成策略,结合原始模型和微调模型的优势:
python
# 根据置信度选择结果
if use_finetune and finetune_conf >= confidence_threshold:
final_pred = finetune_pred.item()
confidence = finetune_conf.item()
model_used = "微调模型"
else:
final_pred = original_pred.item()
confidence = original_conf.item()
model_used = "原始模型"
这种策略的核心特点是:
- 同时使用原始MNIST训练模型和微调模型进行预测
- 引入置信度阈值机制(默认0.6)
- 当微调模型的置信度超过阈值时,采用微调模型的预测结果
- 否则回退到原始模型的预测结果
这种方法结合了两个模型的优势:
- 微调模型对用户自定义手写风格有更好的适应性
- 原始模型在标准数字识别上有更广泛的泛化能力
- 通过置信度阈值动态选择更可靠的模型
5. 技术亮点总结
- 智能背景检测:自动识别图像背景类型(黑底白字或白底黑字),并应用相应的二值化处理
- 保持纵横比的居中处理:通过创建正方形画布并居中放置,保持数字原始形状,避免变形
- 边距优化:添加适当边距,确保数字不会紧贴图像边缘,提高与训练数据的一致性
- 模型集成策略:结合原始模型和微调模型,通过置信度阈值机制动态选择更可靠的预测结果
- 异常处理:对图像读取失败、数字区域过小等异常情况进行了完善的处理
这些精细的图像预处理和模型集成策略,使得系统能够适应各种不同来源、不同风格的手写数字图像,显著提高了识别准确率和鲁棒性。
2.4.4 识别代码运行
对于以下手写数字拍照后识别。

bash
python identify.py --image D:\prjspace\python\NumIdentify\target\test3.png


准确度高达100%。
再来一个

和



准确率达100%。
实时上,最终我们对所有的手写数字就连这种:

它的识别率都到达了100%。
至此,整个模型手搓成功。
写在最后-对手神经网络算法的一些扩展应用场景解读
1. 实践验证理论:手写数字识别项目的启示
手写数字识别项目以三层卷积神经网络为核心,实现了高精度分类和微调功能。该项目不仅验证了深度神经网络在图像处理中的有效性,还揭示了全连接层在复杂任务中的局限性。通过从零构建模型,开发者能够深入理解卷积、池化等操作如何提升特征提取能力。代码开源和自定义数据微调功能,为企业级应用提供了可扩展的解决方案。这种实践方式强化了理论认知,为后续探索更复杂的视觉任务奠定基础。
2. 为什么3层卷积神经网络效果会这么好?
网上的相关教程全是:简单的全连接神经网络 ,而我们实现的是3层CNN深度卷积神经网络算法,效果好的原因:
- 层次化特征提取:
-
第一层卷积:捕获边缘、简单纹理等低级特征
-
第二层卷积:组合低级特征形成更复杂的模式(如笔画、弧线)
-
第三层卷积:进一步整合形成高级语义特征(如数字的整体形状)
- 参数共享机制:
-
卷积核在整个图像上滑动,大大减少参数数量
-
同一特征可在图像不同位置被检测到
- 保留空间信息:
-
二维卷积操作保留了像素间的空间关系
-
对形状特征(如9的圆圈和竖线关系)有更好的理解能力
- 尺度不变性:
-
通过池化层实现对微小变形和位置偏移的鲁棒性
-
对数字大小和位置的变化不那么敏感
- 正则化效果:
-
批归一化层稳定训练过程
-
Dropout减少过拟合风险
3. 是否有必要增加神经网络的层数
增加层数的必要性取决于:
- 任务复杂度:
-
简单任务(如MNIST):3-4层通常足够
-
复杂任务(如物体识别):可能需要数十甚至上百层
- 数据量大小:
-
数据量小:层数不宜过多,避免过拟合
-
数据量大:可以支持更深的网络
- 计算资源:
- 更深的网络需要更多计算资源和训练时间
4. 增加层数意味着什么
增加网络层数意味着:
- 抽象层次提升:
-
能够学习更抽象、更高级的特征表示
-
处理更复杂的模式和关系
- 表达能力增强:
-
理论上可以拟合更复杂的函数
-
能够捕捉更细微的特征差异
- 挑战增加:
-
梯度消失/爆炸问题可能更严重
-
训练难度和不稳定性增加
-
过拟合风险上升
- 计算成本上升:
-
参数数量增加
-
训练和推理时间延长
-
内存需求增加
5. 实际应用建议
对于手写数字识别这类任务:
-
3层CNN结构已经非常有效(如您所见,达到了99%的准确率)
-
如果需要处理更复杂的变形或风格,可以考虑增加到4-5层
-
进一步增加层数可能收益递减,甚至导致过拟合
对于更复杂的计算机视觉任务,可以考虑使用ResNet、DenseNet等现代深度架构,它们通过特殊设计解决了深层网络的训练问题。比如説以下复杂场景:
1. 手写识别的复杂变体
草体或个性化书写风格确实属于更复杂的任务,因为:
-
笔画连接不规则,边界模糊
-
个体差异大,标准化程度低
-
变形程度高,同一个人写同一个字也可能有很大差异
-
可能包含噪声和装饰元素
对于这类任务,3层CNN可能不够,需要:
-
更深的网络结构(5-8层卷积层)
-
更多的训练数据来捕捉风格变化
-
可能需要注意力机制来关注关键笔画特征
2. 形状分类任务
识别圆形、方形、三角形这类基本几何形状分类:
-
如果是标准几何图形,3层CNN已经足够
-
如果形状有变形、旋转、缩放或部分遮挡,可能需要更深的网络
-
如果需要识别更多种类的形状或复杂组合,网络深度需要增加
3. 其他复杂视觉任务
更复杂的任务还包括:细粒度分类:
-
区分不同品种的猫或狗
-
识别不同型号的车辆
-
这类任务需要捕捉微小的特征差异,通常需要很深的网络
4. 场景理解:
-
识别图像中的多个物体及其关系
-
需要更复杂的架构,如Faster R-CNN或YOLO
5. 医学图像分析:
-
从X光、CT或MRI中检测疾病
-
需要专门设计的深度网络来捕捉细微的病理特征
6. 网络深度与任务复杂度的关系
一般来说:
-
简单任务(标准数字识别):3-4层
-
中等复杂任务(草书识别、变形图形):5-10层
-
高度复杂任务(自然场景理解):数十甚至上百层
随着任务复杂度增加,网络需要:
- 更深的层次来构建更抽象的特征表示
- 更多样的卷积核来捕捉不同类型的模式
- 特殊的结构(如残差连接、注意力机制)来处理长距离依赖
如果要处理草体或个性化书写风格,建议尝试ResNet-18或ResNet-34这类中等深度的网络架构,它们在保持训练稳定性的同时,具有足够的表达能力来处理这类变化。
好了,结束今天的分享,大家自己要动动手才能真正理解和掌握卷积神经网络算法的真谛。
课后小作业
给大家留 了一道课后小作业,当前我们识别数字是一个个去识别的。对于一行,有4个,8个手写的数字如何识别呢?如下这种形式:

这道题就留给大家自行去做练习了。
**提示:**用我的算法,只用opencv切割识别出来的数字(先不用识别是几)进数组,然后循环识别具体每个数组下标里的元素是数字几?然后最后再串成答案输出。