模型训练入门教程:从MNIST手写数字迁移到EMNIST手写字母的识别之路

很多人入门深度学习,第一步就是复制 GitHub 上的 MNIST 代码,跑通,看到 99% 的准确率,然后就以为自己会 CNN 了。

我也一样。直到我开始问自己一些"较真"的问题,才发现:代码能跑 ≠ 我真的懂

这篇教程,记录我从调包、解剖、设计,再到踩完迁移学习坑的全过程。没有玄学,只有一步一步的脚踏实地。


本文首先从MNIST的训练过程开始讲起,会详细讲解过程和步骤,会有图例和代码,以及一些踩到的坑等注意事项。下面先介绍一些前置要点:

前言

深度学习框架:PyTorch

PyTorch是一个开源的深度学习框架,由 Facebook 的人工智能研究团队开发,广泛应用于计算机视觉(CV)、自然语言处理(NLP)等领域。它以灵活性和易用性著称,特别适合研究人员和开发者进行快速原型设计和实验。

💡PyTorch 的模型训练通常包括以下步骤:

  1. 数据加载 :使用 DatasetDataLoader 加载和预处理数据。

  2. 模型定义 :通过继承 torch.nn.Module 构建神经网络。

  3. 损失函数与优化器:选择合适的损失函数(如交叉熵)和优化器(如 SGD 或 Adam)。

  4. 训练与验证:通过循环迭代训练模型,并使用验证集评估性能。

  5. 保存与加载模型 :使用 torch.savetorch.load 保存和恢复模型。

深度学习模型:CNN

CNN(卷积神经网络),适合用在图像识别和分类中,通过一系列的卷积层池化层全连接层来处理数据

在卷积层中,CNN使用一组可学习的滤波器(卷积核)来扫描输入 的图像或信号。每个滤波器都能够检测输入中的特定特征,如边缘或颜色斑点。通过这种方式,CNN能够捕捉到图像中的局部特征,并保持这些特征的空间关系。

池化层(也称为下采样层)则用于降低 特征的空间尺寸,从而减少参数数量和计算复杂度,同时使特征检测更加鲁棒。全连接层则将学习到的高级特征用于分类或其他任务。

💡CNN的结构和工作原理

一个典型的CNN包含以下几个主要部分:

  • 输入层:接收原始数据,如图像的像素值。

  • 卷积层:使用多个卷积核提取输入的特征。

  • 激活函数:如ReLU,用于引入非线性,使网络能够学习更复杂的特征。

  • 池化层:降低特征的空间维度,减少计算量。

  • 全连接层:将学习到的特征映射到最终的输出,如分类标签。

  • 输出层:输出网络的最终结果,如分类的概率分布。

CNN通过这些层的堆叠,能够从简单到复杂逐渐提取图像的特征。在训练过程中,CNN通过反向传播算法 调整卷积核中的权重,以最小化预测结果和真实标签之间的差异。

训练MNIST

❓什么是MNIST

全称:(Modified National Institute of Standards and Technology),是机器学习和深度学习领域最经典的入门数据集,被称为深度学习的"Hello World" 。它包含手写数字(0-9) 的灰度图像,广泛用于图像分类算法的训练与测试。

模型定义

首先定义模型的结构,这个结构在训练和推理过程中都是要保持一致的,其中包括模型组件的定义和模型流程定义,代码如下所示:

python 复制代码
class Net(nn.Module):
    def __init__(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, dim=1)
        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):像素 → 边缘 → 部件 → 抽象特征 → 概率

❓ 我当时真实的困惑

  1. 为什么卷积核数量是 3264?不能是 30吗?
  2. 9216这个数字是怎么来的?
  3. 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 × 28
Conv1 3×3 卷积,无 padding 32 × 26 × 26
MaxPool 2×2 池化 32 × 13 × 13
Conv2 3×3 卷积 64 × 11 × 11
MaxPool 2×2 池化 64 × 5 × 5

⚠️ 注意:不同实现可能略有差异,但常见版本最后会再接一个卷积或 padding,使最终特征图为 64 × 12 × 12

那么最终,9216 的来源就是

复制代码
64 个通道
× 12 × 12 每个通道的特征图
= 9216

模型训练

整体来说,训练过程就是把一个 batch 的数据送进模型 → 算损失 → 反向传播 → 更新参数 → 记录日志,这是深度学习里"学习"发生的唯一地方

📌 PyTorch 的数据是"流式"的,不是一次性全塞进去

