模子练习入门教程:从MNIST手写数字迁徙到EMNIST手写字母的辨认之路

[复制链接]
发表于 昨天 21:46 | 显示全部楼层 |阅读模式
许多人入门深度学习,第一步就是复制 GitHub 上的 MNIST 代码,跑通,看到 99% 的精确率,然后就以为本身会 CNN 了。
我也一样。直到我开始问本身一些“较真”的标题,才发现:代码能跑 ≠ 我真的懂
这篇教程,记载我从调包、剖解、操持,再到踩完迁徙学习坑的全过程。没有玄学,只有一步一步的实事求是。
本文起首从MNIST的练习过程开始讲起,会具体解说过程和步调,会有图例和代码,以及一些踩到的坑等注意事项。下面先先容一些前置要点:
前言

深度学习框架:PyTorch

PyTorch是一个开源的深度学习框架,由 Facebook 的人工智能研究团队开发,广泛应用于盘算机视觉(CV)、自然语言处置惩罚(NLP)等范畴。它以机动性和易用性著称,特殊适当研究职员和开发者举行快速原型操持和实行。
💡PyTorch 的模子练习通常包罗以下步调:

  • 数据加载:使用 Dataset 和 DataLoader 加载和预处置惩罚数据。
  • 模子界说:通过继续 torch.nn.Module 构建神经网络。
  • 丧失函数与优化器:选择符合的丧失函数(如交错熵)和优化器(如 SGD 或 Adam)。
  • 练习与验证:通过循环迭代练习模子,并使用验证集评估性能
  • 生存与加载模子:使用 torch.save 和 torch.load 生存和规复模子。
深度学习模子:CNN

CNN(卷积神经网络),适当用在图像辨认和分类中,通过一系列的卷积层、池化层和全毗连层来处置惩罚数据
在卷积层中,CNN使用一组可学习的滤波器(卷积核)来扫描输入的图像或信号。每个滤波器都可以或许检测输入中的特定特性,如边沿或颜色斑点。通过这种方式,CNN可以或许捕捉到图像中的局部特性,并保持这些特性的空间关系。
池化层(也称为下采样层)则用于低沉特性的空间尺寸,从而淘汰参数数量和盘算复杂度,同时使特性检测更加鲁棒。全毗连层则将学习到的高级特性用于分类或其他使命。
💡CNN的结构和工作原理
一个范例的CNN包罗以下几个紧张部分:

  • 输入层:吸收原始数据,如图像的像素值。
  • 卷积层:使用多个卷积核提取输入的特性。
  • 激活函数:如ReLU,用于引入非线性,使网络可以或许学习更复杂的特性。
  • 池化层:低沉特性的空间维度,淘汰盘算量。
  • 全毗连层:将学习到的特性映射到终极的输出,如分类标签。
  • 输出层:输出网络的终极结果,如分类的概率分布。
CNN通过这些层的堆叠,可以或许从简单到复杂徐徐提取图像的特性。在练习过程中,CNN通过反向流传算法调解卷积核中的权重,以最小化猜测结果和真实标签之间的差异。
练习MNIST

❓什么是MNIST
全称:(Modified National Institute of Standards and Technology),是呆板学习和深度学习范畴最经典的入门数据集,被称为深度学习的“Hello World”。它包罗手写数字(0-9) 的灰度图像,广泛用于图像分类算法的练习与测试。
模子界说

起首界说模子的结构,这个结构在练习和推理过程中都是要保持划一的,此中包罗模子组件的界说和模子流程界说,代码如下所示:
  1. class Net(nn.Module):
  2.     def __init__(self):
  3.         super(Net, self).__init__()
  4.         self.conv1 = nn.Conv2d(1, 32, 3, 1)
  5.         self.conv2 = nn.Conv2d(32, 64, 3, 1)
  6.         self.dropout1 = nn.Dropout2d(0.25)
  7.         self.fc1 = nn.Linear(9216, 128)
  8.         self.fc2 = nn.Linear(128, 10)
  9.         
  10.     def forward(self, x):
  11.         x = self.conv1(x)
  12.         x = F.relu(x)
  13.         x = self.conv2(x)
  14.         x = F.relu(x)
  15.         x = F.max_pool2d(x, 2)
  16.         x = self.dropout1(x)
  17.         x = torch.flatten(x, 1)
  18.         x = self.fc1(x)
  19.         x = F.relu(x)
  20.         x = self.dropout2(x)
  21.         x = self.fc2(x)
  22.         output = F.log_softmax(x, dim=1)
  23.         return output
