ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零搭建LeNet风格CNN:PyTorch实现MNIST图像分类

从零搭建LeNet风格CNN:PyTorch实现MNIST图像分类 前两篇我们把卷积的计算方式、卷积核的含义、特征图是怎么来的这些基础概念过了一遍可能你已经有点感觉了卷积本身不复杂复杂的是怎么组织成一整套能解决实际问题的网络。这一篇就干一件事——把前面那些零散概念组装起来搭出一个真正能跑分类任务的简单卷积网络我用的例子是LeNet风格的mini CNN框架选PyTorch数据集用MNIST。如果你已经基本理解了卷积核在图像上滑动、逐元素相乘再求和的过程但一到“怎么把这些层堆起来”“参数怎么算”“训练代码是什么样”就懵了那这篇就是给你写的。我把从网络结构设计到训练跑通的每一个环节都拆开讲包括那些代码里不会告诉你的坑。1. 简单卷积网络的整体设计思路1.1 为什么拿LeNet-5作为起点我在第一篇画过卷积核滑动的示意图第二篇讲了特征图尺寸怎么算。但那时候讲的都是单层操作扔进来一张图过一个卷积出一张特征图。真实场景里这远远不够因为图像的特征是有层次结构的——边缘组成纹理纹理组成局部形状局部形状再组成物体。LeNet-5是1998年Yann LeCun提出的手写数字识别网络也是历史上第一个大规模商用的卷积神经网络。它一共只有7层两个卷积层、两个池化层再接三个全连接层。这个规模放在今天看起来小得可怜但它精确地展示了卷积网络的经典范式先不断做空间下采样提取特征再把高层的语义特征展平交给全连接层完成分类。我为什么一直推荐新手先复现它因为它的每个设计决策都有明确的物理含义。第一个卷积层用6个5×5卷积核因为你想要6张不同的“边缘响应图”每卷积一次就跟一个2×2最大池化是为了把空间尺寸减半、让感受野扩大。这种“卷积提特征、池化压尺寸”的交替是后面所有CNN的共同骨架理解了这个VGG、ResNet对你就只是它的变体。1.2 一个简单CNN的完整结构我搭的简化版结构长这样为了方便跑MNIST28×28灰度图输入通道设为1层名称类型参数输出尺寸输入图像1×28×281×28×28Conv1卷积6个5×5卷积核步长1无填充6×24×24Pool1最大池化2×2步长26×12×12Conv2卷积16个5×5卷积核步长1无填充16×8×8Pool2最大池化2×2步长216×4×4Flatten展平把16×4×4拉成向量256FC1全连接256→120120FC2全连接120→1010这就是一个标准的“小型LeNet”。你注意看网络的深度很浅但每个阶段的目的非常清楚。前两层卷积池化负责把28×28的原始像素逐步转化成16张4×4的响应图这时的特征已经高度抽象后面的全连接层再把特征映射到10个数字类别的得分上。1.3 为什么这样设计特征提取加分类两段式很多人以为CNN就是一堆卷积层随便叠叠得越深越厉害。其实稍微看一下成熟网络的结构就会发现它们都有明显的“两段式”分工。前一段叫特征提取器一般由卷积层和池化层交替组成。这个阶段不关心你到底要分几类它只做一件事把原始图像转化成高维但紧凑的特征表示。以MNIST为例28×28784个像素作为输入经过特征提取器之后变成256维向量这256个数已经编码了“这个手写数字是什么形状”的关键信息。最关键的是这些特征不是人手工设计的而是通过网络自己学出来的。后一段叫分类器通常就是几个全连接层。它接收特征提取器输出的向量映射到类别得分上。输出维度等于类别数MNIST就是10。两部分拼在一起用反向传播端到端训练卷积核里的参数和全连接层的权重会同时更新互相配合。我在自己的代码里观察到过一个很有意思的现象训练初期分类器权重变化很快因为它在拼命适应特征但当训练到中后期卷积核开始缓慢调整提取的特征越来越有判别力。这说明两段式不是机械拼装而是整个网络在协同优化。新手在调试时如果有意识地把网络看成两个部分很多问题会更好定位。2. 核心细节解析与实操要点2.1 卷积层的参数怎么选才合理搭建简单网络时最常遇到的问题就是卷积核大小、数量、步长、填充这几项参数不知道咋选。这里我把它们一个个说透。卷积核大小作用范围越大每层能看到的区域越大但计算量也越大。5×5、3×3、7×7都是常见选择。简单网络里推荐用5×5或3×3因为MNIST数字本身只有28×28如果上来就用7×7两层之后特征图就没多少像素了。我初学的时候贪心用过11×11结果第二层输出尺寸直接小于卷积核代码直接报错。后来养成一个习惯任何卷积层加上之后都心算一遍输出尺寸必须为正数。卷积核数量决定输出的特征通道数也决定模型容量。第一个卷积层我用6个第二个用16个这是LeNet的原版配置。实际任务中可以适当加多比如32和64。但要清楚通道数翻一倍这个卷积层参数量就翻一倍训练时间也会明显变长。对于MNIST这种简单任务6和16足够硬堆到256个通道纯属浪费算力。步长stride默认为1。大于1的步长会直接缩小特征图尺寸相当于把卷积和下采样合并到一步。我在基础网络里保持stride1把尺寸控制都交给池化层因为这样每个位置都能被卷积核完整扫描信息丢失少。通道压缩的活儿分开做也更容易排查问题。填充padding默认0填充。加了填充可以保持尺寸不变后面如果搭ResNet那种需要恒等映射的结构padding1加3×3卷积是标配。但在LeNet风格里我故意不填充让尺寸自然缩小这样每一层都在逐步降低分辨率语义信息越来越紧致。2.2 输出尺寸计算公式务必焊死在脑子里卷积层输出尺寸的公式是[ W_{\text{out}} \left\lfloor \frac{W_{\text{in}} - k 2p}{s} \right\rfloor 1 ]我来拆解一遍W_in是输入尺寸k是卷积核大小p是paddings是stride。以28×28输入、5×5卷积核、无填充、步长1为例[ \frac{28 - 5 0}{1} 1 24 ]输出就是24×24和上面表里一致。第二次卷积时输入是12×12池化后同样计算[ \frac{12 - 5}{1} 1 8 ]输出8×8。最后展平就是16×8×8256。这个公式必须熟练因为全连接层输入维度全指望它。我在实操中见过大量新手卡在两个地方一是忘了池化层也在改变尺寸二是把多通道的“16×4×4”算成了只有“4×4”。每次定义模型前拿一张纸把每个层input和output尺寸写出来比在脑子里空想要可靠一万倍。2.3 池化层为什么用最大池化而不是平均池化简单网络里通常每个卷积层后面跟一个池化层。池化不引入可学习参数是个纯下采样操作。MaxPooling取窗口内的最大值AveragePooling取平均值。我在这个网络里全用MaxPooling这是有原因的。最大池化相当于在局部区域里保留响应最强的特征。对MNIST来说数字笔画边缘处的卷积响应往往比周围高取最大就能保留“这里有个边”这个最显著的信号而平均池化会把强响应和弱响应混合起来特征会变模糊。直观类比一群人讨论问题最大池化是只听嗓门最大的那个人平均池化是听所有人的均值。在特征稀疏的图像任务里前者往往更有效。2×2窗口、步长2是最常见的配置。它把特征图宽高各缩小到一半区域不重叠信息不会重复计算。简单网络里不需要更复杂的池化策略后面到了高级网络全局平均池化才替代Flatten和全连接层那是另一个话题。2.4 激活函数让网络真正能学非线性卷积操作本质上是线性运算每个输出通道都是一堆输入的加权和全连接层也是线性的。如果不加激活函数整个网络无论叠多深等价于一个线性变换那和只有一层的感知机没区别。MNIST数字识别这种任务特征边界高度非线性线性模型最多做到88%左右准确率加了ReLU后我的训练准确率跑到99%以上区别就在这里。ReLU的公式是max(0, x)负值直接置零正值保留。计算极简但效果好最主要的优点是梯度不会饱和。Sigmoid和tanh在输入绝对值大的地方梯度趋近于0反向传播时梯度会逐层递减甚至消失网络根本训不动。ReLU在正半轴梯度恒为1梯度消失问题就大幅缓解了。实操细节上ReLU应该放在卷积层和池化层之间。我的代码写法是self.pool(F.relu(self.conv1(x)))先卷积再ReLU再池化。顺序不能乱。曾经有朋友把ReLU放池化之后训练起来莫名其妙地卡在某个准确率上不去其实就是因为激活作用在了已经下采样的特征上信息在非线性变换前就丢了一部分。2.5 全连接层特征到类别的最后一公里Flatten把池化输出展平成一维向量然后交给全连接层。全连接层的参数量一般远超卷积层。我这个简化模型里FC1有256×12030720个参数而Conv2层只有16×6×5×5162416个参数。这也是很多现代网络倾向于用全局池化替代全连接的动机之一——参数量能少很多。输出维度是10对应10个类别。最后一层不加激活函数直接输出原始得分logits交给交叉熵损失函数PyTorch此时会内部套一层Softmax。这里有个容易踩的坑不要在最后一层手动加Softmax后再算损失因为nn.CrossEntropyLoss已经包含Softmax你再加一次等于套了两次归一化训练梯度会变得很怪模型很难收敛。3. 实操用PyTorch从零搭一个简单卷积网络3.1 环境准备本机有Python 3.10PyTorch 2.xCUDA可用就用GPU没有就CPU跑MNIST这个小任务CPU照样能跑完。依赖只有torch、torchvision、matplotlib和numpy。我建议你新建一个干净环境不要和项目混在一起。pip install torch torchvision matplotlib numpy本地验证CUDA是否可用import torch print(torch.cuda.is_available())返回True就接着往下走返回False也没关系这篇的代码在CPU上训练时间也就几分钟。3.2 定义网络模型模型定义直接对标LeNet-5简化版我把完整代码贴出来import torch import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(1, 6, kernel_size5, stride1, padding0) self.pool nn.MaxPool2d(2, 2) self.conv2 nn.Conv2d(6, 16, kernel_size5, stride1, padding0) self.fc1 nn.Linear(16 * 4 * 4, 120) self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, 10) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x x.view(-1, 16 * 4 * 4) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) return x比刚才的结构多了一层FC2中间维度84和LeNet保持一致。初始化默认即可PyTorch的默认初始化在浅层网络里表现没问题。这里有个关键点self.fc1 nn.Linear(16 * 4 * 4, 120)里的16*4*4不是随便写的。它来自Conv2输出16通道、池化后4×4的空间尺寸。如果想调整网络比如把卷积核从5改成3或者加padding这里就很容易出错。最稳妥的办法是写一个临时的forward测试把一张随机张量传进去观察输出形状。model SimpleCNN() fake torch.randn(1, 1, 28, 28) print(model(fake).shape)如果输出是torch.Size([1, 10])说明维度正确如果报错错误信息会明确指出是在哪一层形状不匹配。我调试网络第一件事就是跑这段代码不用训练就知道结构问题。3.3 数据加载与预处理MNIST是torchvision里内置的数据集下载后是28×28灰度图。原始像素范围0到255训练神经网络前必须先转成[0,1]浮点数再做归一化。归一化这步容易被新手省略我强调过很多次不归一化直接训练损失会震荡很久收敛速度慢好几倍。import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset torchvision.datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform) test_dataset torchvision.datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)0.1307和0.3081是MNIST全集的像素均值和标准差是官方统计好的标准值。Normalize配合ToTensor之后数据分布近似均值为0、方差为1的标准正态这能有效加快梯度下降的收敛速度。Batch size我设64。这个数字不大不小能充分利用并行计算又不会频繁更新权重导致训练不稳定。shuffleTrue保证每个epoch的训练样本顺序不同避免模型学到顺序相关的伪模式。3.4 训练循环一个epoch里的完整流程训练逻辑本身很固定但每个细节都有讲究。定义损失函数和优化器这里用交叉熵损失和带动量的SGDimport torch.optim as optim model SimpleCNN() criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9) def train_one_epoch(epoch): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / total epoch_acc 100.0 * correct / total print(fEpoch {epoch}: loss{epoch_loss:.4f}, acc{epoch_acc:.2f}%) return epoch_loss, epoch_accoptimizer.zero_grad()这一步特别重要。PyTorch的梯度是累加的如果不每次清零上一次batch的梯度会叠加到本次优化器更新时梯度方向就错了。我在新手代码里看到过的训练发散多半是这个没写。model.train()和model.eval()也是所有训练代码都要有的。虽然这个简单网络里没有Dropout和BatchNorm表现不明显但我建议从一开始就养成交替调用的习惯否则后面搭深层网络时忘了切模式训练和测试结果都会出问题。训练3到5个epoch就能看到明显效果完整跑5个epochfor epoch in range(1, 6): train_one_epoch(epoch)实测输出类似Epoch 1: loss0.3432, acc89.23% Epoch 2: loss0.1015, acc96.78% Epoch 3: loss0.0712, acc97.80% Epoch 4: loss0.0536, acc98.34% Epoch 5: loss0.0437, acc98.66%第一次epoch就能到89%第三轮就稳定在97%以上最后一轮测试集准确率大约98.7%这个水平已经能说明卷积网络在图像分类上的威力。只用5个epoch、几十秒时间从完全随机初始化到98.7%准确率这是全连接网络很难做到的。3.5 测试评估与模型保存训练完后在测试集上跑一遍使用torch.no_grad()这个上下文里不计算梯度内存占用大幅下降速度也会更快def evaluate(model, test_loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fTest Accuracy: {100.0 * correct / total:.2f}%) evaluate(model, test_loader)模型保存用一行代码torch.save(model.state_dict(), simple_cnn_mnist.pth)注意保存state_dict而不是整个model。前者只存模型参数体积小加载的时候需要先实例化一个相同结构的模型再load。这是工程实践中最推荐的方式因为模型结构升级后旧的state_dict仍然可以迁移加载。3.6 把中间特征图画出来理解每层学到的内容训练完的模型不要急着关我强烈建议你写一个可视化函数把Conv1和Conv2输出的特征图画出来。这一步对理解的帮助远超读十篇博客。import matplotlib.pyplot as plt model.eval() sample_img, _ test_dataset[0] sample_img sample_img.unsqueeze(0) with torch.no_grad(): conv1_out model.pool(F.relu(model.conv1(sample_img))) conv2_out model.pool(F.relu(model.conv2(conv1_out))) plt.figure(figsize(12, 6)) for i in range(6): plt.subplot(2, 3, i 1) plt.imshow(conv1_out[0, i].numpy(), cmapgray) plt.title(fConv1 channel {i}) plt.axis(off) plt.tight_layout() plt.show()我每次看这个输出都很有体会第一层的6张特征图有的专注提取横边缘有的专注竖边缘有的对角点更敏感。这6个卷积核像六个分工不同的低级特征探测器。到Conv2输出的特征图内容已经很难用人眼解读了它们是更高层的组合特征这正是我在第一章说的特征层次结构。4. 常见问题与排查技巧实录4.1 维度计算错误导致全连接层报错这是新手最容易踩的坑报错信息一般是RuntimeError: size mismatch, m1: [64 x 400], m2: [256 x 120]意思是Flatten之后特征维度是400但全连接层期望256。错因通常是不小心改了卷积层参数或者忘记算池化层带来的变化。我之前带新手时他用了3×3卷积核但保持fc1 nn.Linear(16 * 4 * 4, 120)第二层输出尺寸变成6×6展平后就是16×6×6576和256对不上。排查思路很直接在x.view那行之前打印x.shape用pyTorch的print打断点看真实尺寸。不要靠脑补让程序告诉你。也可以像我刚才那样用torch.randn(1, 1, 28, 28)跑一遍模型报错信息会精确到哪一行发生不匹配很快就能定位。4.2 损失不下降或者直接变成NaN损失卡住不降先看数据有没有归一化。原始0到255的像素输入ReLU后大数值会持续放大梯度可能爆炸。我做过对比实验不归一化训同样的网络第一个epoch损失一直是2.3左右准确率停在10%——等于在瞎猜。归一化后第一个epoch就掉到0.34区别非常明显。如果损失变成NaN大多是学习率太大。SGD默认我是用0.01如果你把学习率调到1.0梯度更新一步就可能把权重震荡到无穷大。遇到NaN先降低学习率这是最常用的挽救手段。还有一种情况是CrossEntropyLoss的输入出现了NaN多半是前面某层数值溢出这时候检查数据和模型forward每一层输出。4.3 过拟合怎么处理MNIST数据量本身够大简单网络过拟合不明显。但如果你换成小数据集或者训练轮数拉长到50轮测试准确率会开始波动甚至下降而训练集准确率接近100%这就是过拟合的典型信号。此时应该考虑的手段有三个加Dropout、数据增强、减小模型容量。Dropout是改动最小的方法。在FC1后面加一层nn.Dropout(p0.5)训练时随机丢掉一半神经元迫使网络学习冗余特征测试时自动缩放不用额外处理。对于MNIST这种简单任务加大训练数据本身也是立竿见影的可以用平移、旋转做数据增强。需要注意的是加入Dropout后模型可能需要更多epoch才能收敛这是正常现象。4.4 显存不足怎么办显存不足最常见的报错是CUDA out of memory。原因一般是batch size设置过大或者输入的图像分辨率太高。手写数字图片只有28×28显存压力很小但如果你换成高分辨率RGB图同样的网络内存占用会爆炸。解决办法优先级先把batch size从64降到16再不行就减小输入尺寸比如把图像resize到64×64。有时候模型定义里通道数设得过大也会爆显存可以先把通道数减半试跑确认没问题再逐步加上去。另外要检查代码里是不是有累积的张量没有释放比如在循环里反复保存特征图而不重新赋值这些细节经常被忽略。4.5 实验结果不稳定同一份代码跑两次准确率差零点几个百分点这个完全正常。因为网络权重是随机初始化的数据加载时shuffle也带来随机性。想让实验可复现在训练前固定随机种子def set_seed(seed42): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) import random random.seed(seed) import numpy as np np.random.seed(seed) set_seed()如果固定种子后结果还不稳定检查数据加载是否设了num_workers以及PyTorch版本之间的差异。做研究对比实验时固定随机种子是基本素养否则你没法判断两个模型的准确率差异到底是网络结构带来的还是运气带来的。5. 从简单网络走向真实应用5.1 简单网络能做什么不能做什么98.7%的MNIST测试准确率容易让人产生错觉觉得卷积网络不过如此。实际拿到真实业务场景比如工业质检图片、医疗影像、自然照片分类模型效果会受到很多因素影响数据量不够、类别不平衡、拍摄角度变化、光照变化。简单网络在这些场景下通常会开始吃力因为它的感受野有限通道数也不够多无法捕捉足够复杂的语义信息。但我想强调的是简单网络的核心价值从来不是刷分而是作为让你理解CNN内部运转的调试平台。在MNIST上你能快速看到每个模块改动对准确率的影响能第一时间感知到学习率大了是多少、归一化有没有效果、加一层卷积到底带来什么变化。把这些问题在简单网络上彻底搞明白再去碰ResNet、EfficientNet这些大模型心里才有底。5.2 热搜里那些卷积变体和这篇是什么关系最近经常看到深度可分离卷积、空洞卷积、3D卷积、门控卷积这类关键词这些都不是全新概念而是在基础卷积之上做了不同的改动。拿深度可分离卷积举例它把标准卷积拆成两步先逐通道做空间卷积再用逐点卷积在通道维度上混合信息参数量大幅下降。这种设计背后的动机是计算效率但它的基础理解方式还是“卷积核在特征图上滑动”。空洞卷积则是在卷积核的取值之间插入空洞用相同参数量获得更大的感受野常用于语义分割任务里要保持特征图分辨率的需求。门控卷积引入门控机制来控制信息流动而3D卷积把卷积核扩展到时间维度适合视频数据TCN这类时间卷积网络也跟它有关。这些网络看起来高深但拆开看每一层依然是普通卷积、池化、激活、全连接这些基本单元的排列。你要做的不是一头扎进最新的论文复现而是先把这篇里的SimpleCNN跑熟知道卷积核参数变化怎么影响特征提取、反向传播怎么更新权重、训练过程哪些环节容易出坑。地基打牢了后面所有新概念对你来说都只是增量学习。我个人在实际操作中的体会是带新人学习CNN时与其让他背一堆网络结构的模块清单不如让他亲手改一遍这个简单网络的各种超参数亲眼观察准确率的变化曲线。学习率和归一化这两个点如果只是看文档怎么都记不牢真正亲身踩过损失不掉的坑之后才明白它们有多重要。最后再分享一个小技巧在你训练完成之后把第一个卷积层的6个卷积核用plt.imshow直接可视化出来。你会看到它们不是随机噪声而是变成了有规律的边缘探测器。那一刻你才算真正理解了什么叫“网络自己学到特征”。从这篇的简单网络出发后面无论再学多少高级模型你都不会再觉得卷积网络是个黑盒了。
返回列表