python 复制代码
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(
                f'Epoch {epoch} '
                f'[{batch_idx * len(data)}/{len(train_loader.dataset)} '
                f'({100. * batch_idx / len(train_loader):.0f}%)]\t'
                f'Loss: {loss.item():.6f}'
            )

函数参数表含义如下所示:

参数 是什么
model 你的 CNN 网络
device CPU / GPU
train_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()清空梯度,来给新的学习腾地方

而模型在这里的作用就是做前向传播,就是前面所将的流程:

text 复制代码
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属性里,核心是把"总错误"按责任比例分摊回去,对比正向传播,过程如下:

text 复制代码
Loss
 ↓
∂Loss/∂FC
 ↓
∂FC/∂Pool
 ↓
∂Pool/∂Conv2
 ↓
∂Conv2/∂Conv1
 ↓
∂Conv1/∂W

🔑有反向传播就可以自动学习,而不用手动调参

梯度告诉了方向,然后是优化器(Optimizer Step)optimizer.step()负责迈步子,读取.grad,根据优化规则更新参数,其中Adam 做的是:\(\theta=\theta-η⋅ \frac{m}{\sqrt{ v }+ϵ}\),这是"真正动手改模型"的地方

大致的训练流程就是这样,那么现在开始实际的训练吧。

python 复制代码
def main():
    # ==========================================
    # 1. 设备选择与配置
    # ==========================================
    # 自动检测是否有可用的 GPU(CUDA)
    # 如果有则用 GPU 加速训练,否则回退到 CPU
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # ==========================================
    # 2. 模型与优化器初始化
    # ==========================================
    # 实例化我们定义的 CNN 模型
    # .to(device) 将模型的所有参数和缓冲区移动到指定设备(GPU/CPU)
    model = Net().to(device)
    
    # 使用 Adam 优化器
    # model.parameters() 告诉优化器:需要更新哪些参数
    # lr=0.001 是学习率,控制每次参数更新的步长
    optimizer = optim.Adam(model.parameters(), lr=0.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',          # 数据存储路径
        train=True,        # 使用训练集(而非测试集)
        download=True,     # 如果数据不存在,自动从网上下载
        transform=transform  # 应用上面定义的数据预处理
    )
    
    # DataLoader 负责批量加载数据,并提供打乱、并行读取等功能
    train_loader = DataLoader(
        dataset,           # 要加载的数据集
        batch_size=64,     # 每个批次包含 64 个样本
        shuffle=True       # 每个 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, label="Training Loss")
    
    # 设置坐标轴标签
    plt.xlabel("Batch")      # X轴:批次索引
    plt.ylabel("Loss")       # Y轴:损失值
    
    # 设置图表标题
    plt.title("MNIST CNN Training Loss")
    
    # 显示网格线,便于观察数值
    plt.grid(True)
    
    # 显示图例(对应 label="Training Loss")
    plt.legend()
    
    # 自动调整子图参数,使之填充整个画布
    plt.tight_layout()
    
    # 显示图形窗口
    plt.show()

经过训练循环过程后,模型的犯错程度在不断的下降,如下图所示:

到这里,基本的训练过程就讲差不多了,那么一个epoch 里到底发生了什么?

text 复制代码
Epoch 1
 ├─ Batch 1: 猜 → 算错 → 改参数
 ├─ Batch 2: 猜 → 算错 → 改参数
 ├─ ...
 └─ Batch N: 猜 → 算错 → 改参数

Epoch 2
 ├─ 猜得更准一点
 ├─ Loss 更小
 └─ ...

Epoch 10
 └─ 基本稳定

💡训练函数的本质,就是用损失函数告诉模型"你错在哪,再用反向传播告诉它"该怎么改"

模型测试

我使用了Forward Hook(前向钩子)方法,来达到可视化推理的中间过程,如下所示:

python 复制代码
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(),同时需要注意模型定义要和训练时保持一致

python 复制代码
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_location=device))
model.eval()

模型接受的输入需要先预处理一下,因为MNIST的图片有一定规范,比如尺寸、颜色等

python 复制代码
# ============ 图片预处理 ===============
img_path = "my_digit.png"
img = Image.open(img_path).convert("L")

# ✅ 白底黑字 → 黑底白字(MNIST 风格)
img = ImageOps.invert(img)

# resize 到 28×28
img = 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)

最后就是模型推理代码了