复制代码
上面的代码中,起首界说了模子的组件结构,其次forward是模子的流程结构。这是一个经典的“特性提取 + 分类器”双阶段结构,分为两个阶段。
第一阶段:特性提取(卷积神经网络),负责把像素变成“语义特性”。

  • 卷积层 1(Conv1):低级特性提取(边沿、线条)

    • self.conv1 = nn.Conv2d(1, 32, 3, 1)
    • in_channels=1  : 输入是灰度图(1个通道)
    • out_channels=32: 派出32个差异的“侦探”(滤波器)
    • kernel_size=3 : 每个侦探拿3x3的放大镜
    • stride=1      : 每次移动1个像素
    • 输出尺寸盘算:(28 - 3 + 1) = 26 -> 输出 (32, 26, 26)

  • 卷积层 2:高级特性提取(部件、纹理)

    • self.conv2 = nn.Conv2d(32, 64, 3, 1)
    • in_channels=32: 吸收上一层的32个特性图
    • out_channels=64: 增长到64个侦探,组合更复杂的模式
    • 输出尺寸盘算:(26 - 3 + 1) = 24 -> 输出 (64, 24, 24)

  • Dropout 层 1:针对卷积特性的正则化

    • self.dropout1 = nn.Dropout2d(0.25)
    • 作用:随机“掐断”25%的通道(整张特性图),逼迫模子不要把赌注押在某几个特定的特性组合上
    • 为什么用 Dropout2d:防止特性图之间产生共顺应(Co-adaptation)

  • Dropout 层 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 × 3
  • padding:0
  • stride:1
✅ 每一层尺寸变革

层操纵输出尺寸Input原始图片1 × 28 × 28Conv13×3 卷积,无 padding32 × 26 × 26MaxPool2×2 池化32 × 13 × 13Conv23×3 卷积64 × 11 × 11MaxPool2×2 池化64 × 5 × 5⚠️ 注意:差异实现大概略有差异,但常见版本末了会再接一个卷积或 padding,使终极特性图为 64 × 12 × 12。
那么终极,9216 的泉源就是
  1. 64 个通道
  2. × 12 × 12 每个通道的特征图
  3. = 9216
复制代码
模子练习

团体来说,练习过程就是把一个 batch 的数据送进模子 → 算丧失 → 反向流传 → 更新参数 → 记载日记,这是深度学习里“学习”发生的唯一地方
📌 PyTorch 的数据是“流式”的,不是一次性全塞进去
  1. def train(model, device, train_loader, optimizer, epoch, losses):
  2.     model.train()
  3.     for batch_idx, (data, target) in enumerate(train_loader):
  4.         data, target = data.to(device), target.to(device)
  5.         optimizer.zero_grad()
  6.         output = model(data)
  7.         loss = F.nll_loss(output, target)
  8.         loss.backward()
  9.         optimizer.step()
  10.         losses.append(loss.item())
  11.         if batch_idx % 100 == 0:
  12.             print(
  13.                 f'Epoch {epoch} '
  14.                 f'[{batch_idx * len(data)}/{len(train_loader.dataset)} '
  15.                 f'({100. * batch_idx / len(train_loader):.0f}%)]\t'
  16.                 f'Loss: {loss.item():.6f}'
  17.             )
复制代码
函数参数表寄义如下所示:


参数



是什么

model你的 CNN 网络deviceCPU / GPUtrain_loader数据加载器(自动分批)optimizer优化器(Adam / SGD)epoch当前是第几轮losses用来记载 loss 的列表起紧张做的就是让模子切换到练习模式,使用model.train(),它告诉模子Dropout层开启随机失活,BatchNorm层使用当前batch 的均值/方差
如果有GPU,那么data, target = data.to(device), target.to(device)就是把数据搬到GPU
由于PyTorch 默认梯度是累加的,以是使用optimizer.zero_grad()清空梯度,来给新的学习腾地方
而模子在这里的作用就是做前向流传,就是前面所将的流程:
  1. 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)是深度学习最焦点的一行代码,可以自动盘算\(\frac{∂Loss}{∂w}\),然后把
