简介:面向深度学习初学者与算法工程师的卷积神经网络(CNN)代码实现PDF,基于Python与TensorFlow完成,以MNIST手写数字识别为贯穿案例,系统讲解CNN的代码实现方法。文档围绕网络结构、参数设置、训练过程等关键知识点展开,覆盖两个5×5卷积层、最大池化层、512节点全连接层、Dropout层、Softmax分类器以及交叉熵损失函数的完整实现,并给出ReLU激活函数和Adam优化器的具体用法。压缩包内仅含1个PDF文件,大小约56KB,内容精炼便于离线研读,已有3760人学习/下载。除了完整代码,作者还通过控制变量的方式对比分析了增加训练集数据量与增加卷积核数量对模型准确率的影响,并结合实验总结数据规模与网络容量在CNN性能中的权衡关系,有助于读者既掌握代码细节,又理解深度学习调参与实验设计的核心思路,是一份高性价比的CNN入门参考资料。
1. 手写 CNN 代码前,先把这口气理顺
很多人把「Python CNN 卷积神经网络代码实现」当成一个大项目,觉得要同时搞定 Python 语法、CNN 原理、训练流程、调参玄学,压力直接拉满。但如果你把这件事拆开看,会发现它其实就两件事:第一,用 Python 把一个卷积神经网络的结构搭出来;第二,把数据喂进去,让它跑起来,看到 loss 下降、准确率上升。真正的痛点是,网上教程要么只讲原理不贴完整代码,要么贴了代码却跑不通——环境、版本、数据格式每个环节都埋着雷。这篇文章就按我平时从零搭一个 CNN 的实际路径来写,不绕弯子。读者大概是两类人:刚学完 Python 基础、想拿一个「能跑的深度学习项目」练手的新手,以及有机器学习概念但没完整写过 CNN 训练脚本的转行者。这两类人都能从后面几章直接抄作业。
先说一个反直觉的结论:CNN 代码实现里,最花时间的往往不是网络结构,而是数据预处理和训练循环里那几行不起眼的代码。卷积、池化、全连接这些层的调用,框架都封装好了,真正的门槛在于怎么把它们串成一个能收敛的系统。接下来我会用 PyTorch 从头写一个可运行的 CNN 分类模型,带着你把它跑通。
2. 为什么是 CNN:卷积操作到底给图像处理带来了什么
2.1 从全连接到卷积:三个关键设计解决了什么问题
在 CNN 出现之前,用神经网络处理图像的主流做法是把图片拉直成一维向量,然后送进全连接网络。一张 28x28 的灰度图,拉直后是 784 个输入节点;如果隐藏层有 256 个神经元,那第一层全连接就需要约 20 万个权重参数。如果换成 256x256 的彩色图,参数量直接爆炸到千万级,训练基本不可能。CNN 做了三件全连接网络做不到的事,这也是为什么它至今仍是图像任务的默认起点。
第一个是局部连接。卷积核只关注输入的一个局部区域,比如 3x3 的窗口,而不是整张图。这符合图像的一个基本特征:相邻像素的相关性强,距离远的像素相关性弱。第二个是权重共享。同一个卷积核在整张图上滑动,参数对所有位置都是一样的,这样不管输入多大,这一层卷积的参数量只跟卷积核的尺寸和个数有关,跟输入图片的尺寸无关——这是参数量大幅下降的核心原因。第三个是下采样。池化层(常用的有最大池化和平均池化)把每个局部区域压缩成一个值,保留主要特征的同时减小特征图尺寸,也让后续层能处理更大范围的信息。
用代码描述这个过程会比文字直观得多。下面这段是用 PyTorch 实现一个单层卷积加池化的最简单示例,它展示了 CNN 的基本操作是如何落到代码上的:
import torch import torch.nn as nn # 模拟一张 1 通道、28x28 的输入:batch_size=4 x = torch.randn(4, 1, 28, 28) # 卷积层:输入通道1,输出通道8,卷积核3x3,padding=1保持尺寸不变 conv = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=3, padding=1) # 最大池化:2x2窗口,步长为2,特征图尺寸减半 pool = nn.MaxPool2d(kernel_size=2, stride=2) # 前向传播 x = conv(x) # 输出尺寸: [4, 8, 28, 28] x = pool(x) # 输出尺寸: [4, 8, 14, 14] print(x.shape) # torch.Size([4, 8, 14, 14])这段代码展示了 CNN 代码的核心逻辑:输入是四维张量[batch, channel, height, width],nn.Conv2d负责提取特征,nn.MaxPool2d负责压缩尺寸。in_channels=1指的是灰度图的单通道,如果换彩色图就得改成in_channels=3。out_channels=8表示这一层用 8 个不同的卷积核去提取 8 种特征,输出就成了 8 个特征图。padding=1的目地是让卷积前后尺寸不变,省去后面计算尺寸的心智负担。
提示:这里张量的通道顺序是
[batch, channel, height, width],这是 PyTorch 的默认约定。Numpy 图片数组的格式通常是[height, width, channel],转换时需要用np.transpose调整维度。
2.2 为什么在这个时间点仍然从 CNN 入门而不是直接上 Transformer
你可能会问:现在不是 Transformer 的时代吗?为什么还要学 CNN 代码实现?我的观点是,CNN 恰好是理解深度学习工程化最好的「教学模型」,没有之一。原因在于它的结构相对固定,训练过程稳定,对算力要求低,而且调参时你能直观感受到每个改动带来的影响——这在更大的模型上是做不到的,大模型更多时候是个黑匣子。
做过实际项目的人都会有一个体会:模型结构只是冰山一角,数据、训练策略、工程化能力才是真正拉开差距的地方。CNN 因为结构简单,能让你把更多精力放在理解训练流程本身——数据加载、batch 组织、梯度下降、学习率调整、过拟合判断。这套方法论学会了,后面迁移到 Transformer 或其他模型只是换结构的问题。
而且 CNN 在工业界的落地场景远没有过时。很多图像分类、物体检测、缺陷检测的线上系统,骨干网络依然在用 ResNet、EfficientNet 这类 CNN 结构。在一些算力受限的边缘设备上,CNN 的推理速度和显存占用比同等规模的 Transformer 模型友好得多。所以从职业发展的角度看,CNN 代码实现不是过时的技能,而是检测识别类任务的看家底子。
框架选型上,我的建议是直接选 PyTorch。TensorFlow/Keras 在语法上确实更简洁,但 PyTorch 的调试体验和代码可读性在工程社区已经是事实标准。它的nn.Module让网络结构像积木一样清晰,autograd自动处理反向传播,DataLoader管理数据迭代——这三个特性足够支撑从入门到工业落地的全程。
| 对比项 | PyTorch | TensorFlow/Keras |
|---|---|---|
| 网络定义 | nn.Module类,前向传播显式写出 | 顺序式Sequential,简明但灵活性受限 |
| 调试方式 | 可以打断点看中间张量,接近普通 Python | 图模式编译后较难直观查看中间值 |
| 代码量 | 稍多,但结构清晰可读 | 更少,适合快速验证 |
| 生态地位 | 学术和工业界主流,新论文大多先出 PyTorch 版 | 生产部署(如 TF Serving)便利,但社区热度下降 |
| 本项目的选择 | 下文代码全部用 PyTorch | 不采用 |
PyTorch 的调试体验尤其值得说一句:它允许你在训练循环里直接print每一层的输出尺寸,这对排查维度不匹配的问题几乎是决定性的帮助。
3. 从零搭建可运行的 CNN 分类器:完整代码与参数拆解
3.1 环境准备与数据集加载:第一道坎是 python 环境而非模型本身
动手写网络之前,先保证环境是通的。很多人在「CNN 代码实现」这一步翻车,不是因为不懂网络结构,而是环境没配好。如果你是刚装了 Python 的新手,我建议用 Anaconda 管理环境,它能避免大量的依赖冲突问题。打开终端,按下面的步骤操作:
# 创建名为 cnn_env 的虚拟环境,指定 Python 版本 conda create -n cnn_env python=3.10 # 激活环境 conda activate cnn_env # 安装 CPU 版 PyTorch(不需要 GPU 也能完整跑通本文所有代码) pip install torch torchvision这里关键点是:torch是核心张量库,torchvision提供了常用的数据集(如 MNIST、CIFAR-10)、预训练模型和图像处理工具。创建虚拟环境的目的是让项目依赖隔离,避免不同项目之间因为包版本冲突而互相影响——这是工程上最基本的卫生习惯。
数据集用 MNIST 就够了。它是深度学习界的「Hello World」:28x28 的灰度手写数字图片,10 个类别,6 万张训练图 + 1 万张测试图。torchvision提供了自动下载的接口,第一次运行会花费一点时间下载,之后会自动存到本地。
3.2 数据预处理:归一化为什么比你想象的重要
数据加载的代码只做三件事:下载数据集、做归一化、包装成可以按批迭代的 DataLoader。但这三件事每一件都有讲究,先说最容易忽略的归一化。图像像素值的范围是 0 到 255,直接输入网络会导致梯度更新幅度过大,训练过程不稳定。常见的做法是标准化到均值为 0、方差为 1 的分布。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 定义数据预处理流程 transform = transforms.Compose([ transforms.ToTensor(), # 把PIL图像转为张量,像素值从[0,255]缩放到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 用MNIST数据集的全局均值和标准差做标准化 ]) # 下载并加载训练集 train_dataset = datasets.MNIST( root='./data', # 数据集存放路径 train=True, # True表示训练集 download=True, # 首次运行时下载 transform=transform # 应用预处理 ) # 下载并加载测试集 test_dataset = datasets.MNIST( root='./data', train=False, transform=transform ) # DataLoader:按批次迭代数据,shuffle打乱顺序 train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=2) # 打印一个批次的数据形状验证 data, labels = next(iter(train_loader)) print(data.shape) # torch.Size([64, 1, 28, 28]) print(labels.shape) # torch.Size([64])代码里transforms.ToTensor()把 PIL 图像转成张量,同时把像素值从 0-255 缩放到 0-1 区间,这一步常常被新手忽略,直接导致后续训练速度极慢甚至不收敛。transforms.Normalize((0.1307,), (0.3081,))用的是 MNIST 数据集的全局均值和标准差,这两个数值是公开的标准值,训练其它数据集时需要重新计算自己数据的均值和标准差。batch_size=64是内存和训练速度的折中,太大会导致显存不足,太小会让梯度更新方向抖动太厉害。num_workers=2表示用两个子进程并行加载数据,能加快数据读取速度——但如果你在 Windows 环境下遇到多进程报错,可以先改成num_workers=0,这是最常见的踩坑点之一。
3.3 网络结构定义:逐层拆解参数量与感受野的变化
数据准备好了,下面定义网络。我用的是一个简化版 LeNet 结构:两个卷积块加一个全连接分类头。之所以选这个结构,是因为它足够经典,代码量短,而它的每层设计理念和现代 CNN 骨干网络没有本质区别。你以后看的 ResNet、VGG、EfficientNet,底层都是这些组件的组合。
import torch.nn as nn import torch.nn.functional as F class LeNet(nn.Module): def __init__(self, num_classes=10): super(LeNet, self).__init__() # 第一个卷积块:1->16 通道,提取低层特征(边缘、纹理) self.conv1 = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(16) # 第二个卷积块:16->32 通道,提取更高层特征(形状、局部模式) self.conv2 = nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(32) # 池化:2x2窗口,步长为2,宽高各减半 self.pool = nn.MaxPool2d(kernel_size=2, stride=2) # 全连接分类头:经过两次池化后,28x28变成7x7 self.fc1 = nn.Linear(in_features=32 * 7 * 7, out_features=128) self.fc2 = nn.Linear(in_features=128, out_features=num_classes) def forward(self, x): # 输入 x 的形状: [batch, 1, 28, 28] x = self.pool(F.relu(self.bn1(self.conv1(x)))) # -> [batch, 16, 14, 14] x = self.pool(F.relu(self.bn2(self.conv2(x)))) # -> [batch, 32, 7, 7] # 展平成一维向量送入全连接层 x = x.view(x.size(0), -1) # -> [batch, 32*7*7] x = F.relu(self.fc1(x)) x = self.fc2(x) return x # 实例化网络并打印 model = LeNet(num_classes=10) print(model)这段代码的关键细节:self.conv1后面接了一个nn.BatchNorm2d(16),它的作用是让每层输入在训练中保持稳定的分布,能显著加快收敛速度。F.relu是激活函数,给网络引入非线性——如果不加激活函数,多个线性层的叠加仍然等价于一个线性变换,网络就失去了表达能力。x.view(x.size(0), -1)把[batch, 32, 7, 7]的特征图展平成[batch, 1568]才能喂给全连接层。
关于参数量的估算有一个简单公式:卷积层参数量 = 卷积核高 × 卷积核宽 × 输入通道数 × 输出通道数。以conv1为例,3x3x1x16 = 144 个权重,加上 16 个偏置,总共 160 个参数。对比一下如果第一层用全连接,输入是 784,输出是 16,参数量就是 784×16 = 12544——是卷积层的约 80 倍。这个对比直观说明了卷积结构在参数效率上的巨大优势。
3.4 训练循环与评估:五步一个周期,把上述代码串成完整的工程项目
网络定义好了,训练就像一个「循环往复」的过程:取一批数据,计算预测结果和损失,反向传播求梯度,更新参数,再来一次。下面是完整的训练与评估函数,这段代码可以直接加到工程里作为训练主循环。
import torch.optim as optim # 定义损失函数和优化器 criterion = nn.CrossEntropyLoss() # 多分类任务的标配损失函数 optimizer = optim.Adam(model.parameters(), lr=0.001) # Adam优化器,学习率0.001 def train_one_epoch(model, train_loader, criterion, optimizer, device='cpu'): model.train() # 切换为训练模式,启用Dropout和BatchNorm的统计更新 total_loss, correct, total = 0, 0, 0 for data, target in train_loader: data, target = data.to(device), target.to(device) optimizer.zero_grad() # 清空上一轮的梯度,否则梯度会累加 output = model(data) # 前向传播,得到预测分数 loss = criterion(output, target) # 计算交叉熵损失 loss.backward() # 反向传播,计算梯度 optimizer.step() # 更新参数 total_loss += loss.item() # 计算准确率:取分数最大的索引作为预测类别 pred = output.argmax(dim=1) correct += pred.eq(target).sum().item() total += target.size(0) return total_loss / len(train_loader), correct / total # 训练5个epoch for epoch in range(5): avg_loss, acc = train_one_epoch(model, train_loader, criterion, optimizer) print(f"Epoch {epoch+1}/5, Loss: {avg_loss:.4f}, Accuracy: {acc:.4f}")这段代码中model.train()这行至关重要:它告诉模型当前是在训练阶段。optimizer.zero_grad()负责清空上一轮的梯度——PyTorch 的梯度是累积的,如果不手动清零,下一轮更新时梯度会叠加导致参数更新方向错误。loss.backward()自动完成反向传播的所有求导运算,这是 PyTorch 的「自动挡」功能,你不需要手动写任何梯度公式。
关于训练轮数(epoch)和学习率(lr=0.001)的搭配,这里有个经验值:MNIST 用 Adam 优化器加 0.001 的学习率,通常 3-5 个 epoch 就能到 99% 以上的训练准确率。如果你的任务更复杂,数据量更大,一般做法是先用 0.001 起步,观察 loss 下降曲线变平后,再把学习率降到 0.0001 继续训练——这叫学习率衰减,是最基础也最有效的一招。完整评估阶段的代码,我一般用torch.no_grad()做推理,因为它能关闭梯度计算,减少内存占用:
def evaluate(model, test_loader, device='cpu'): model.eval() # 切换为评估模式,固定BatchNorm参数 correct, total = 0, 0 with torch.no_grad(): # 不计算梯度,加快速度、降低内存 for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) pred = output.argmax(dim=1) correct += pred.eq(target).sum().item() total += target.size(0) return correct / total test_acc = evaluate(model, test_loader) print(f"Test Accuracy: {test_acc:.4f}")model.eval()和model.train()的区别正是很多人的困惑所在:训练模式下,BatchNorm层会利用当前 batch 的均值方差做归一化,同时更新全局统计量;评估模式下则固定使用全局统计量,不加随机性。忘了切换模式是导致「训练时准确率高、预测时结果差」的头号原因。
4. CNN 代码实现避坑手册:5 个真实踩过的坑与解决逻辑
作为一个写过多个 CNN 训练脚本并踩过无数坑的人,我把最常见的翻车点和修复方案整理在这。每一条都是「现象 → 原因 → 解决」的故障排查思路,手把手带你少走弯路。
4.1 训练 Loss 不下降或乱跳,准确率维持在 10% 左右
现象:Loss 基本不动或者上下乱跳,准确率一直停在 0.1 附近(随机猜的概率)。
原因:这个现象可以排查三件事。第一,数据归一化是否做了——如果像素值直接以 0-255 输入网络,梯度往往过大导致训练不稳定;第二,学习率是否过大或过小——过大会导致 loss 震荡,过小会导致收敛极慢,几乎看不到 loss 下降;第三,标签是否是 0-9 的整数,而不是 one-hot 编码之后又没有做相应的处理——用nn.CrossEntropyLoss()时,它内部已经包含了 softmax 和 log 运算,配合整数标签使用,不需要手动做 one-hot 编码。如果你对标签做了 one-hot 却仍然用CrossEntropyLoss,就会报维度错误或出现奇怪的训练行为。
解决:按顺序检查这三步。先确认预处理里有Normalize;然后尝试lr=0.001,如果 loss 还是乱跳就降到0.0001;最后打印一个 batch 的data.shape和labels.shape确认维度正确。调试的关键思路是把问题拆开排查,一次只改一个变量,不要同时调多个参数。
4.2 训练时直接报错:Expected 4D input, got 3D in channel dimension
现象:网络第一个卷积层的输入报错,维度不匹配。
原因:这个报错的本质是nn.Conv2d需要 4 维输入[batch, channel, height, width],而输入只有 3 维。常见于两种场景:推理时只输入单张图片,形状是[1, 28, 28],少了 batch 维度;或者用 PIL 读入的单张图片没有经过ToTensor(),直接传给了模型。
解决:排查时先给输入加一个 batch 维度:
# 假设 x 是单张 28x28 的图片张量 if len(x.shape) == 3: x = x.unsqueeze(0) # 在0维插入一个维度,变成 [1, 28, 28] # 如果是 28x28x1 的 numpy 数组,需要先转成 [1, 1, 28, 28] 的 torch 张量 x = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0).float()unsqueeze(0)和permute(2, 0, 1)这两个操作就是这一节的关键词。前者补 batch 维,后者把[H, W, C]的标准图像格式转换成 PyTorch 约定的[C, H, W]。
4.3 训练时准确率已经接近 100%,测试集却只有 70%
现象:这是最典型的过拟合——网络把训练集「背」下来了,但面对没见过的图片就表现很差。
原因:主要原因有三个方向:数据量太少、网络容量过大(参数量远超数据量)、缺少正则化手段。MNIST 有 6 万张训练图,本身不容易过拟合,但如果你换成了更小的数据集(比如几千张自定义图片),这就很常见了。
解决:从简单到复杂有四个手段。第一层是数据增强:对训练图片做随机旋转、平移、缩放,相当于把数据量翻几倍;第二层是增加 Dropout 层,随机丢掉一部分神经元,强迫网络学到更鲁棒的特征;第三层是加权重衰减(Adam 优化器里设weight_decay=1e-4),限制参数的绝对值不要变得太大;第四层是提前停止,用验证集监控,一旦验证集准确率几个 epoch 不升反降,立即停止训练。我在实践中通常先用数据增强和 Dropout,这两招见效最快。
4.4 GPU 显存不足:CUDA out of memory
现象:训练刚开始就报 CUDA OOM,程序直接终止。
原因:显存被 batch 数据和中间激活值占满。主要场景是 batch_size 设得太大,或者输入图片分辨率高,或者中间特征图的通道数太多。
解决:最直接的是把batch_size从 64 降到 32 或 16。如果还不行,有几种更高级的处理办法:用混合精度训练(AMP),让部分计算用 float16 半精度,能省约一半显存;再或者用梯度累积,通过多次小 batch 模拟大 batch 的效果。下面是一段用梯度累积克服显存瓶颈的经典写法:
accumulation_steps = 4 # 每4个小batch做一次参数更新 optimizer.zero_grad() for i, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) output = model(data) loss = criterion(output, target) loss = loss / accumulation_steps # 将损失平均 loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()这样每次参数更新用到的是4 * batch_size张图片的梯度,效果上等价于 batch 变大了 4 倍,但显存占用没有增加。需要注意的是 loss 需要除以累加步数,否则总梯度会加大导致训练不稳定。这条经验能帮你省下不少买新显卡的预算。
4.5 环境坑:包版本冲突与 CPU/GPU 版本选错
现象:代码运行时报ModuleNotFoundError: No module named 'torch'或者提示torchvision版本不匹配。
原因:环境里没装 PyTorch,或者你装了 CPU 版却在 GPU 机器上跑,又或者torch和torchvision版本不匹配。这种问题在 Pandas、Numpy 相关的项目里也常见,本质上是依赖管理习惯没有建立。
解决:用前文提到的 Anaconda 建独立环境,安装命令去 PyTorch 官网的安装向导里根据你的操作系统、包管理方式和 CUDA 版本复制生成,不要自己瞎猜版本。安装完确认一下是否正确:
import torch print(torch.__version__) # 打印 PyTorch 版本 print(torch.cuda.is_available()) # True表示GPU可用,False表示只有CPU print(torch.backends.mps.is_available()) # 苹果M系列芯片可用的加速后端如果在没有独立显卡的电脑上训练,不用焦虑,MNIST 这种小规模数据集用 CPU 跑完 5 个 epoch 也只要两三分钟。代码里device = 'cuda' if torch.cuda.is_available() else 'cpu'这个写法可以直接写在数据传给模型的环节,保证代码在有无 GPU 的环境下都能运行。
5. 用 CNN 做真实项目的进阶技巧与验证方法
当你的 CNN 已经能在 MNIST 上稳定达到 98% 以上的准确率,就说明你已经具备了独立实现 CNN 项目的真实基础。下一步是把这个技能转化成能落地的工程能力。这几年在实战项目里,有三个习惯对我帮助最大。
第一个习惯是始终记录实验配置。每跑一组实验,就记下当时的网络层数、学习率、batch_size、数据增强方式和最终准确率。用torch.save把模型权重保存下来存档:
# 保存模型权重(推荐方式,保存state_dict即可,不依赖模型结构文件额外的依赖) torch.save(model.state_dict(), 'mnist_cnn_epoch5_acc99.2.pth') # 推理恢复模型 model = LeNet(num_classes=10) model.load_state_dict(torch.load('mnist_cnn_epoch5_acc99.2.pth')) model.eval()可能你会想,训练完就完事了,为什么要存模型?因为工程上最怕的就是训练出一个好模型却复现不出来。权重文件保存的是模型学到的参数,很小也很通用,推理时只要把它加载回模型结构即可。
第二个习惯是用混淆矩阵验证模型。准确率这个指标在类别不均衡时会骗人。比如 90% 的样本都是类别 0,那模型只要全预测成 0 就有 90% 的准确率,看起来不错实际却毫无用处。用混淆矩阵能看到每个类别互相混淆的情况,判断模型到底哪里学得不好。
import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for data, target in test_loader: output = model(data) pred = output.argmax(dim=1) all_preds.extend(pred.cpu().numpy()) all_labels.extend(target.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) disp = ConfusionMatrixDisplay(confusion_matrix=cm) disp.plot(cmap='Blues') plt.savefig('confusion_matrix.png', dpi=150)这段代码把测试集的预测结果和真实标签收集起来,生成一张直观的混淆矩阵图。你能一眼看出哪些数字容易被互相认错——比如 4 和 9、3 和 5 这类形状相近的手写数字。知道错在哪,后续优化的方向就清楚了。
第三个习惯是保留一个没有经过任何预处理或增强的原始测试集。做数据增强时很容易不小心把增强逻辑应用到测试数据上,导致测试结果反映的不是模型真实能力。训练和测试的数据流一定要分开,这是工程上和科研上通用的底线。
从部署角度补充一句:文章里的代码在 CPU 上用不了多少资源就能跑到 99% 以上的准确率,这意味着只要是中等配置的开发机都能顺利跑完。如果你的电脑是普通笔记本没有独立显卡,也完全不用有压力。MNIST 数据规模小,训练过程中一行代码都不需要改——只要按照正文章节从环境到训练一步步执行,就能跑通一个真实项目。
动手之前有个小提醒:代码从头到尾敲一遍,不要直接复制粘贴。自己敲的过程中,你会注意到很多复制粘贴时注意不到的细节——比如model.train()和model.eval()的位置、view展平的操作、optimizer.zero_grad()的必要性。这些细节才是真正的收获,也是你以后能独立写大型项目的基础。我当年跑第一个 CNN 时,被DataLoader的num_workers在 Windows 下报错耗了一整天,后来把参数设为 0 就解决了。这些血泪经验让我养成了一个习惯:所有脚本先跑通最小版本,再逐步加功能扩展。希望帮到你。
本文还有配套的精品资源,点击获取