python 复制代码
# ==========================================
# 推理模式(禁用梯度计算)
# 作用:节省显存、加快计算、关闭 Dropout
# ==========================================
with torch.no_grad():
    
    # ==========================================
    # 前向传播:让模型看这张图片
    # output 形状: (1, 10)
    # 注意:这是 log_softmax 输出(对数概率)
    # ==========================================
    output = model(input_tensor)
    
    # ==========================================
    # 将对数概率转换回普通概率
    # 公式: prob = exp(log_prob)
    # 作用:让人类能直观理解(0~1 之间)
    # ==========================================
    prob = torch.exp(output)
    
    # ==========================================
    # 找出概率最大的类别(预测结果)
    # argmax(dim=1): 在类别维度上找最大值索引
    # item(): 把 Tensor 转成 Python 整数
    # 结果: 0~9 的数字
    # ==========================================
    pred = prob.argmax(dim=1).item()
    
    # ==========================================
    # 取出该类别的预测置信度
    # prob[0, pred]: 第 0 个样本、预测类别上的概率值
    # item(): 转成 Python 浮点数
    # 结果: 0.0~1.0 之间的置信度
    # ==========================================
    conf = prob[0, pred].item()

使用上面提到的钩子函数获取过程特征图,以Conv1举例:

python 复制代码
conv1_feat = features['conv1'].squeeze().cpu()

plt.figure(figsize=(12, 6))
for i in range(32):
    plt.subplot(4, 8, i+1)
    plt.imshow(conv1_feat[i], cmap='gray')
    plt.axis('off')
plt.suptitle("Conv1: Edge Detectors")
plt.show()

下面就是推理的中间过程图片,有4个,分别是conv1,conv2,dropout1以及全连接层的输出,如下所示:

  1. Conv1 输出

32 张特征图,有的对竖边敏感,有的对横边敏感,有的对角点敏感

  1. Conv2 输出

不再是简单边缘,开始出现:圈、弧线、局部形状

  1. Pooling + Dropout输出

池化在"做减法"(精简信息),Dropout 在"做干扰"(增强韧性)

  1. 全连接层

抽象成 10 个类别的概率分布,这10个概率来源于torch.exp(output)

整个过程中,CNN 真的是在从像素 → 边缘 → 部件 → 类别

踩坑实录

❌ 真实踩坑

问题 现象
OpenMP 报错 libiomp5md.dll冲突
CUDA 报错 Tensor 不能直接转 NumPy
识别不准 字太大 / 太小 / 位置偏

✅ 解决方案

1️⃣ DLL 冲突(Windows)

复制代码
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"

2️⃣ GPU → CPU → NumPy

复制代码
tensor.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的尺寸,前向传播过程保持不变,代码如下所示:

python 复制代码
class Net(nn.Module):
    def __init__(self, num_classes=62):
        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, dim=1)

模型训练

首先加载数据,以及输入数据的预处理定义