梯度存到每个参数的 .grad属性里,焦点是把“总错误”按责任比例分摊归去,对比正向流传,过程如下:
  1. Loss
  2. ∂Loss/∂FC
  3. ∂FC/∂Pool
  4. ∂Pool/∂Conv2
  5. ∂Conv2/∂Conv1
  6. ∂Conv1/∂W
复制代码
🔑有反向流传就可以自动学习,而不消手动调参
梯度告诉了方向,然后是优化器(Optimizer Step)optimizer.step()负责迈步子,读取.grad,根据优化规则更新参数,此中Adam 做的是:\(\theta=\theta-η⋅ \frac{m}{\sqrt{ v }+ϵ}\),这是“真正动手改模子”的地方
大抵的练习流程就是如许,那么如今开始实际的练习吧。
  1. def main():
  2.     # ==========================================
  3.     # 1. 设备选择与配置
  4.     # ==========================================
  5.     # 自动检测是否有可用的 GPU(CUDA)
  6.     # 如果有则用 GPU 加速训练,否则回退到 CPU
  7.     device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
  8.     # ==========================================
  9.     # 2. 模型与优化器初始化
  10.     # ==========================================
  11.     # 实例化我们定义的 CNN 模型
  12.     # .to(device) 将模型的所有参数和缓冲区移动到指定设备(GPU/CPU)
  13.     model = Net().to(device)
  14.    
  15.     # 使用 Adam 优化器
  16.     # model.parameters() 告诉优化器:需要更新哪些参数
  17.     # lr=0.001 是学习率,控制每次参数更新的步长
  18.     optimizer = optim.Adam(model.parameters(), lr=0.001)
  19.     # ==========================================
  20.     # 3. 数据预处理管道
  21.     # ==========================================
  22.     # transforms.Compose 将多个变换组合成一个流水线
  23.     transform = transforms.Compose([
  24.         # 将 PIL 图像或 numpy.ndarray 转换为 PyTorch Tensor
  25.         # 同时将像素值从 [0, 255] 缩放到 [0.0, 1.0]
  26.         transforms.ToTensor(),
  27.         
  28.         # 标准化处理(Normalization)
  29.         # 使用 MNIST 数据集的官方均值和标准差
  30.         # 公式: output = (input - mean) / std
  31.         # 作用: 使数据分布均值为0,方差为1,加速模型收敛
  32.         transforms.Normalize((0.1307,), (0.3081,))
  33.     ])
  34.     # ==========================================
  35.     # 4. 数据集与数据加载器
  36.     # ==========================================
  37.     # 加载 MNIST 训练数据集
  38.     dataset = datasets.MNIST(
  39.         './data',          # 数据存储路径
  40.         train=True,        # 使用训练集(而非测试集)
  41.         download=True,     # 如果数据不存在,自动从网上下载
  42.         transform=transform  # 应用上面定义的数据预处理
  43.     )
  44.    
  45.     # DataLoader 负责批量加载数据,并提供打乱、并行读取等功能
  46.     train_loader = DataLoader(
  47.         dataset,           # 要加载的数据集
  48.         batch_size=64,     # 每个批次包含 64 个样本
  49.         shuffle=True       # 每个 epoch 都打乱数据顺序(防止模型记忆顺序)
  50.     )
  51.     # ==========================================
  52.     # 5. 训练循环
  53.     # ==========================================
  54.     # 用于记录每个 batch 的损失值,以便后续可视化
  55.     losses = []
  56.     # 训练 10 个 epoch(完整遍历数据集 10 次)
  57.     for epoch in range(1, 11):
  58.         # 调用我们定义的 train 函数
  59.         # 将模型、设备、数据加载器、优化器、当前轮次和损失列表传入
  60.         train(model, device, train_loader, optimizer, epoch, losses)
  61.     # ==========================================
  62.     # 6. 保存训练好的模型
  63.     # ==========================================
  64.     # 保存模型的参数(state_dict)
  65.     # 只保存参数而不保存整个模型是推荐做法,节省空间且灵活
  66.     torch.save(model.state_dict(), "mnist_cnn.pt")
  67.     # ==========================================
  68.     # 7. 可视化训练过程
  69.     # ==========================================
  70.     # 创建一个 8x5 英寸大小的画布
  71.     plt.figure(figsize=(8, 5))
  72.    
  73.     # 绘制损失曲线
  74.     # losses 列表记录了每个 batch 的损失值
  75.     plt.plot(losses, label="Training Loss")
  76.    
  77.     # 设置坐标轴标签
  78.     plt.xlabel("Batch")      # X轴:批次索引
  79.     plt.ylabel("Loss")       # Y轴:损失值
  80.    
  81.     # 设置图表标题
  82.     plt.title("MNIST CNN Training Loss")
  83.    
  84.     # 显示网格线,便于观察数值
  85.     plt.grid(True)
  86.    
  87.     # 显示图例(对应 label="Training Loss")
  88.     plt.legend()
  89.    
  90.     # 自动调整子图参数,使之填充整个画布
  91.     plt.tight_layout()
  92.    
  93.     # 显示图形窗口
  94.     plt.show()
