ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CNN+PyTorch+MNIST实战:从零构建手写数字识别模型

CNN+PyTorch+MNIST实战:从零构建手写数字识别模型 如果你刚开始学深度学习那 CNN PyTorch MNIST 这套组合基本是绕不开的第一课。MNIST 就是深度学习界的“Hello World”用卷积神经网络在 PyTorch 里训练一个手写数字识别模型几乎每个做 AI 的从业者都亲手跑过一遍。这篇文章不是我拿现成 Demo 糊弄你而是我自己踩过无数坑之后的完整复盘从“为什么图像要用 CNN 而不能用普通神经网络”这类基础问题到环境安装、数据下载 404、模型定义、训练调参、特征图可视化一条线全串起来。我会把代码全部贴出来并且逐段讲清楚“这一行到底在干嘛、为什么这么写”。这篇文章适合三种人看第一次接触 CNN 的初学者已经会调库但不太清楚内部原理的“调包侠”以及想复现一个干净、易读、能直接跑通的 MNIST 实战项目的同学。1. 内容整体设计与思路拆解1.1 为什么图像要用 CNN 而不是普通全连接网络这个问题几乎每个人初学的时候都会问也是面试高频题。要理解这件事你得先回到数据本身。一张 28x28 的灰度 MNIST 图片本质上是一个 28x28 的矩阵展开以后是 784 个像素值。如果我们用普通的前馈神经网络来处理第一件事就是把 784 个像素“拍扁”成一维向量输入。问题是图像的空间信息在这个“拍扁”的过程中被破坏了。手写数字“2”和“3”的区别很大程度体现在笔画的局部形状、边缘方向、相对位置上。普通神经网络默认每个输入特征之间是独立的它很难学到“这 3 个像素组合起来是一条竖线”这种局部模式因为它没有“相邻像素”的概念。更致命的是参数爆炸。假设输入是一张 128x128 的彩色图展平后就是 128x128x3 49152 维你哪怕设置第一个隐藏层只有 1024 个神经元这一层的参数就超过 5000 万。在 MNIST 这种小图上还好一旦到了真实图像任务全连接网络在计算上基本是不可行的。CNN 的做法完全不同。它有两个核心机制局部连接和权值共享。卷积核每次只看图片的一个小区域比如 3x3这就叫局部感受野这个 3x3 的卷积核滑过整张图片时参数是同一份这就叫权值共享。用大白话说全连接网络像派一群人去检查整张照片的每个像素而 CNN 像分工协作——有人专门看边缘有人专门看纹理最后把结果汇总。这个思路非常贴近人类视觉皮层的处理方式。1.2 卷积层、池化层、全连接层三层结构各干什么经典的 CNN 分类网络通常由三类层组成卷积层负责提取特征池化层负责降维全连接层负责分类决策。卷积层是怎么工作的你可以把卷积核想象成一个小手电筒在图片上从左到右、从上到下地扫过。每次照到 3x3 的区域就把这个区域里的像素值和卷积核的权重做加权求和得到一个输出值。一个卷积核只能提取一种模式比如“竖直边缘”或者“左上角到右下角的斜线”。所以我们通常会放很多个卷积核——在下面的代码里第一层放了 32 个第二层放了 64 个让网络自己从数据里学出这些模式。池化层最常用的是最大池化。它的操作很简单把 2x2 的区域中最大的那个值取出来图片尺寸直接减半。为什么要这么做一方面降低计算量另一方面也是在告诉你“这个局部区域里最明显的特征保留下来就够了具体位置稍微平移一点没关系。”这就给模型带来了平移不变性。最后是分类层。卷积和池化把原始图片逐步变换成一组更抽象的特征图但最终我们要输出 0-9 这 10 个数字各自的概率。全连接层把特征图展平成一个长向量再通过几层线性变换映射到 10 个输出上。在 PyTorch 里这一步就是 Flatten 加 Linear。这张表格是我自己整理过很多次的最直观流程输入一张 28x28 的灰度图后每一层的形状变化层级操作输出形状输入原始图片(1, 28, 28)卷积层1Conv2d(1, 32, kernel3, padding1) ReLU(32, 28, 28)池化层1MaxPool2d(2)(32, 14, 14)卷积层2Conv2d(32, 64, kernel3, padding1) ReLU(64, 14, 14)池化层2MaxPool2d(2)(64, 7, 7)展平Flatten(3136)全连接1Linear(3136, 128) ReLU(128)输出层Linear(128, 10)(10)2. 动手之前环境搭建与 MNIST 数据的避坑记录2.1 版本选择CPU 够不够用GPU 怎么选MNIST 是灰度小图单张只有 28x28CPU 训练一个上面这种规模的 CNN10 个 epoch 大约也就十分钟级别。如果你只是学习先装 CPU 版本完全没问题跑通了再考虑 GPU 也不迟。安装 PyTorch 我用得最顺的是在 Anaconda 里建一个独立环境避免污染基础的 Python。假设你电脑没有 NVIDIA 显卡或者暂时不想折腾 CUDA直接执行conda create -n pytorch python3.10 -y conda activate pytorch pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu如果你确定要用 GPU先去官网的 Get Started 页面选好系统、包管理方式、CUDA 版本然后把生成的命令复制执行。我见过太多人卡在“安装完以后导入 torch 报错”这一步绝大多数情况是 CUDA 版本和显卡驱动不匹配。一个很实用的判断方法在命令行输入nvidia-smi看右上角支持的 CUDA 版本然后安装那个版本号以下的 PyTorch 都没问题不需要非得装最新。2.2 Windows 上的经典报错WinError 1114 怎么解决如果你在 Windows 上安装 PyTorch 后第一次import torch就报OSError: [WinError 1114] 动态链接库(DLL)初始化例程失败别慌这是个非常经典的坑。这个报错指向的是torch\lib\c10.dll加载失败几乎和你的代码无关。我实际排查过几次最终确认主要诱因有两类一类是系统缺少 Microsoft Visual C Redistributable 运行库去微软官网下载最新版安装就能解决另一类是 Python 版本和 PyTorch 版本兼容性问题比如 Python 3.11 配某些早期 PyTorch 版本就会出问题。解决办法是换到 Python 3.10或者把 PyTorch 升级到新版。还有一个细节特别容易忽略如果你用的是 Anaconda 环境装完包以后要确认当前环境真的激活了否则pip install可能装到了 base 环境代码里 import 的却是 pytorch 环境下的包版本对不上也会报莫名其妙的错。2.3 torchvision 下载 MNIST 404 的终极解决方案这个坑几乎每个用 torchvision 的人都会遇到。你用torchvision.datasets.MNIST(... , downloadTrue)时控制台突然报 404 错误或者 gibberish/not a gzipped file 之类的异常。原因是 torchvision 默认下载地址指向了 MNIST 原始官网但这个站点的资源经常迁移或者不稳定旧版本 torchvision 的 URL 还停留在老地址自然就 404 了。我在项目里最稳妥的做法是手动下载数据文件。去 MNIST 官网找到那四个 gz 压缩包直接下载后放到项目目录下的data/MNIST/raw/文件夹里。注意文件名要保持原样train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz。只要这四个文件在位datasets.MNIST加载数据时就不会再去下载你也可以把download参数设为True也不会反复触发网络请求。这个方法比改代码去换镜像源要可靠得多尤其适合网络环境不太稳定的场景。3. 核心代码逐段解析用 PyTorch 从零写一个 CNN 训练 MNIST3.1 项目文件怎么组织结构清晰比什么都重要写深度学习代码最忌讳把所有内容堆在一个文件里。训练逻辑、模型定义、数据加载混在一起前期跑通可能觉得很爽但你想改个网络结构、换个数据集的时候改起来非常痛苦。我习惯的项目结构很简单mnist_cnn/ ├── data/ # MNIST 数据存放目录 ├── model.py # 模型定义 ├── train.py # 训练和测试主脚本 └── mnist_cnn.pth # 训练完成后保存的权重文件这个划分足够精简而且以后扩展到其他图像分类任务只需要改model.py和数据加载部分。下面我把两个文件的代码都贴出来然后拆开讲。3.2 模型定义这一层为什么要这么写model.py的完整内容如下import torch.nn as nn class CNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x))第一层Conv2d(1, 32, kernel_size3, padding1)的 1 表示输入通道数——MNIST 是灰度图所以是 1如果是彩色图这里就要改成 3。32 是输出通道数也就是说我们用 32 个不同的卷积核去提取特征。padding1的妙处在于3x3 卷积核扫过图像后输出尺寸和输入保持一致这样特征图的高宽不会因为卷积而缩小对浅层网络来说能保留更多边界信息。两个卷积池化块之后特征图从 28x28 变成 14x14再变成 7x7。64 * 7 * 7是展平后的向量长度这张图我在前面表格里推过一遍所以你可以对照着看代码。最后一个Linear(128, 10)输出的是 10 个类别的 logits。特别说明一下为什么最后没有加 Softmax 层因为 PyTorch 的CrossEntropyLoss已经内部包含了 Softmax 计算你直接拿模型输出的原始 logits 算损失就行加了反而容易出精度问题。3.3 训练主脚本数据加载、训练循环、评估一气呵成train.py的代码看起来多一点但每一块都是必需的import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms from model import CNN device torch.device(cuda if torch.cuda.is_available() else cpu) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_data datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_data datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_data, batch_size64, shuffleTrue, num_workers0) test_loader DataLoader(test_data, batch_size256, shuffleFalse, num_workers0) model CNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) def evaluate(): model.eval() total, correct 0, 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) total labels.size(0) correct (preds labels).sum().item() return correct / total if __name__ __main__: epochs 10 for epoch in range(1, epochs 1): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) avg_loss running_loss / len(train_data) acc evaluate() print(fEpoch {epoch:02d} | Loss {avg_loss:.4f} | Test Acc {acc:.4f}) torch.save(model.state_dict(), mnist_cnn.pth)数据预处理部分有两个重点。ToTensor()会把 PIL 图片转换成张量并且把像素值从 0-255 缩放到 0-1 区间。Normalize((0.1307,), (0.3081,))用的是 MNIST 官方统计出来的均值和标准差它把数据从 0-1 区间转成均值为 0、标准差为 1 的分布这样梯度传播更稳定模型收敛也更快。很多初学者会在这里偷懒不写 Normalize我请你千万不要省这个细节真的会影响最终效果。训练循环里的model.train()和evaluate里的model.eval()也要特别留意。Dropout 层在训练和预测时行为不同train()模式下会随机丢弃一部分神经元eval()模式下则全部保留。所以评估准确率之前必须切换到 eval 模式否则结果会不稳定。3.4 训练一个模型实际看到的结果我在 CPU 环境下跑这个代码大概每个 epoch 需要 1-2 分钟10 个 epoch 总共十几分钟。打印出来的曲线大致是这个趋势Epoch 01 | Loss 0.3208 | Test Acc 0.9287 Epoch 02 | Loss 0.1452 | Test Acc 0.9664 Epoch 03 | Loss 0.1023 | Test Acc 0.9751 Epoch 04 | Loss 0.0774 | Test Acc 0.9807 Epoch 05 | Loss 0.0622 | Test Acc 0.9833 Epoch 06 | Loss 0.0516 | Test Acc 0.9851 ... Epoch 10 | Loss 0.0278 | Test Acc 0.9918第一个 epoch 就能到 92% 左右后面稳步爬升到 99% 以上这个结果在 MNIST 上是非常正常的。如果你跑出来的准确率明显偏低大概率不是模型问题而是数据预处理或者训练参数出了问题。4. 训练参数的选择与实验观察调参不是玄学4.1 batch_size、学习率、epochs 到底该怎么选我给的这套参数并不是随便拍的每个都有它的道理。batch_size64是在稳定性和训练速度之间比较好的折中点。batch 太小梯度震荡厉害batch 太大一个 epoch 的更新次数少收敛变慢而且对内存显存要求高。MNIST 训练集有 60000 张图片64 张一个 batch一个 epoch 就是 938 个 batch这个更新频率对 Adam 优化器来说非常舒服。学习率我选了1e-3这是 Adam 优化的默认值也是经过大量任务验证的“黄金起步点”。如果你发现训练一开始损失就剧烈震荡不下降多半是学习率太大如果损失下降得像蜗牛一样慢可以往3e-4或5e-4调。我之前试过用1e-2去训结果损失曲线像个电锯来回横跳最后收敛到 90% 都费劲。epochs10对 MNIST 够用了。这个数据集的识别难度本身不大10 个 epoch 足够让模型在测试集上到 99%。继续多训几个 epoch 不是不行但收益很小还可能开始过拟合。4.2 怎么判断模型有没有过拟合只盯着测试集准确率看是不够的你应该同时观察训练集上的损失和准确率。这里有一个我常用的经验判据如果训练集准确率已经冲到了 99.5%而测试集准确率只有 97%两者差距越拉越大那就说明模型开始“背题”了——它记住了训练集里特有的噪声模式而不是真正的泛化规律。想更早发现过拟合可以在代码里把训练集切一部分出来当验证集比如用random_split把 60000 张训练图分成 54000 张训练、6000 张验证每个 epoch 结束都在验证集上测一次。一旦发现验证集准确率连续几个 epoch 不涨、训练集准确率还在往上走就可以停了。这也解释了我模型里为什么加Dropout(0.5)——它就是专门用来缓解过拟合的训练时随机砍掉一半全连接层的神经元让网络不能过度依赖某几个节点。4.3 用小实验理解数据量和模型能力的关系学到这里我可以给你一个更有感觉的实验思路把训练数据从 60000 张减少到 1000 张其他参数全不动再跑一遍训练你猜测试准确率会掉到多少我自己试过大概只能到 85% 左右。这说明数据量是模型表现的天花板无论你把网络调得多花哨喂进去的信息不够模型就学不出完整的规律。反过来我们把模型缩小——比如把两个卷积层的通道数从 32/64 改成 16/32——准确率可能只掉零点几个百分点但训练速度能快小一倍。这就是模型容量和数据规模的权衡。初学者很容易产生“网络越大越深越好”的错觉但实际上能解决当前问题的模型才是好模型这个实验越早做越能帮你建立正确的感觉。5. 常见问题与排查技巧实录5.1 数据下载、加载与 Windows 环境相关报错报错/现象可能原因解决办法torchvision 下载 MNIST 时 404旧版 torchvision 下载地址失效手动下载四个压缩包放到data/MNIST/raw/或升级 torchvisionOSError: [WinError 1114]c10.dll 加载失败缺少 VC 运行库或 Python/PyTorch 版本冲突安装 Visual C Redistributable换 Python 3.10升级 PyTorchWindows 下num_workers设大后程序卡死或报错Windows 的 DataLoader 多进程兼容性问题把num_workers设为 0下载很慢半天不动默认源访问不稳定手动下载到本地再加载避免反复请求batch 太大致使内存/显存溢出单个 batch 的中间变量占用过高尝试减小 batch_size 到 32 或 165.2 训练效果不对时的排查顺序如果你的模型准确率不正常别急着加 Dropout、改网络结构先按这个顺序排查第一步打印一两张训练图片确认图像没有变形、标签没有错位第二步尝试用小批量数据比如 100 张图跑 5 个 epoch看训练损失能不能降下去第三步检查数据预处理的 Normalize 是否生效均值和标准差写没写反第四步确认模型最后没有多余加 Softmax避免和CrossEntropyLoss冲突。我见过不止一个人折腾了半天发现问题是数据加载时transform忘加ToTensor()图像以 PIL 格式直接进了网络。你可以打印一个 batch 的images.shape和dtype来快速验证正常情况应该是torch.FloatTensor的形状(64, 1, 28, 28)。这比盯着代码看半天有效得多。5.3 进阶技巧把卷积层“打开”看看模型学到了什么训练完成后你可以把第一层卷积的输出可视化一下这一步对建立直觉非常有帮助。我在模型进入 eval 模式后取一张测试图片把它喂进model.features[0]然后画出卷积结果的灰度图import matplotlib.pyplot as plt model.eval() with torch.no_grad(): sample, _ test_data[0] sample sample.unsqueeze(0).to(device) feat model.features[0](sample).squeeze(0).cpu() fig, axes plt.subplots(1, 5, figsize(12, 3)) for i in range(5): axes[i].imshow(feat[i], cmapgray) axes[i].axis(off) plt.show()你会看到前几个卷积核的输出很像边缘检测器——有些把数字的竖直轮廓提了出来有些关注横向笔画有些对斜线敏感。这就是 CNN 提取特征的直观证据也是全连接网络很难给出的解释。第二层卷积的输出会更抽象已经把边缘组合成了类似“数字部件”的特征。看到这些你才算真正“看到”了 CNN 的内部状态。关于 MNIST 项目的扩展我最后再补一句个人经验当你完整复现过这个项目之后下一个建议尝试的方向是把这个模型迁移到 Fashion-MNIST 上——它依然是 28x28 的灰度图数据加载方式几乎一样但识别难度明显更高你会遇到一些 MNIST 上遇不到的问题比如类别之间的视觉相似性这对你调整模型和超参数的帮助很大。
返回列表