python 复制代码
# 模型输入数据预处理,进行了数据增强
transform_train = transforms.Compose([
    transforms.RandomRotation(90),  # 👈 关键!允许 ±90° 旋转
    transforms.RandomAffine(
        degrees=15,
        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', train=True, download=True, transform=transform_train
)

# 定义数据加载器,决定怎么给数据
train_loader = DataLoader(
    train_dataset,
    batch_size=128,        # 从 64 → 128
    shuffle=True,
    num_workers=4,        # CPU 核心数
    pin_memory=True,      # GPU 加速
    persistent_workers=True
)

然后加载已训练好的MNIST权重,并且仅加载卷积层

python 复制代码
mnist_weights = torch.load('mnist_cnn.pt', map_location=device)
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)

接下来就是分阶段训练的内容了,主要分为两个阶段,第一阶段是冻结其他层,只训练分类头(全连接层),以保证已学习的特征不会被破坏,这是迁移学习常用的方式。第二个阶段就是微调整个模型,对预训练模型的参数进行调整,使模型更好地适应新的内容。

python 复制代码
def train_stage(stage, epochs, lr, train_all=False):
    """
    执行一个训练阶段(stage-wise training)
    
    参数说明:
    stage       : 当前是第几个训练阶段(仅用于打印)
    epochs      : 该阶段要训练的 epoch 数
    lr          : 学习率
    train_all   : 是否训练所有层
                  False → 只训练分类头(迁移学习常用)
                  True  → 微调整个模型
    """

    # =========================
    # 1. 打印当前训练阶段信息
    # =========================
    print(f"\n{'='*50}")
    print(f"Stage {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()),
        lr=lr
    )

    # =========================
    # 4. 学习率调度器
    # =========================
    # 每 3 个 epoch 将学习率乘以 0.5
    scheduler = optim.lr_scheduler.StepLR(
        optimizer,
        step_size=3,
        gamma=0.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(dim=1)
            correct += pred.eq(target).sum().item()
            total += target.size(0)

        # 7.1 更新学习率
        scheduler.step()

        # 7.2 计算准确率
        acc = 100. * correct / total

        # 7.3 打印当前 epoch 结果
        print(f"Epoch {epoch:02d} | "
              f"Loss: {total_loss/len(train_loader):.4f} | "
              f"Acc: {acc:.2f}% | "
              f"LR: {scheduler.get_last_lr()[0]:.6f}")
              
# 执行训练
train_stage(stage=1, epochs=5, lr=0.001, train_all=False)   # 只训练分类器
train_stage(stage=2, epochs=15, lr=0.0001, train_all=True)  # 微调整个网络

训练好之后,可以先拿原生测试数据集(EMNIST)进行大规模测试,看看准确率怎么样,测试代码如下所示:

python 复制代码
# ========== 1. 加载你训练好的模型(和训练时结构完全一致) ==========
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = Net(num_classes=62).to(device)
checkpoint = torch.load("emnist_62class_final.pt", map_location=device)
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",  # 必须和训练时一致!
    train=False,
    download=False,
    transform=test_transform
)

print(f"📊 测试集总样本数: {len(test_dataset)}")
print(f"📊 标签范围: {min(test_dataset.targets.numpy())} ~ {max(test_dataset.targets.numpy())}")

# ========== 3. 全量预测 + 统计 ==========
correct_total = 0
correct_digit = 0
total_digit = 0
correct_upper = 0
total_upper = 0
correct_lower = 0
total_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(dim=1).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 0
upper_acc = 100 * correct_upper / total_upper if total_upper > 0 else 0
lower_acc = 100 * correct_lower / total_lower if total_lower > 0 else 0

print("\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(), key=lambda x: len(x[1]), reverse=True)[: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, i+1)
            plt.imshow(img_tensor.squeeze(), cmap="gray")
            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 中数字样本多,并且相似字符会互相干扰
  • 迁移学习上限:因为是从 MNIST(10 类) 迁移到 EMNIST(62 类),所以预训练模型的卷积层是为"数字边缘"设计的,不是为"字母曲线"

但可以看到其中对数字的识别最准,小写字母不太好分辨

模型测试

遇到一个最隐蔽的坑 ------ 图片方向。我写了一个大写 A ,模型说是 F / t / r ,直到我把图片旋转 90°,它才认出来。排查出原因如下:

  • EMNIST 来自 NIST 扫描表单
  • 原始数据方向并不统一
  • 我"正着写",模型"横着看"
  • 典型的训练-测试分布不一致问题

测试模型的过程同上,也是图片预处理和模型输入预处理,直接看结果吧

先鼠标画一张"A"图片作为模型输入的图片,做了90°旋转

下面是模型的TOP-5预测结果:

还有62个分类的热力图:

然后再测试数字"3",也做了旋转处理,向右旋转90°,结果如下所示:

最后测试小写字母"j",也是调整图片,旋转、平移后,结果如下所示:

整体来说,迁移学习训练出来的模型有点差强人意,实际使用中,我还得去配合模型的约束与参数,不然模型效果很不理想,有点本末倒置了,应该就是训练-测试分布不一致导致的问题吧?

我多次调整训练过程,发现针对EMNIST测试集的准确率都在及格范围内,但是实际使用我的鼠标写的字符图片就需要不断调整才有可能识别正确,有的图片我怎么调整都识别不对,就很心累。

总结

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

  1. 调包阶段:知道怎么跑,不知道为什么。
  2. 解剖阶段:想知道每一层在干什么,于是学会了可视化和原理。
  3. 调试阶段:不满足于现有模型,开始尝试微调模型,以满足个性化需求。

如果你也刚开始学,不要急着跑更复杂的模型(ResNet、Transformer)。先把 MNIST 这个"麻雀"解剖明白了. ​当你能对着一张 28×28 的图,说出它经过每一层后尺寸怎么变、为什么变的时候,就算入门了。

而从 MNIST 迁移到 EMNIST 的这一步,过程中遇到的问题和挑战是很伤人脑筋的,你会发现,一个模型只适用于它学习过的内容,对于额外的内容它也是难以把握。训练的过程是让模型掌握新知识的过程,但是新知识如何融合进已有的内容就很值得思考了。

最后,如果想要代码的话,可以联系我,我会给我实验过程的jupyter文档。如果文章有什么问题,或者有什么指教都可以联系我。

微信公众号:软趴趴的工程师(一个乐于助人的工程师)