复制代码
颠末练习循环过程后,模子的犯错水平在不绝的降落,如下图所示:

到这里,根本的练习过程就讲差不多了,那么一个epoch 里到底发生了什么?
  1. Epoch 1
  2. ├─ Batch 1: 猜 → 算错 → 改参数
  3. ├─ Batch 2: 猜 → 算错 → 改参数
  4. ├─ ...
  5. └─ Batch N: 猜 → 算错 → 改参数
  6. Epoch 2
  7. ├─ 猜得更准一点
  8. ├─ Loss 更小
  9. └─ ...
  10. Epoch 10
  11. └─ 基本稳定
复制代码
💡练习函数的本质,就是用丧失函数告诉模子“你错在哪,再用反向流传告诉它“该怎么改”
模子测试

我使用了Forward Hook(前向钩子)方法,来到达可视化推理的中心过程,如下所示:
  1. features = {}
  2. def hook(name):
  3.     def fn(_, __, out):
  4.         features[name] = out.detach()
  5.     return fn
  6. # 注册钩子
  7. model.conv1.register_forward_hook(get_features('conv1'))
  8. model.conv2.register_forward_hook(get_features('conv2'))
  9. model.dropout1.register_forward_hook(get_features('dropout1'))
复制代码
举行测试时,加载之前练习好的模子权重,并开启model.eval(),同时须要注意模子界说要和练习时保持划一
  1. device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
  2. model = Net().to(device)
  3. model.load_state_dict(torch.load("mnist_cnn.pt", map_location=device))
  4. model.eval()
复制代码
模子担当的输入须要先预处置惩罚一下,由于MNIST的图片有肯定规范,好比尺寸、颜色等
  1. # ============ 图片预处理 ===============
  2. img_path = "my_digit.png"
  3. img = Image.open(img_path).convert("L")
  4. # ✅ 白底黑字 → 黑底白字(MNIST 风格)
  5. img = ImageOps.invert(img)
  6. # resize 到 28×28
  7. img = img.resize((28, 28), Image.LANCZOS)
  8. # 输入预处理
  9. transform = T.Compose([
  10.     T.ToTensor(),
  11.     T.Normalize((0.1307,), (0.3081,))
  12. ])
  13. # 获得模型输入
  14. input_tensor = transform(img).unsqueeze(0).to(device)
复制代码
末了就是模子推理代码了
  1. # ==========================================
  2. # 推理模式(禁用梯度计算)
  3. # 作用:节省显存、加快计算、关闭 Dropout
  4. # ==========================================
  5. with torch.no_grad():
  6.    
  7.     # ==========================================
  8.     # 前向传播:让模型看这张图片
  9.     # output 形状: (1, 10)
  10.     # 注意:这是 log_softmax 输出(对数概率)
  11.     # ==========================================
  12.     output = model(input_tensor)
  13.    
  14.     # ==========================================
  15.     # 将对数概率转换回普通概率
  16.     # 公式: prob = exp(log_prob)
  17.     # 作用:让人类能直观理解(0~1 之间)
  18.     # ==========================================
  19.     prob = torch.exp(output)
  20.    
  21.     # ==========================================
  22.     # 找出概率最大的类别(预测结果)
  23.     # argmax(dim=1): 在类别维度上找最大值索引
  24.     # item(): 把 Tensor 转成 Python 整数
  25.     # 结果: 0~9 的数字
  26.     # ==========================================
  27.     pred = prob.argmax(dim=1).item()
  28.    
  29.     # ==========================================
  30.     # 取出该类别的预测置信度
  31.     # prob[0, pred]: 第 0 个样本、预测类别上的概率值
  32.     # item(): 转成 Python 浮点数
  33.     # 结果: 0.0~1.0 之间的置信度
  34.     # ==========================================
  35.     conf = prob[0, pred].item()
