从MNIST手写数字迁移到EMNIST手写字母的识别之路

发布时间:2026/7/10 6:17:00
从MNIST手写数字迁移到EMNIST手写字母的识别之路 深度学习框架PyTorchPyTorch是一个开源的深度学习框架由 Facebook 的人工智能研究团队开发广泛应用于计算机视觉CV、自然语言处理NLP等领域。它以灵活性和易用性著称特别适合研究人员和开发者进行快速原型设计和实验。PyTorch 的模型训练通常包括以下步骤数据加载使用 Dataset 和 DataLoader 加载和预处理数据。模型定义通过继承 torch.nn.Module 构建神经网络。损失函数与优化器选择合适的损失函数如交叉熵和优化器如 SGD 或 Adam。训练与验证通过循环迭代训练模型并使用验证集评估性能。保存与加载模型使用 torch.save 和 torch.load 保存和恢复模型。深度学习模型CNNCNN卷积神经网络适合用在图像识别和分类中通过一系列的卷积层、池化层和全连接层来处理数据在卷积层中CNN使用一组可学习的滤波器卷积核来扫描输入的图像或信号。每个滤波器都能够检测输入中的特定特征如边缘或颜色斑点。通过这种方式CNN能够捕捉到图像中的局部特征并保持这些特征的空间关系。池化层也称为下采样层则用于降低特征的空间尺寸从而减少参数数量和计算复杂度同时使特征检测更加鲁棒。全连接层则将学习到的高级特征用于分类或其他任务。CNN的结构和工作原理一个典型的CNN包含以下几个主要部分输入层接收原始数据如图像的像素值。卷积层使用多个卷积核提取输入的特征。激活函数如ReLU用于引入非线性使网络能够学习更复杂的特征。池化层降低特征的空间维度减少计算量。全连接层将学习到的特征映射到最终的输出如分类标签。输出层输出网络的最终结果如分类的概率分布。CNN通过这些层的堆叠能够从简单到复杂逐渐提取图像的特征。在训练过程中CNN通过反向传播算法调整卷积核中的权重以最小化预测结果和真实标签之间的差异。训练MNIST❓什么是MNIST全称Modified National Institute of Standards and Technology是机器学习和深度学习领域最经典的入门数据集被称为深度学习的“Hello World”。它包含手写数字0-9 的灰度图像广泛用于图像分类算法的训练与测试。模型定义首先定义模型的结构这个结构在训练和推理过程中都是要保持一致的其中包括模型组件的定义和模型流程定义代码如下所示class Net(nn.Module):definit(self):super(Net, self).init()self.conv1 nn.Conv2d(1, 32, 3, 1)self.conv2 nn.Conv2d(32, 64, 3, 1)self.dropout1 nn.Dropout2d(0.25)self.fc1 nn.Linear(9216, 128)self.fc2 nn.Linear(128, 10)def forward(self, x): x self.conv1(x) x F.relu(x) x self.conv2(x) x F.relu(x) x F.max_pool2d(x, 2) x self.dropout1(x) x torch.flatten(x, 1) x self.fc1(x) x F.relu(x) x self.dropout2(x) x self.fc2(x) output F.log_softmax(x, dim1) return output上面的代码中首先定义了模型的组件结构其次forward是模型的流程结构。这是一个经典的“特征提取 分类器”双阶段结构分为两个阶段。第一阶段特征提取卷积神经网络负责把像素变成“语义特征”。卷积层 1(Conv1)初级特征提取边缘、线条self.conv1 nn.Conv2d(1, 32, 3, 1)in_channels1 : 输入是灰度图1个通道out_channels32: 派出32个不同的“侦探”滤波器kernel_size3 : 每个侦探拿3x3的放大镜stride1 : 每次移动1个像素输出尺寸计算(28 - 3 1) 26 - 输出 (32, 26, 26)卷积层 2高级特征提取部件、纹理self.conv2 nn.Conv2d(32, 64, 3, 1)in_channels32: 接收上一层的32个特征图out_channels64: 增加到64个侦探组合更复杂的模式输出尺寸计算(26 - 3 1) 24 - 输出 (64, 24, 24)Dropout 层 1针对卷积特征的正则化self.dropout1 nn.Dropout2d(0.25)作用随机“掐断”25%的通道整张特征图,强迫模型不要把赌注押在某几个特定的特征组合上为什么用 Dropout2d防止特征图之间产生共适应Co-adaptationDropout 层 2针对全连接层的正则化self.dropout2 nn.Dropout2d(0.5)作用随机“掐断”50%的神经元因为全连接层参数最多最容易过拟合第二阶段分类器全连接网络负责把特征变成具体的数字类别全连接层 1特征映射与降维self.fc1 nn.Linear(9216, 128)输入维度 9216 的由来关键计算经过 Conv2(64, 24, 24) - MaxPool(2, 2)输出尺寸64个通道 * 12 * 12 9216输出维度 128将高维特征压缩成128维的“数字指纹”全连接层 2最终输出层self.fc2 nn.Linear(128, 10)输入 128接收上层的指纹输出 10对应 0-9 十个数字类别而这些阶段落实到的流程执行中需要用到一些额外的手段如下所示relu激活函数引入非线性筛选有效特征max_pool2d最大池化模型不再关心像素的精确位置只关心“有没有”flatten展平把三维特征图拉直成一维向量为了对接全连接层只能吃一维数据log_softmax转换为对数概率forward函数清晰地展示了数据流(Flow)像素 → 边缘 → 部件 → 抽象特征 → 概率。❓ 我当时真实的困惑为什么卷积核数量是 32和 64不能是 30吗9216这个数字是怎么来的forward 里数据尺寸到底是怎么一步步变化的这几个是非常典型、也非常值得认真回答的问题。首先回答第一个问题可以是 30但几乎没人这么做。原因有三个都是工程现实不是玄学1️⃣ 最重要的是GPU 硬件对齐因为现代 GPU 的运算单元CUDA Core / Tensor Core是按 2 的幂次并行工作的能刚好对齐显存带宽和线程块block如果用 30会出现 padding / waste计算效率下降训练变慢。2️⃣ 经验性容量设计在第一层提取简单特征边、角、点32个通道足够覆盖常见低级模式第二层组合成复杂结构弧线、圈、部件需要更多表示能力64是实践中验证过的稳定选择3️⃣ 历史惯性LeNet 传统很多教程只是继承这种 翻倍增长​ 的设计而不是重新发明其次是第二个问题9216 不是玄学是尺寸推导是算出来的✅ 已知前提输入图片28 × 28卷积核3 × 3padding0stride1✅ 每一层尺寸变化层 操作 输出尺寸Input 原始图片 1 × 28 × 28Conv1 3×3 卷积无 padding 32 × 26 × 26MaxPool 2×2 池化 32 × 13 × 13Conv2 3×3 卷积 64 × 11 × 11MaxPool 2×2 池化 64 × 5 × 5⚠️ 注意不同实现可能略有差异但常见版本最后会再接一个卷积或 padding使最终特征图为 64 × 12 × 12。那么最终9216 的来源就是64 个通道× 12 × 12 每个通道的特征图 9216模型训练整体来说训练过程就是把一个 batch 的数据送进模型 → 算损失 → 反向传播 → 更新参数 → 记录日志这是深度学习里“学习”发生的唯一地方 PyTorch 的数据是“流式”的不是一次性全塞进去def train(model, device, train_loader, optimizer, epoch, losses):model.train()for batch_idx, (data, target) in enumerate(train_loader):data, target data.to(device), target.to(device)optimizer.zero_grad() output model(data) loss F.nll_loss(output, target) loss.backward() optimizer.step() losses.append(loss.item()) if batch_idx % 100 0: print( fEpoch {epoch} f[{batch_idx * len(data)}/{len(train_loader.dataset)} f({100. * batch_idx / len(train_loader):.0f}%)]\t fLoss: {loss.item():.6f} )函数参数表含义如下所示参数是什么model 你的 CNN 网络device CPU / GPUtrain_loader 数据加载器自动分批optimizer 优化器Adam / SGDepoch 当前是第几轮losses 用来记录 loss 的列表首先要做的就是让模型切换到训练模式使用model.train()它告诉模型Dropout层开启随机失活BatchNorm层使用当前batch 的均值/方差如果有GPU那么data, target data.to(device), target.to(device)就是把数据搬到GPU因为PyTorch 默认梯度是累加的所以使用optimizer.zero_grad()清空梯度来给新的学习腾地方而模型在这里的作用就是做前向传播就是前面所将的流程Conv1 → ReLU → Conv2 → ReLU → Pool → Dropout → FC → Softmax模型的输出output的形状是(batch_size, 10)计算损失使用loss F.nll_loss(output, target)它衡量的是模型猜的有多离谱其中nll_loss是负对数似然target是真实标签0~9我们肯定是想要最小化损失函数的反向传播Backward Pass是深度学习最核心的一行代码可以自动计算∂Loss∂w然后把梯度存到每个参数的 .grad属性里核心是把“总错误”按责任比例分摊回去对比正向传播过程如下Loss↓∂Loss/∂FC↓∂FC/∂Pool↓∂Pool/∂Conv2↓∂Conv2/∂Conv1↓∂Conv1/∂W有反向传播就可以自动学习而不用手动调参梯度告诉了方向然后是优化器Optimizer Stepoptimizer.step()负责迈步子读取.grad根据优化规则更新参数其中Adam 做的是θθ−η⋅m√vϵ这是“真正动手改模型”的地方大致的训练流程就是这样那么现在开始实际的训练吧。def main():# # 1. 设备选择与配置# # 自动检测是否有可用的 GPUCUDA# 如果有则用 GPU 加速训练否则回退到 CPUdevice torch.device(“cuda” if torch.cuda.is_available() else “cpu”)# # 2. 模型与优化器初始化 # # 实例化我们定义的 CNN 模型 # .to(device) 将模型的所有参数和缓冲区移动到指定设备GPU/CPU model Net().to(device) # 使用 Adam 优化器 # model.parameters() 告诉优化器需要更新哪些参数 # lr0.001 是学习率控制每次参数更新的步长 optimizer optim.Adam(model.parameters(), lr0.001) # # 3. 数据预处理管道 # # transforms.Compose 将多个变换组合成一个流水线 transform transforms.Compose([ # 将 PIL 图像或 numpy.ndarray 转换为 PyTorch Tensor # 同时将像素值从 [0, 255] 缩放到 [0.0, 1.0] transforms.ToTensor(), # 标准化处理Normalization # 使用 MNIST 数据集的官方均值和标准差 # 公式: output (input - mean) / std # 作用: 使数据分布均值为0方差为1加速模型收敛 transforms.Normalize((0.1307,), (0.3081,)) ]) # # 4. 数据集与数据加载器 # # 加载 MNIST 训练数据集 dataset datasets.MNIST( ./data, # 数据存储路径 trainTrue, # 使用训练集而非测试集 downloadTrue, # 如果数据不存在自动从网上下载 transformtransform # 应用上面定义的数据预处理 ) # DataLoader 负责批量加载数据并提供打乱、并行读取等功能 train_loader DataLoader( dataset, # 要加载的数据集 batch_size64, # 每个批次包含 64 个样本 shuffleTrue # 每个 epoch 都打乱数据顺序防止模型记忆顺序 ) # # 5. 训练循环 # # 用于记录每个 batch 的损失值以便后续可视化 losses [] # 训练 10 个 epoch完整遍历数据集 10 次 for epoch in range(1, 11): # 调用我们定义的 train 函数 # 将模型、设备、数据加载器、优化器、当前轮次和损失列表传入 train(model, device, train_loader, optimizer, epoch, losses) # # 6. 保存训练好的模型 # # 保存模型的参数state_dict # 只保存参数而不保存整个模型是推荐做法节省空间且灵活 torch.save(model.state_dict(), mnist_cnn.pt) # # 7. 可视化训练过程 # # 创建一个 8x5 英寸大小的画布 plt.figure(figsize(8, 5)) # 绘制损失曲线 # losses 列表记录了每个 batch 的损失值 plt.plot(losses, labelTraining Loss) # 设置坐标轴标签 plt.xlabel(Batch) # X轴批次索引 plt.ylabel(Loss) # Y轴损失值 # 设置图表标题 plt.title(MNIST CNN Training Loss) # 显示网格线便于观察数值 plt.grid(True) # 显示图例对应 labelTraining Loss plt.legend() # 自动调整子图参数使之填充整个画布 plt.tight_layout() # 显示图形窗口 plt.show()经过训练循环过程后模型的犯错程度在不断的下降如下图所示image到这里基本的训练过程就讲差不多了那么一个epoch 里到底发生了什么Epoch 1├─ Batch 1: 猜 → 算错 → 改参数├─ Batch 2: 猜 → 算错 → 改参数├─ …└─ Batch N: 猜 → 算错 → 改参数Epoch 2├─ 猜得更准一点├─ Loss 更小└─ …Epoch 10└─ 基本稳定训练函数的本质就是用损失函数告诉模型“你错在哪再用反向传播告诉它“该怎么改”模型测试我使用了Forward Hook前向钩子方法来达到可视化推理的中间过程如下所示features {}def hook(name):def fn(_, __, out):features[name] out.detach()return fn注册钩子model.conv1.register_forward_hook(get_features(‘conv1’))model.conv2.register_forward_hook(get_features(‘conv2’))model.dropout1.register_forward_hook(get_features(‘dropout1’))进行测试时加载之前训练好的模型权重并开启model.eval()同时需要注意模型定义要和训练时保持一致device torch.device(“cuda” if torch.cuda.is_available() else “cpu”)model Net().to(device)model.load_state_dict(torch.load(“mnist_cnn.pt”, map_locationdevice))model.eval()模型接受的输入需要先预处理一下因为MNIST的图片有一定规范比如尺寸、颜色等 图片预处理 img_path “my_digit.png”img Image.open(img_path).convert(“L”)✅ 白底黑字 → 黑底白字MNIST 风格img ImageOps.invert(img)resize 到 28×28img img.resize((28, 28), Image.LANCZOS)输入预处理transform T.Compose([T.ToTensor(),T.Normalize((0.1307,), (0.3081,))])获得模型输入input_tensor transform(img).unsqueeze(0).to(device)最后就是模型推理代码了推理模式禁用梯度计算作用节省显存、加快计算、关闭 Dropoutwith torch.no_grad():# # 前向传播让模型看这张图片 # output 形状: (1, 10) # 注意这是 log_softmax 输出对数概率 # output model(input_tensor) # # 将对数概率转换回普通概率 # 公式: prob exp(log_prob) # 作用让人类能直观理解0~1 之间 # prob torch.exp(output) # # 找出概率最大的类别预测结果 # argmax(dim1): 在类别维度上找最大值索引 # item(): 把 Tensor 转成 Python 整数 # 结果: 0~9 的数字 # pred prob.argmax(dim1).item() # # 取出该类别的预测置信度 # prob[0, pred]: 第 0 个样本、预测类别上的概率值 # item(): 转成 Python 浮点数 # 结果: 0.0~1.0 之间的置信度 # conf prob[0, pred].item()使用上面提到的钩子函数获取过程特征图以Conv1举例conv1_feat features[‘conv1’].squeeze().cpu()plt.figure(figsize(12, 6))for i in range(32):plt.subplot(4, 8, i1)plt.imshow(conv1_feat[i], cmap‘gray’)plt.axis(‘off’)plt.suptitle(“Conv1: Edge Detectors”)plt.show()下面就是推理的中间过程图片有4个分别是conv1,conv2,dropout1以及全连接层的输出如下所示Conv1 输出32 张特征图有的对竖边敏感有的对横边敏感有的对角点敏感imageConv2 输出不再是简单边缘开始出现圈、弧线、局部形状imagePooling Dropout输出池化在“做减法”精简信息Dropout 在“做干扰”增强韧性image全连接层抽象成 10 个类别的概率分布这10个概率来源于torch.exp(output)image整个过程中CNN 真的是在从像素 → 边缘 → 部件 → 类别。踩坑实录❌ 真实踩坑问题 现象OpenMP 报错 libiomp5md.dll冲突CUDA 报错 Tensor 不能直接转 NumPy识别不准 字太大 / 太小 / 位置偏✅ 解决方案1️⃣ DLL 冲突Windowsos.environ[“KMP_DUPLICATE_LIB_OK”] “TRUE”2️⃣ GPU → CPU → NumPytensor.cpu().detach().numpy()3️⃣ 图片预处理关键点img Image.open(img_path).convert(“L”) # 灰度img ImageOps.invert(img) # 黑字白底 → 白字黑底img img.crop(bbox) # 去白边img img.resize((28, 28)) # 固定尺寸结论​✅ 90% 的问题不在模型而在数据。迁移训练EMNIST原本我并不想训练EMNIST我想要训练自己的数据集具体一点是想要在已有模型基础上增加新的内容也就是迁移训练。我辛辛苦苦手搓了大小写字母的手写图片(鼠标画的)每个字母有3张总共156张图片分别保存在以大小写字母命名的文件夹目录内。第一次尝试我以为只是把 10改成 62结果数字准确率从 99% → 10% 以失败告终但也让我认识到数据源的重要性我认知到156 张字母图太少了解冻卷积层后模型把数字特征全忘了这是发生了灾难性遗忘然后我开始转为EMNIST数据集并且沿用训练MNIST时的模型。从 MNIST 到 EMNIST 的升级过程也是悲伤的发现效果很差准确率一直在49%徘徊最后排查出来有如下几个原因迁移学习策略错误MNIST 只有 10 类而EMNIST 有 62 类加载了 MNIST 权重直接训练会导致梯度冲突模型容量不足训练MNIST时特征图尺寸变化为Conv(32) → Conv(64) → FC(128) → FC(62)对于 62 类手写字符FC(128) 太小了所以我修改了模型定义以及迁移学习策略把模型容量提升迁移学习时不要一次性训练所有层分阶段进行训练最后进行数据增强添加一些随机旋转和平移等干扰。模型定义对比训练MNIST的模型定义只是增加了模型容量及conv1,conv2,fc1,fc2的尺寸前向传播过程保持不变代码如下所示class Net(nn.Module):definit(self, num_classes62):super(Net, self).init()self.conv1 nn.Conv2d(1, 64, 3, 1)self.conv2 nn.Conv2d(64, 128, 3, 1)self.dropout1 nn.Dropout2d(0.25)self.dropout2 nn.Dropout2d(0.5)self.fc1 nn.Linear(128 * 12 * 12, 256)self.fc2 nn.Linear(256, num_classes)def forward(self, x): x F.relu(self.conv1(x)) x F.relu(self.conv2(x)) x F.max_pool2d(x, 2) x self.dropout1(x) x torch.flatten(x, 1) x F.relu(self.fc1(x)) x self.dropout2(x) x self.fc2(x) return F.log_softmax(x, dim1)模型训练首先加载数据以及输入数据的预处理定义模型输入数据预处理进行了数据增强transform_train transforms.Compose([transforms.RandomRotation(90), # 关键允许 ±90° 旋转transforms.RandomAffine(degrees15,translate(0.1, 0.1),scale(0.8, 1.2)),transforms.ToTensor(),transforms.Normalize((0.1307,), (0.3081,))])加载EMNIST数据集train_dataset datasets.EMNIST(‘./data’, split‘byclass’, trainTrue, downloadTrue, transformtransform_train)定义数据加载器决定怎么给数据train_loader DataLoader(train_dataset,batch_size128, # 从 64 → 128shuffleTrue,num_workers4, # CPU 核心数pin_memoryTrue, # GPU 加速persistent_workersTrue)然后加载已训练好的MNIST权重并且仅加载卷积层mnist_weights torch.load(‘mnist_cnn.pt’, map_locationdevice)conv_dict {k: v for k, v in mnist_weights.items()if ‘conv’ in k and v.shape model.state_dict()[k].shape}model_dict model.state_dict()model_dict.update(conv_dict)model.load_state_dict(model_dict)接下来就是分阶段训练的内容了主要分为两个阶段第一阶段是冻结其他层只训练分类头(全连接层)以保证已学习的特征不会被破坏这是迁移学习常用的方式。第二个阶段就是微调整个模型对预训练模型的参数进行调整使模型更好地适应新的内容。def train_stage(stage, epochs, lr, train_allFalse):“”执行一个训练阶段stage-wise training参数说明 stage : 当前是第几个训练阶段仅用于打印 epochs : 该阶段要训练的 epoch 数 lr : 学习率 train_all : 是否训练所有层 False → 只训练分类头迁移学习常用 True → 微调整个模型 # # 1. 打印当前训练阶段信息 # print(f\n{*50}) print(fStage {stage}: {All layers if train_all else Only classifier}) print(f{*50}) # # 2. 控制哪些层参与训练 # if not train_all: # 2.1 先冻结整个模型的所有参数 for param in model.parameters(): param.requires_grad False # 2.2 只解冻分类头全连接层 for param in model.fc1.parameters(): param.requires_grad True for param in model.fc2.parameters(): param.requires_grad True # ✅ 目的 # 保护卷积层已经学到的通用特征边缘、形状 # 防止小数据集导致“灾难性遗忘” else: # 2.3 微调整个模型 for param in model.parameters(): param.requires_grad True # # 3. 优化器只优化需要梯度的参数 # optimizer optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lrlr ) # # 4. 学习率调度器 # # 每 3 个 epoch 将学习率乘以 0.5 scheduler optim.lr_scheduler.StepLR( optimizer, step_size3, gamma0.5 ) # # 5. 损失函数 # # NLLLoss 配合 LogSoftmax 使用 criterion nn.NLLLoss() # # 6. 开始训练循环 # for epoch in range(1, epochs 1): # 6.1 设置模型为训练模式 # 启用 Dropout / BatchNorm 的训练行为 model.train() total_loss 0 correct 0 total 0 # 6.2 遍历训练集 for batch_idx, (data, target) in enumerate(train_loader): # 6.3 数据搬到设备CPU / GPU data, target data.to(device), target.to(device) # 6.4 清空梯度防止梯度累加 optimizer.zero_grad() # 6.5 前向传播 output model(data) # 6.6 计算损失 loss criterion(output, target) # 6.7 反向传播 loss.backward() # 6.8 更新参数 optimizer.step() # # 7. 统计指标 # total_loss loss.item() pred output.argmax(dim1) correct pred.eq(target).sum().item() total target.size(0) # 7.1 更新学习率 scheduler.step() # 7.2 计算准确率 acc 100. * correct / total # 7.3 打印当前 epoch 结果 print(fEpoch {epoch:02d} | fLoss: {total_loss/len(train_loader):.4f} | fAcc: {acc:.2f}% | fLR: {scheduler.get_last_lr()[0]:.6f})执行训练train_stage(stage1, epochs5, lr0.001, train_allFalse) # 只训练分类器train_stage(stage2, epochs15, lr0.0001, train_allTrue) # 微调整个网络训练好之后可以先拿原生测试数据集(EMNIST)进行大规模测试看看准确率怎么样测试代码如下所示 1. 加载你训练好的模型和训练时结构完全一致 device torch.device(“cuda” if torch.cuda.is_available() else “cpu”)model Net(num_classes62).to(device)checkpoint torch.load(“emnist_62class_final.pt”, map_locationdevice)model.load_state_dict(checkpoint[“model_state_dict”])model.eval()print(f✅ 模型加载成功训练时准确率: {checkpoint.get(‘accuracy’, ‘未知’)}%) 2. 加载EMNIST测试集和训练时用完全一样的transform test_transform transforms.Compose([transforms.ToTensor(),transforms.Normalize((0.1307,), (0.3081,))])test_dataset datasets.EMNIST(root“./data”,split“byclass”, # 必须和训练时一致trainFalse,downloadFalse,transformtest_transform)print(f 测试集总样本数: {len(test_dataset)}“)print(f 标签范围: {min(test_dataset.targets.numpy())} ~ {max(test_dataset.targets.numpy())}”) 3. 全量预测 统计 correct_total 0correct_digit 0total_digit 0correct_upper 0total_upper 0correct_lower 0total_lower 0收集错误样本key是(真实标签, 预测标签)value是(图片tensor, 出现次数)error_cases {}with torch.no_grad():for idx in range(len(test_dataset)):img, true_label test_dataset[idx]# 增加batch维度img_input img.unsqueeze(0).to(device)# 预测 output model(img_input) pred_label output.argmax(dim1).item() prob torch.exp(output).max().item() # 统计总准确率 if pred_label true_label: correct_total 1 else: # 记录错误样本 error_key (true_label, pred_label) if error_key not in error_cases: error_cases[error_key] [] error_cases[error_key].append((img.cpu(), prob)) # 分大类统计 if true_label 10: # 数字 total_digit 1 if pred_label true_label: correct_digit 1 elif true_label 36: # 大写字母 total_upper 1 if pred_label true_label: correct_upper 1 else: # 小写字母 total_lower 1 if pred_label true_label: correct_lower 1 4. 输出结果 total_acc 100 * correct_total / len(test_dataset)digit_acc 100 * correct_digit / total_digit if total_digit 0 else 0upper_acc 100 * correct_upper / total_upper if total_upper 0 else 0lower_acc 100 * correct_lower / total_lower if total_lower 0 else 0print(“\n” “”*60)print(f 全量测试结果共{len(test_dataset)}张图:“)print(f” 总准确率: {total_acc:.2f}%“)print(f” 数字(0-9)准确率: {digit_acc:.2f}% ({correct_digit}/{total_digit})“)print(f” 大写字母(A-Z)准确率: {upper_acc:.2f}% ({correct_upper}/{total_upper})“)print(f” 小写字母(a-z)准确率: {lower_acc:.2f}% ({correct_lower}/{total_lower})“)print(”*60) 5. 可视化最常见的错误 if len(error_cases) 0:print(“\n 最常见的5种错误:”)# 按错误次数排序sorted_errors sorted(error_cases.items(), keylambda x: len(x[1]), reverseTrue)[:5]for (true_lbl, pred_lbl), cases in sorted_errors: # 字符映射 def label_to_char(lbl): if lbl 10: return str(lbl) elif lbl 36: return chr(ord(A) lbl -10) else: return chr(ord(a) lbl -36) true_char label_to_char(true_lbl) pred_char label_to_char(pred_lbl) count len(cases) print(f {true_char}(标签{true_lbl}) → {pred_char}(标签{pred_lbl}): {count}次) # 可视化前3个错误样本 plt.figure(figsize(10, 3)) for i in range(min(3, len(cases))): img_tensor, prob cases[i] plt.subplot(1, 3, i1) plt.imshow(img_tensor.squeeze(), cmapgray) plt.title(f真:{true_char} 预:{pred_char}\n置信度:{prob:.3f}) plt.axis(off) plt.suptitle(f错误类型: {true_char}→{pred_char}共{count}次) plt.show()else:print(“\n✅ 没有错误样本模型100%准确几乎不可能说明测试逻辑有问题”)输出如下信息✅ 模型加载成功训练时准确率: 84.91269998194682% 测试集总样本数: 116323 标签范围: 0 ~ 61 全量测试结果共116323张图:总准确率: 84.91%数字(0-9)准确率: 95.64% (55390/57918)大写字母(A-Z)准确率: 82.09% (25733/31346)小写字母(a-z)准确率: 65.23% (17650/27059)这个准确率说明模型没有过拟合、没有崩溃、学到了有效特征但是为什么达不到90%呢其中有几个原因类别极度不平衡EMNIST ByClass 中数字样本多并且相似字符会互相干扰迁移学习上限因为是从 MNIST10 类​ 迁移到 EMNIST62 类所以预训练模型的卷积层是为“数字边缘”设计的不是为“字母曲线”但可以看到其中对数字的识别最准小写字母不太好分辨模型测试遇到一个最隐蔽的坑 —— 图片方向。我写了一个大写 A模型说是 F / t / r直到我把图片旋转 90°它才认出来。排查出原因如下EMNIST 来自 NIST 扫描表单原始数据方向并不统一我“正着写”模型“横着看”典型的训练-测试分布不一致问题测试模型的过程同上也是图片预处理和模型输入预处理直接看结果吧先鼠标画一张“A图片作为模型输入的图片做了90°旋转image下面是模型的TOP-5预测结果image