恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从零搭建简单卷积网络:MNIST手写数字识别实战详解
首页
资讯中心
/
从零搭建简单卷积网络:MNIST手写数字识别实战详解
从零搭建简单卷积网络:MNIST手写数字识别实战详解
发布时间:2026/10/20 12:36:33
你可能已经背下了卷积运算的公式也知道卷积核和池化各自是干什么的但真到自己动手拼一个完整的卷积网络时还是会卡壳第一层到底该放多少个卷积核为什么池化之后要接全连接层输出尺寸该怎么算这篇文章就是来解决这些“组装阶段才冒出来的问题”。作为卷积基础知识系列的第三篇前两篇咱们把卷积是怎么算的、卷积核是怎么理解图像局部特征的讲清楚了。这篇我会带你把这个系列的零件真正拼成一台能跑起来的机器——一个基于MNIST手写数字识别的简单卷积网络。读完你会发现所谓“简单卷积网络”拆开看也就是输入层、卷积层、池化层、全连接层和输出层这几块积木难的不是结构而是理解每一块积木为什么被放在那个位置以及它们之间的尺寸衔接。这篇适合两类读者一类是把卷积原理看得七七八八、正准备写第一个CNN的初学者另一类是写过不少模型但从来没认真看过特征图、遇到训练不收敛只想改学习率的人。不管你是哪一类我保证这篇的内容都能让你少踩几个坑。1. 为什么要把特征提取和分类拆成两段1.1 传统路子先人工设计特征再交给分类器在卷积网络成为默认选项之前图像识别的主流做法是“提特征 分类器”两段式。所谓提特征就是人工设计一些算子比如边缘检测的Sobel算子、纹理描述的局部二值模式LBP、尺度不变特征变换SIFT等把一张图片变成一组能够反映“哪里有边缘”“哪里有角点”“纹理长什么样”的向量。然后再把这组向量送到SVM或者随机森林里做分类。这套思路有一个绕不开的瓶颈特征是人设计的而设计特征的人很难预料真实数据里所有可能的形态。你设计了一组专门识别竖线的特征遇到斜着的、断掉的、光照不好的线就抓瞎了。而且特征设计本身是个经验活换个数据集之前好用的特征可能就失效了整套方案就得重新设计。1.2 卷积网络改变了什么特征不再是配方而是产物卷积网络最核心的思想改变是把“特征设计”这件事从人类手里交给了网络自己。网络的前半部分卷积层池化层负责从原始像素里逐层抽象出特征后半部分全连接层负责基于这些特征做出分类决策。整条链路可以端到端训练你只需要提供带标签的图片反传梯度会自动调整卷积核里的数值让网络自己摸索出一组比人工设计更好的特征。那为什么特征提取部分不用全连接层来做呢这也是新手最容易困惑的点。全连接层本质上做的是“全局加权求和”它把输入的每一个元素都连到输出的每一个元素上参数量是输入数量乘以输出数量。一张28x28的灰度图展平后是784个像素第一层如果有256个神经元就是大约20万个参数这在MNIST这种小图上勉强还能忍但如果输入是100x100的RGB图第一层展平就是3万个输入再接256个神经元直接800万个参数。更别说图像里那些重要的边缘、角点往往只有几个像素宽让全连接层去学这种局部模式它不仅费力还学不干净因为它默认“每一个像素跟所有其他位置一样重要”完全不利用图像在空间上的相关性。卷积的聪明之处就在于两个字局部和共享。每个卷积核只关心一个小邻域比如3x3这就是局部连接但同一个卷积核会滑过整张图像的所有位置用同一组权重去检测同一种模式这就是参数共享。换句话说一个卷积核就是在问“图像的每一个位置是否出现了某种特定的局部图案”。这样参数量跟图像尺寸没关系只跟卷积核尺寸和数量有关训练起来也快得多。1.3 分类器为什么放到最后有人会问能不能让全连接层直接接在卷积层后面不要池化或者干脆全用卷积不用全连接结构上确实可以但设计逻辑不太对。卷积层输出的特征图仍然保留空间结构——每一个通道的像素值表示“这个特征在这个位置有没有出现”。但分类任务最终只需要一个回答“这张图属于哪个类别”。从“每个位置有哪些特征”到“整张图属于哪个类”中间需要一层信息汇聚也就是把空间上的特征整合成一个整体判断。池化是其中一种办法全连接层也是。全连接层的优势在于它可以学习不同空间位置、不同特征通道之间的组合关系像“上方有弧线、下方有竖线、中间有洞口所以这可能是个数字6”这样的推断本质上就是特征的组合打分。所以简单卷积网络的标准姿势是卷积层不断提取局部特征池化层逐步压缩空间尺寸把尺度从像素级放大到语义级最后全连接层把高度抽象的特征向量映射到类别得分。分段清晰、各司其职这是它好用又容易调试的根本原因。2. 简单卷积网络的五个标准零件2.1 输入层先搞清楚数据长什么样写网络之前第一件事不是定义模型而是搞清楚输入数据的形状。以MNIST为例每张图片是28x28的灰度图灰度图只有一个通道所以在PyTorch里的张量形状是(1, 28, 28)。如果有N张图组成一个批次batch那实际送入网络的形状是(N, 1, 28, 28)N就是批次大小。这里很容易踩一个理解上的坑通道维度到底在第几位。PyTorch默认的布局是“通道在前”也就是(batch, channels, height, width)简称NCHW。TensorFlow的Keras默认布局是“通道在后”即(batch, height, width, channels)简称NHWC。同一个模型从PyTorch搬到Keras如果没注意通道维度的差异输入形状写错第一层就会报维度的错。另外还要做归一化。像素值范围是0到255直接把原始数值喂给网络不仅会让梯度变得不稳定还会让不同数据集之间的尺度不一致。正确的做法是先用(255)把像素缩放到0到1再用数据集的均值和标准差做标准化让输入分布接近标准正态。transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])MNIST的全局均值和标准差大约是0.1307和0.3081这两个值不需要自己算网上到处都能查到DataLoader里用一下就行。2.2 卷积层参数怎么定输出多大卷积层是网络里最核心的零件定义它需要四个关键参数输入通道数、输出通道数也就是卷积核个数、卷积核尺寸、padding。输入通道数好理解灰度图就是1上一层的输出通道数就是这一层的输入通道数。输出通道数代表你想在这个位置提取多少种不同的局部特征第一个卷积层一般取32或64太少了学不全太多了容易过拟合。卷积核尺寸最常见的选3x3因为它是能捕获局部模式的最小非对称尺寸1x1只能做通道组合2x2没有中心点。两个连续的3x3卷积叠加感受野能达到5x5而参数量只有5x5卷积的18/25所以现代网络普遍偏爱小卷积核叠加深层数。输出尺寸的计算公式不复杂但要记牢输出高 floor((输入高 - 卷积核高 2 * padding) / stride) 1stride代表卷积核每次移动的步长。padding是为了保持边缘信息不丢失而做的填充最常用的是padding1配合kernel_size3这样输出尺寸不变。给一个具体的例子输入28x28kernel_size3padding1stride1那么输出就是(28 - 3 2*1)/1 1 28形状保持不变。如果stride2输出会缩到14。我自己写卷积层之前习惯把每一层的输出尺寸先手算一遍画成一张表再开始写代码。这样可以避免写到全连接层时才发现“咦展平后神经元个数算错了”。2.3 激活函数为什么默认选ReLU卷积运算是线性运算如果卷积后面不跟激活函数那再多层卷积叠加起来本质上还是一个线性变换根本学不了非线性特征。激活函数的引入就是为了给网络注入非线性。现在的主流选择是ReLUf(x)max(0,x)。它只有一个阈值计算几乎不耗时间负数直接置零正数保留原值。相比早期常用的sigmoidReLU最大的优势是能缓解梯度消失问题。sigmoid的导数在远离零点时几乎为0网络一深梯度乘几次就趋近于0前面几层根本学不动。ReLU在正半轴的导数恒为1梯度传播稳定得多。ReLU也有一点需要注意如果一个神经元的输入一直是负数它会被永久置零梯度永远为0这就是所谓的“神经元坏死”。在简单卷积网络里解决它最实用的办法是降低学习率不要一上来就用很大的学习率把权重推到负区间。2.4 池化层压缩信息扩大感受野池化层没有参数它的工作是对特征图做下采样。最常见的最大池化MaxPooling是每个2x2的小窗口里只保留最大值把特征图尺寸缩小一半信息保留最“活跃”的那个响应。池化有两个作用。第一是降维把特征图的宽高从28缩到14、再从14缩到7参数和计算量大幅减少也能防止过拟合。第二是扩大感受野卷积核看到的范围是有限的经过一次池化原来3x3卷积看到的信息覆盖了输入中更大的区域后面的卷积层可以基于更大的上下文做特征提取。简单说就是让网络先看局部再看更大的局部。2.5 全连接层和输出层把特征变成得分经过若干轮卷积和池化之后特征图会被展平成一条向量比如最后一层特征图是64通道、7x7大小展平后就是64773136个数值。这一层的任务是把这3136个数字打一套组合分输出维度等于类别数MNIST是10。具体过程就是先接一个全连接层比如输出128个神经元激活函数用ReLU再接一个dropout层随机丢掉一半神经元防止网络过度依赖某几个特征最后再接一个全连接层输出10个类别得分。这10个得分经过softmax就变成“这张图片属于数字0到9的概率分布”。到这里一个完整网络的骨架就出来了两层卷积池化交替 一层全连接 一层分类输出。这就是LeNet-5这个经典架构的简化版LeNet-5当年就是在MNIST上靠这一套组合拿到的极高识别率。3. 手算一次前向传播彻底告别尺寸焦虑3.1 一个5x5输入和3x3卷积核的例子不看公式、不写代码手动把卷积过一遍这是理解卷积网络最好的方式。咱们用一个小例子输入图像 I5x51 0 1 0 0 0 1 1 0 1 1 0 1 1 0 0 1 0 1 0 1 0 1 0 1卷积核 K3x31 0 1 0 1 0 1 0 1偏置暂时设为0。卷积核从输入左上角开始与图像左上角3x3区域做逐元素相乘再求和。左上角3x3窗口是1 0 1 0 1 1 1 0 1对应相乘再累加非零的匹配位置有5个所以第一个输出值是5。这是最朴素的卷积运算窗口在图像上滑一下算一个数再滑一下再算一个数。接着把窗口向右平移一格这时候窗口内容是0 1 0 1 1 0 0 1 0和卷积核逐元素相乘后只有中间那个位置有有效响应1所以输出是1。你会看到图像里有对角线和一些局部结构这个3x3卷积核正好在匹配“十字形加四个对角”的模式匹配得越多输出越强。整个过程下来步长为1、没有padding时输出尺寸是(5-3)/113也就是一张3x3的特征图。这3x3的9个数值每个都代表“输入图像对应局部位置跟这个卷积核的匹配度”。3.2 padding和stride到底改变了什么现在把padding设为1意思是在输入周围补一圈0输入从5x5变成7x7卷积核再滑过去输出尺寸就变成(7-3)/115和原始输入一样大。padding的意义在于原来图像边缘的像素只被卷积核扫过一次填充之后边缘像素参与计算的次数变多了边缘信息不会被轻易丢掉。stride的作用则相反它决定卷积核一次走几步。stride2时上面的5x5输入、3x3核、无padding输出是(5-3)/2取整再加1等于2特征图直接缩到2x2。stride越大计算量越小但空间信息丢得越狠。在实际网络中卷积层通常保持stride1、padding1来维持尺寸用池化层来做缩小。这样卷积层只需要专注于特征提取尺寸变化都交给池化逻辑更清晰新手也更好把控。等你哪天想自己设计网络结构先手算一遍每一层的输出尺寸再动笔能省一大堆调试时间。4. 一个能跑的简单CNN完整实现与训练4.1 网络结构设计为什么是这个配置我把这个简单网络设计成2层卷积、2层池化、2层全连接模型规模控制在不到30万个参数。结构如下输入1x28x28Conv132个3x3卷积核padding1输出32x28x28ReLU激活MaxPool2x2输出32x14x14Conv264个3x3卷积核padding1输出64x14x14ReLU激活MaxPool2x2输出64x7x7Flatten展平成3136维FC13136 - 128ReLU激活Dropoutdropout0.5FC2128 - 10为什么第一层用32而不是128因为这个任务本身的难度不高MNIST只有10个类别32个卷积核足够学到笔画、边缘、弧线这类基础模式。第二层扩到64个卷积核是为了在更高语义层次上组合第一层的特征。如果第一层就上128个卷积核MNIST这种小数据集很快就能过拟合训练集准确率冲到99%以上验证集却停滞在97%。对新手来说从一个小模型开始确认它跑得通、收敛得动再逐步加宽度是更务实的思路。代码如下import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(in_channels1, out_channels32, kernel_size3, padding1) self.conv2 nn.Conv2d(in_channels32, out_channels64, kernel_size3, padding1) self.pool nn.MaxPool2d(kernel_size2, stride2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.dropout nn.Dropout(0.5) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x x.view(-1, 64 * 7 * 7) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x4.2 训练代码与基线结果训练代码同样不复杂。数据集用torchvision自带的MNIST注意下载过一次之后它会缓存在本地不用重复下载。DataLoader的batch_size我习惯设64shuffle必须设为True否则每个epoch里同一个类别的样本聚集在一起梯度的方向会周期性偏斜训练曲线看起来就没那么平滑。# MNIST数据集加载与标准化 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_data datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) val_data datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_data, batch_size64, shuffleTrue) val_loader DataLoader(val_data, batch_size64, shuffleFalse) def train(model, device, train_loader, val_loader, epochs5): model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(1, epochs 1): model.train() running_loss 0.0 correct 0 total 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) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() train_loss running_loss / total train_acc correct / total # 验证集评估 model.eval() val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) val_total labels.size(0) val_correct (predicted labels).sum().item() val_acc val_correct / val_total print(fEpoch {epoch}: train_loss{train_loss:.4f}, train_acc{train_acc:.4f}, val_acc{val_acc:.4f})设备如果没有GPU在CPU上跑这个模型也完全没压力。我用CPU跑5个epoch大概需要一分多钟最后一个epoch训练集的准确率能到99%左右验证集准确率稳定在98.5%以上。这个基线对MNIST来说是合理的——历史上第一个卷积网络LeNet-5也差不多是这个水平当时离深度学习爆发还有十几年。4.3 训练动态怎么读新手看到loss下降就高兴看到loss反弹就慌。其实这里有几个信号要分清楚训练集loss持续下降、验证集loss也跟着下降说明模型在正常学习如果训练集loss不断下降但验证集loss停滞甚至上升就是过拟合的信号该侧重正则化或者减小模型规模如果验证集loss一开始就乱跳训练loss也下不去多半是学习率太大或者数据预处理出了问题。用上面这份代码训练时你大概率会看到“Epoch 1: train_loss0.21, val_acc0.97Epoch 2: train_loss0.08, val_acc0.98Epoch 3以后波动会小很多”。到这一步你的第一个简单卷积网络就算真正跑通了。5. 训练一个简单CNN最容易踩的坑5.1 维度不匹配全连接层神经元数量算错这是新手遇到最多的报错等到代码跑到全连接层突然抛出size mismatch或者PyTorch直接告诉你mat1和mat2的形状对不上。原因几乎都是前两行卷积池化之后特征图的尺寸跟你写在nn.Linear里的输入维度不一致。比如你按我的结构写但把第二个池化层去掉以为特征图还是14x14FC1里却写了6477跑起来一定报错。要避免这个问题最笨也最有效的方法是在网络forward函数里临时加一行打印跑一个假样本看每一层的维度。def forward(self, x): print(input:, x.shape) x self.pool(F.relu(self.conv1(x))) print(after conv1pool:, x.shape) x self.pool(F.relu(self.conv2(x))) print(after conv2pool:, x.shape) ...跑一次把正确的维度填进fc1的输入里再把打印删掉。这个习惯看起来粗糙实际上很多做模型的工程师也会用比对着公式空想快得多。5.2 学习率太大loss变成NaNAdam优化器的默认学习率是1e-3对MNIST这个任务来说刚刚好。但我见过不少初学者把学习率调到1e-1训练几个batch之后loss直接变成nan。原因很简单梯度乘上一个大学习率权重更新幅度过大数值溢出后面怎么救都回不来。如果真的碰到了把学习率降到1e-3甚至1e-4重新初始化模型再训一次就行。学习率也不是越小越好太小的学习率会让loss下降得像蜗牛爬5个epoch下去验证集准确率还在90%附近打转。对于这类小规模任务1e-3就是一个很好的起点先按这个跑遇到不稳定再降。5.3 过拟合训练集99%验证集只有91%MNIST本身是个相对简单的数据集但这个现象依然会出现尤其是当网络宽度过大或者训练轮次过多时。训练集准确率高于验证集是正常的但如果差距超过3个百分点就得注意了。我的习惯是在两个地方加防护一是全连接层前面加Dropout(0.5)二是观察验证集准确率连续两三个epoch不再上升就用早停。Dropout的原理是每次前向传播随机让一半神经元失活等于每次训练都在“用不同的小网络投票”能显著降低对某几个神经元的依赖。早停则是最简单的正则化——验证集不再变好说明再训下去只是在记住训练集里的噪声。5.4 忘了归一化和shuffle这两个问题不报错但影响很隐蔽。忘了归一化图像像素范围是0到255经过第一层卷积后输出数值偏大ReLU之后负半轴大量置零整个信号可能被放大到激活函数饱和区域梯度既不稳定也不均匀。MNIST的灰度图还好如果是三通道RGB图三个通道之间的数值差异还会让网络偏向某个通道学出来的特征一开始就是歪的。DataLoader里shuffleFalse的话每个epoch内模型看到的顺序完全相同而且MNIST原始数据本身就是按类别排列的前几千张全是数字0后面全是数字1梯度会跟着类别分布来回摆动训练过程很颠簸。所以只要涉及到批量梯度下降训练集shuffle一定要开。5.5 一张问题排查速查表现象可能原因排查办法forward到全连接层报维度错误特征图尺寸计算错误在forward里打印每层输出shapeloss震荡不收敛学习率太大或太小尝试1e-3、1e-4观察曲线loss出现nan学习率过大导致梯度爆炸降低学习率、重新初始化训练集准确率高、验证集低过拟合加Dropout、减小模型、早停归一化忘记写数据尺度不统一加ToTensor和Normalize梯度方向不稳定DataLoader忘记shuffleshuffleTrue6. 特征图可视化把网络的“眼睛”打开看看6.1 用hook提取中间层输出模型训练好之后很多初学者不知道下一步该做什么觉得模型能跑、能分类就算完事了。其实还有一个特别值得做的练习把卷积层输出的特征图可视化亲眼看一看网络到底在图像上看到了什么。PyTorch里可以用前向传播的hook机制拿到中间层输出不用修改模型结构。代码很简单import matplotlib.pyplot as plt activations {} def hook_fn(name): def hook(model, input, output): activations[name] output.detach() return hook model.conv1.register_forward_hook(hook_fn(conv1)) model.conv2.register_forward_hook(hook_fn(conv2)) model.eval() sample, _ val_data[0] sample sample.unsqueeze(0) _ model(sample) conv1_feats activations[conv1] # (1, 32, 28, 28)因为样本集里第一张图通常是个数字5或者类似的样本你会在conv1的特征图上看到32张小图每一张都高亮原图的不同局部结构有的通道对竖线敏感有的对圆弧敏感有的对背景里的纹理敏感。这就是卷积核学到的“特征模板”在图像上的响应。6.2 浅层特征和深层特征的差异把conv1和conv2的特征图放在一起对比你会明显感到“抽象程度”的递进。conv1的特征图跟原图在空间上还保持很高的对应关系张张都能看出数字的轮廓到conv2特征图已经缩到7x7空间结构模糊了很多每个通道响应的是“某些局部模式组合是否出现在附近”更像是高级部件检测器。用代码把conv1的32个通道摊开fig, axes plt.subplots(4, 8, figsize(12, 6)) for i in range(32): ax axes[i // 8][i % 8] ax.imshow(conv1_feats[0, i], cmapgray) ax.axis(off) plt.tight_layout() plt.savefig(conv1_features.png, dpi150)这一张图胜过我写一百句解释。你会直观理解什么叫“卷积网络自动学习特征”——那些高亮的线条和轮廓不是谁设计出来的是网络在训练中自己摸索出来的。这个体验会直接影响你后面看网络结构的能力。可视化还有一个实际价值当模型在某个类别上识别效果特别差时把该类别样本的中间特征图拉出来看能快速发现是输入覆盖不足、特征被池化过度稀释还是某一层发生了神经元坏死。这比只盯着训练曲线瞎猜靠谱得多。7. 写在最后简单网络跑通之后下一步练什么先把这套简单卷积网络完整复现一遍MNIST跑到98%以上再谈别的。这不是敷衍而是它已经包含了CNN的所有核心组件卷积、池化、全连接、dropout、归一化、特征可视化。把这条链路吃透后面学习批量归一化BatchNorm、残差连接ResNet、乃至Transformer视觉模型都是在这些基本概念上做的加法。我个人在实际操作中的一个体会是不要一开始就模仿那些上百层的大网络。大网络在大数据集上确实强但在MNIST这种小任务上它学到的很多层根本发挥不出作用反而让初学者连“哪一层出了问题”都定位不了。先用一个小调通、可视化、理解深刻再逐步放大网络才是稳步提升的路。如果非要说点亲历的教训那就是遇到模型效果不好别急着加层数、加卷积核先去看训练曲线、看特征图、看输入输出是否匹配。大部分问题出在数据预处理、学习率设置和维度对不上而不是网络不够深。把简单网络调明白了深渊后面的坑你自然会越来越少。