复制代码
使用上面提到的钩子函数获取过程特性图,以Conv1举例:
  1. conv1_feat = features['conv1'].squeeze().cpu()
  2. plt.figure(figsize=(12, 6))
  3. for i in range(32):
  4.     plt.subplot(4, 8, i+1)
  5.     plt.imshow(conv1_feat[i], cmap='gray')
  6.     plt.axis('off')
  7. plt.suptitle("Conv1: Edge Detectors")
  8. plt.show()
复制代码
下面就是推理的中心过程图片,有4个,分别是conv1,conv2,dropout1以及全毗连层的输出,如下所示:

  • Conv1 输出
32 张特性图,有的对竖边敏感,有的对横边敏感,有的对角点敏感


  • Conv2 输出
不再是简单边沿,开始出现:圈、弧线、局部外形


  • Pooling + Dropout输出
池化在“做减法”(精简信息),Dropout 在“做干扰”(增强韧性)


  • 全毗连层
抽象成 10 个种别的概率分布,这10个概率泉源于torch.exp(output)

整个过程中,CNN 真的是在从像素 → 边沿 → 部件 → 种别
踩坑实录

❌ 真实踩坑
标题征象OpenMP 报错libiomp5md.dll辩说CUDA 报错Tensor 不能直接转 NumPy辨认禁绝字太大 / 太小 / 位置偏✅ 办理方案
1️⃣ DLL 辩说(Windows)
  1. os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
复制代码
2️⃣ GPU → CPU → NumPy
  1. tensor.cpu().detach().numpy()
复制代码
3️⃣ 图片预处置惩罚(关键点)
  1. img = Image.open(img_path).convert("L")  # 灰度
  2. img = ImageOps.invert(img)               # 黑字白底 → 白字黑底
  3. img = img.crop(bbox)                    # 去白边
  4. 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的尺寸,前向流传过程保持稳固,代码如下所示:
  1. class Net(nn.Module):
  2.     def __init__(self, num_classes=62):
  3.         super(Net, self).__init__()
  4.         self.conv1 = nn.Conv2d(1, 64, 3, 1)
  5.         self.conv2 = nn.Conv2d(64, 128, 3, 1)
  6.         self.dropout1 = nn.Dropout2d(0.25)
  7.         self.dropout2 = nn.Dropout2d(0.5)
  8.         self.fc1 = nn.Linear(128 * 12 * 12, 256)
  9.         self.fc2 = nn.Linear(256, num_classes)
  10.     def forward(self, x):
  11.         x = F.relu(self.conv1(x))
  12.         x = F.relu(self.conv2(x))
  13.         x = F.max_pool2d(x, 2)
  14.         x = self.dropout1(x)
  15.         x = torch.flatten(x, 1)
  16.         x = F.relu(self.fc1(x))
  17.         x = self.dropout2(x)
  18.         x = self.fc2(x)
  19.         return F.log_softmax(x, dim=1)
复制代码
模子练习

起首加载数据,以及输入数据的预处置惩罚界说
  1. # 模型输入数据预处理,进行了数据增强
  2. transform_train = transforms.Compose([
  3.     transforms.RandomRotation(90),  # 👈 关键!允许 ±90° 旋转
  4.     transforms.RandomAffine(
  5.         degrees=15,
  6.         translate=(0.1, 0.1),
  7.         scale=(0.8, 1.2)
  8.     ),
  9.     transforms.ToTensor(),
  10.     transforms.Normalize((0.1307,), (0.3081,))
  11. ])
  12. # 加载EMNIST数据集
  13. train_dataset = datasets.EMNIST(
  14.     './data', split='byclass', train=True, download=True, transform=transform_train
  15. )
  16. # 定义数据加载器,决定怎么给数据
  17. train_loader = DataLoader(
  18.     train_dataset,
  19.     batch_size=128,        # 从 64 → 128
  20.     shuffle=True,
  21.     num_workers=4,        # CPU 核心数
  22.     pin_memory=True,      # GPU 加速
  23.     persistent_workers=True
  24. )
复制代码
然后加载已练习好的MNIST权重,而且仅加载卷积层
  1. mnist_weights = torch.load('mnist_cnn.pt', map_location=device)
  2. conv_dict = {k: v for k, v in mnist_weights.items()
  3.              if 'conv' in k and v.shape == model.state_dict()[k].shape}
  4. model_dict = model.state_dict()
  5. model_dict.update(conv_dict)
  6. model.load_state_dict(model_dict)
复制代码
接下来就是分阶段练习的内容了,紧张分为两个阶段,第一阶段是冻结其他层,只练习分类头(全毗连层),以包管已学习的特性不会被粉碎,这是迁徙学习常用的方式。第二个阶段就是微调解个模子,对预练习模子的参数举行调解,使模子更好地顺应新的内容。
  1. def train_stage(stage, epochs, lr, train_all=False):
  2.     """
  3.     执行一个训练阶段(stage-wise training)
  4.    
  5.     参数说明:
  6.     stage       : 当前是第几个训练阶段(仅用于打印)
  7.     epochs      : 该阶段要训练的 epoch 数
  8.     lr          : 学习率
  9.     train_all   : 是否训练所有层
  10.                   False → 只训练分类头(迁移学习常用)
  11.                   True  → 微调整个模型
  12.     """
  13.     # =========================
  14.     # 1. 打印当前训练阶段信息
  15.     # =========================
  16.     print(f"\n{'='*50}")
  17.     print(f"Stage {stage}: {'All layers' if train_all else 'Only classifier'}")
  18.     print(f"{'='*50}")
  19.     # =========================
  20.     # 2. 控制哪些层参与训练
  21.     # =========================
  22.     if not train_all:
  23.         # 2.1 先冻结整个模型的所有参数
  24.         for param in model.parameters():
  25.             param.requires_grad = False
  26.         # 2.2 只解冻分类头(全连接层)
  27.         for param in model.fc1.parameters():
  28.             param.requires_grad = True
  29.         for param in model.fc2.parameters():
  30.             param.requires_grad = True
  31.         # ✅ 目的:
  32.         # 保护卷积层已经学到的通用特征(边缘、形状)
  33.         # 防止小数据集导致“灾难性遗忘”
  34.     else:
  35.         # 2.3 微调整个模型
  36.         for param in model.parameters():
  37.             param.requires_grad = True
  38.     # =========================
  39.     # 3. 优化器:只优化需要梯度的参数
  40.     # =========================
  41.     optimizer = optim.Adam(
  42.         filter(lambda p: p.requires_grad, model.parameters()),
  43.         lr=lr
  44.     )
  45.     # =========================
  46.     # 4. 学习率调度器
  47.     # =========================
  48.     # 每 3 个 epoch 将学习率乘以 0.5
  49.     scheduler = optim.lr_scheduler.StepLR(
  50.         optimizer,
  51.         step_size=3,
  52.         gamma=0.5
  53.     )
  54.     # =========================
  55.     # 5. 损失函数
  56.     # =========================
  57.     # NLLLoss 配合 LogSoftmax 使用
  58.     criterion = nn.NLLLoss()
  59.     # =========================
  60.     # 6. 开始训练循环
  61.     # =========================
  62.     for epoch in range(1, epochs + 1):
  63.         # 6.1 设置模型为训练模式
  64.         #     启用 Dropout / BatchNorm 的训练行为
  65.         model.train()
  66.         total_loss = 0
  67.         correct = 0
  68.         total = 0
  69.         # 6.2 遍历训练集
  70.         for batch_idx, (data, target) in enumerate(train_loader):
  71.             # 6.3 数据搬到设备(CPU / GPU)
  72.             data, target = data.to(device), target.to(device)
  73.             # 6.4 清空梯度(防止梯度累加)
  74.             optimizer.zero_grad()
  75.             # 6.5 前向传播
  76.             output = model(data)
  77.             # 6.6 计算损失
  78.             loss = criterion(output, target)
  79.             # 6.7 反向传播
  80.             loss.backward()
  81.             # 6.8 更新参数
  82.             optimizer.step()
  83.             # =========================
  84.             # 7. 统计指标
  85.             # =========================
  86.             total_loss += loss.item()
  87.             pred = output.argmax(dim=1)
  88.             correct += pred.eq(target).sum().item()
  89.             total += target.size(0)
  90.         # 7.1 更新学习率
  91.         scheduler.step()
  92.         # 7.2 计算准确率
  93.         acc = 100. * correct / total
  94.         # 7.3 打印当前 epoch 结果
  95.         print(f"Epoch {epoch:02d} | "
  96.               f"Loss: {total_loss/len(train_loader):.4f} | "
  97.               f"Acc: {acc:.2f}% | "
  98.               f"LR: {scheduler.get_last_lr()[0]:.6f}")
  99.               
  100. # 执行训练
  101. train_stage(stage=1, epochs=5, lr=0.001, train_all=False)   # 只训练分类器
  102. train_stage(stage=2, epochs=15, lr=0.0001, train_all=True)  # 微调整个网络
复制代码
练习好之后,可以先拿原生测试数据集(EMNIST)举行大规模测试,看看精确率怎么样,测试代码如下所示:
  1. # ========== 1. 加载你训练好的模型(和训练时结构完全一致) ==========
  2. device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
  3. model = Net(num_classes=62).to(device)
  4. checkpoint = torch.load("emnist_62class_final.pt", map_location=device)
  5. model.load_state_dict(checkpoint["model_state_dict"])
  6. model.eval()
  7. print(f"✅ 模型加载成功,训练时准确率: {checkpoint.get('accuracy', '未知')}%")
  8. # ========== 2. 加载EMNIST测试集(和训练时用完全一样的transform!) ==========
  9. test_transform = transforms.Compose([
  10.     transforms.ToTensor(),
  11.     transforms.Normalize((0.1307,), (0.3081,))
  12. ])
  13. test_dataset = datasets.EMNIST(
  14.     root="./data",
  15.     split="byclass",  # 必须和训练时一致!
  16.     train=False,
  17.     download=False,
  18.     transform=test_transform
  19. )
  20. print(f"📊 测试集总样本数: {len(test_dataset)}")
  21. print(f"📊 标签范围: {min(test_dataset.targets.numpy())} ~ {max(test_dataset.targets.numpy())}")
  22. # ========== 3. 全量预测 + 统计 ==========
  23. correct_total = 0
  24. correct_digit = 0
  25. total_digit = 0
  26. correct_upper = 0
  27. total_upper = 0
  28. correct_lower = 0
  29. total_lower = 0
  30. # 收集错误样本:key是(真实标签, 预测标签),value是(图片tensor, 出现次数)
  31. error_cases = {}
  32. with torch.no_grad():
  33.     for idx in range(len(test_dataset)):
  34.         img, true_label = test_dataset[idx]
  35.         # 增加batch维度
  36.         img_input = img.unsqueeze(0).to(device)
  37.         
  38.         # 预测
  39.         output = model(img_input)
  40.         pred_label = output.argmax(dim=1).item()
  41.         prob = torch.exp(output).max().item()
  42.         
  43.         # 统计总准确率
  44.         if pred_label == true_label:
  45.             correct_total += 1
  46.         else:
  47.             # 记录错误样本
  48.             error_key = (true_label, pred_label)
  49.             if error_key not in error_cases:
  50.                 error_cases[error_key] = []
  51.             error_cases[error_key].append((img.cpu(), prob))
  52.         
  53.         # 分大类统计
  54.         if true_label < 10:  # 数字
  55.             total_digit += 1
  56.             if pred_label == true_label:
  57.                 correct_digit += 1
  58.         elif true_label < 36:  # 大写字母
  59.             total_upper += 1
  60.             if pred_label == true_label:
  61.                 correct_upper += 1
  62.         else:  # 小写字母
  63.             total_lower += 1
  64.             if pred_label == true_label:
  65.                 correct_lower += 1
  66. # ========== 4. 输出结果 ==========
  67. total_acc = 100 * correct_total / len(test_dataset)
  68. digit_acc = 100 * correct_digit / total_digit if total_digit > 0 else 0
  69. upper_acc = 100 * correct_upper / total_upper if total_upper > 0 else 0
  70. lower_acc = 100 * correct_lower / total_lower if total_lower > 0 else 0
  71. print("\n" + "="*60)
  72. print(f"🎯 全量测试结果(共{len(test_dataset)}张图):")
  73. print(f"   总准确率: {total_acc:.2f}%")
  74. print(f"   数字(0-9)准确率: {digit_acc:.2f}% ({correct_digit}/{total_digit})")
  75. print(f"   大写字母(A-Z)准确率: {upper_acc:.2f}% ({correct_upper}/{total_upper})")
  76. print(f"   小写字母(a-z)准确率: {lower_acc:.2f}% ({correct_lower}/{total_lower})")
  77. print("="*60)
  78. # ========== 5. 可视化最常见的错误 ==========
  79. if len(error_cases) > 0:
  80.     print("\n🔴 最常见的5种错误:")
  81.     # 按错误次数排序
  82.     sorted_errors = sorted(error_cases.items(), key=lambda x: len(x[1]), reverse=True)[:5]
  83.    
  84.     for (true_lbl, pred_lbl), cases in sorted_errors:
  85.         # 字符映射
  86.         def label_to_char(lbl):
  87.             if lbl < 10: return str(lbl)
  88.             elif lbl < 36: return chr(ord('A') + lbl -10)
  89.             else: return chr(ord('a') + lbl -36)
  90.         
  91.         true_char = label_to_char(true_lbl)
  92.         pred_char = label_to_char(pred_lbl)
  93.         count = len(cases)
  94.         print(f"   {true_char}(标签{true_lbl}) → {pred_char}(标签{pred_lbl}): {count}次")
  95.         
  96.         # 可视化前3个错误样本
  97.         plt.figure(figsize=(10, 3))
  98.         for i in range(min(3, len(cases))):
  99.             img_tensor, prob = cases[i]
  100.             plt.subplot(1, 3, i+1)
  101.             plt.imshow(img_tensor.squeeze(), cmap="gray")
  102.             plt.title(f"真:{true_char} 预:{pred_char}\n置信度:{prob:.3f}")
  103.             plt.axis("off")
  104.         plt.suptitle(f"错误类型: {true_char}→{pred_char}(共{count}次)")
  105.         plt.show()
  106. else:
  107.     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 中数字样本多,而且相似字符会相互干扰
  • 迁徙学习上限:由于是从 MNIST(10 类)​ 迁徙到 EMNIST(62 类),以是预练习模子的卷积层是为“数字边沿”操持的,不是为“字母曲线”
但可以看到此中对数字的辨认最准,小写字母不太好分辨
模子测试

碰到一个最潜伏的坑 —— 图片方向。我写了一个大写 A,模子说是 F / t / r,直到我把图片旋转 90°,它才认出来。排查出缘故原由如下:

  • EMNIST 来自 NIST 扫描表单
  • 原始数据方向并不同一
  • 我“正着写”,模子“横着看”
  • 范例的练习-测试分布差异等标题
测试模子的过程同上,也是图片预处置惩罚和模子输入预处置惩罚,直接看结果吧
先鼠标画一张“A"图片作为模子输入的图片,做了90°旋转

下面是模子的TOP-5猜测结果:

尚有62个分类的热力图:

然后再测试数字“3”,也做了旋转处置惩罚,向右旋转90°,结果如下所示:

末了测试小写字母“j”,也是调解图片,旋转、平移后,结果如下所示:

团体来说,迁徙学习练习出来的模子有点差能人意,实际使用中,我还得去共同模子的束缚与参数,否则模子结果很不理想,有点本末倒置了,应该就是练习-测试分布差异等导致的标题吧?
我多次调解练习过程,发现针对EMNIST测试集的精确率都在合格范围内,但是实际使用我的鼠标写的字符图片就须要不绝调解才有大概辨认精确,有的图片我怎么调解都辨认不对,就很心累。
总结

回首整个学习调试历程,我履历了三个阶段的跃迁:

  • 调包阶段:知道怎么跑,不知道为什么。
  • 剖解阶段:想知道每一层在干什么,于是学会了可视化和原理。
  • 调试阶段:不满意于现有模子,开始实行微调模子,以满意个性化需求。
如果你也刚开始学,不要急着跑更复杂的模子(ResNet、Transformer)。先把 MNIST 这个“麻雀”剖解明白了. ​当你能对着一张 28×28 的图,说出它颠末每一层后尺寸怎么变、为什么变的时间,就算入门了。
而从 MNIST 迁徙到 EMNIST 的这一步,过程中碰到的标题和寻衅是很伤人头脑的,你会发现,一个模子只实用于它学习过的内容,对于额外的内容它也是难以把握。练习的过程是让模子把握新知识的过程,但是新知识怎样融合进已有的内容就很值得思索了。
末了,如果想要代码的话,可以接洽我,我会给我实行过程的jupyter文档。如果文章有什么标题,大概有什么指教都可以接洽我。
微信公众号:软趴趴的工程师(一个乐于助人的工程师)


免责声明:如果侵犯了您的权益,请联系站长及时删除侵权内容,谢谢合作!qidao123.com:ToB企服之家,中国第一个企服评测及软件市场,开放入驻,技术点评得现金.

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表