资讯详情

资讯详情

PyTorch入门必看:CNN卷积神经网络实现手写数字识别

1. 第9讲到底讲了什么——先把这个“分水岭”看清楚如果你是从零开始学PyTorch前面几讲可能还算顺线性模型、梯度下降、反向传播、逻辑回归再到多维特征输入每讲都是搭积木改几行代码就能看到loss往下走。但到了第9讲“卷积神经网络”很多人第一次感觉“代码看不懂了”——又是Conv2d又是MaxPool2d输入从向量变成了四维张量打印shape都费劲。这一讲其实是整个PyTorch实践课程里真正的分水岭前面所有内容都在为“用全连接网络做简单分类”服务而从卷积神经网络开始才进入深度学习真正擅长的领域——图像。为什么图像任务用全连接不行CNN到底在做什么第9讲用MNIST手写数字识别这个小而完整的例子把这些问题全串起来了。这一讲的目标很明确用PyTorch搭建一个包含卷积层、池化层、全连接层的CNN模型在MNIST数据集上把手写数字识别的准确率跑到99%左右。任务不大但完整走通了“数据加载-模型定义-训练循环-评估”整个流程是后续学习ResNet、目标检测、语义分割这些进阶内容的地基。2. 卷积网络的核心机制——CNN凭什么比全连接层“会看图片”2.1 从全连接到卷积参数量是怎么暴减的先说一个MNIST任务上的直观对比。图片是28×28的灰度图展开成向量是784维。如果做手写数字10分类一个单隐藏层的全连接网络假设隐藏层128个神经元那第一层的参数量就是 784×128 128 ≈ 10万。这还只是单隐藏层如果图片变成224×224ImageNet常见尺寸展开是5万维第一层参数量直接奔着千万去了。问题是图片的空间结构被破坏了。像素之间的相邻关系、局部的纹理特征在“展平”这个动作里全丢了。全连接层看到的是一个“一长串数字”它不知道第100个像素和第101个像素是否挨着更不知道第200个像素是不是在第100个像素正下方。卷积层解决的就是这个问题。它用一个固定大小的窗口比如3×3或5×5在图像上滑动每次只看窗口内的局部区域提取局部特征。同样是处理MNIST一个经典的LeNet风格CNN卷积层池化层全连接层总参数量只有几万效果却远超全连接网络。这个对比解释了《动手学深度学习》和很多教材里反复强调的一句话卷积神经网络通过局部连接和权值共享大幅降低了模型复杂度同时保留了对图像空间结构的建模能力。你不需要记这句话只需要记住一个直觉卷积层是在“看局部”全连接层是在“看全局”而图像的特征恰恰是由局部组合出来的。2.2 卷积、池化、激活三个组件各管什么事一个基础的CNN块通常由三件事组成卷积提取特征激活函数引入非线性池化压缩尺寸。卷积层干的事就是用卷积核也称滤波器在输入上滑动计算局部区域和卷积核的点积得到一张特征图。每个卷积核负责检测一种特定的局部模式——比如横线、竖线、拐角、弧线。第一层学会的是边缘和纹理这种低级特征网络越深后面的层学会的特征越抽象比如数字的某个部件甚至整个数字的轮廓。MNIST虽然是灰度图但原理和彩色图完全一样只是输入通道数从3变成1。激活函数放在卷积之后常用ReLU。它的作用是引入非线性让网络有能力拟合复杂的映射关系。如果没有激活函数叠多少层卷积本质上还是线性变换网络的表达能力会被严重限制。这个在前几讲讲逻辑回归时已经提过第9讲只是把它放进了CNN的上下文里。池化层做的是下采样。最常见的是最大池化把2×2窗口里的最大值挑出来窗口步长为2于是特征图的宽高各缩一半。为什么要池化两个原因一是减少计算量特征图小了后续计算更省二是增强平移不变性——稍微平移几个像素池化后的结果变化不大这让模型对物体位置的微小偏移更鲁棒。2.3 特征图尺寸怎么算把公式推导一遍刚开始写PyTorch时最烦的就是定义全连接层时不知道in_features填多少。卷积层和池化层都不用管输入尺寸——PyTorch会自动推断但一旦要接全连接层就得手动算清楚。特征图尺寸公式很简单输出尺寸 (输入尺寸 - 卷积核尺寸 2×填充) / 步长 1第9讲最典型的MNIST CNN结构走一遍尺寸变化你以后遇到任何网络都能自己推了输入1×28×281个通道高28宽28第一个卷积层nn.Conv2d(1, 10, kernel_size5)步长默认1无填充。输出通道10尺寸变为 (28-5)/11 24得到 10×24×24最大池化nn.MaxPool2d(2)尺寸减半得到 10×12×12第二个卷积层nn.Conv2d(10, 20, kernel_size5)输入通道10输出通道20尺寸变为 (12-5)/11 8得到 20×8×8最大池化得到 20×4×4展平后接全连接层20×4×4 320所以nn.Linear(320, 10)320这个数就是这么来的。很多教程直接写了nn.Linear(320, 10)没告诉你为什么会是320。自己手推一遍之后再也不会忘了。MNIST输入尺寸是28×28两次“卷积池化”后变成4×4这是设计好的——28减两次4卷积核5砍掉4再除以两次2池化正好得到4。后续学ResNet时你会发现网络的stride、padding设计都有讲究但思路完全一样任何时候你都应该能口算出特征图尺寸否则模型定义会变成盲人摸象。3. 手写数字识别的完整实现——从数据加载到模型训练一卷到底3.1 数据加载ToTensor和Normalize到底做了什么第9讲最容易被忽略的其实是数据加载部分。PyTorch的torchvision封装好了MNIST数据集三行代码就能下载datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform)。但那个transform里的transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])每一行都值得细看。ToTensor()做了两件事一是把PIL图片或NumPy数组转成torch.Tensor二是把像素值从[0, 255]缩放到[0.0, 1.0]。注意顺序不能反——必须先转Tensor再Normalize因为Normalize是在浮点张量上做运算的。Normalize((0.1307,), (0.3081,))是标准化操作公式是(x - mean) / std。这里的0.1307和0.3081是MNIST整个数据集的像素均值和标准差。标准化之后数据分布接近标准正态分布均值为0方差为1这能让训练更稳定、收敛更快。为什么不直接除以255就行因为除以255只是把数据缩放到[0,1]但没有解决数据的分布形状问题。原始图片像素值的分布可能是偏斜的标准化把分布拉正了。实测下来对MNIST这种简单任务不标准化也能跑出不错的结果但遇到复杂数据集时标准化往往是模型能不能收敛的分水岭。数据加载还有一个参数容易被忽略DataLoader里的batch_size和shuffle。第9讲通常用64或128trainTrue时shuffle设为TruetrainFalse时shuffle设为False。shuffle的作用是打乱样本顺序避免模型记住数据排列的先后关系这点在训练集有顺序相关性时特别重要——比如数据集按标签排序如果不打乱模型会在batch内只看到同一类数字梯度的方向会被带偏。3.2 模型定义Conv2d和MaxPool2d的参数选型逻辑第9讲的CNN模型定义代码写出来是这个样子的import torch import torch.nn as nn import torch.nn.functional as F class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 nn.Conv2d(1, 10, kernel_size5) self.conv2 nn.Conv2d(10, 20, kernel_size5) self.pool nn.MaxPool2d(2) self.fc nn.Linear(320, 10) def forward(self, x): batch_size x.size(0) x F.relu(self.pool(self.conv1(x))) x F.relu(self.pool(self.conv2(x))) x x.view(batch_size, -1) x self.fc(x) return x逐个参数解释nn.Conv2d(1, 10, kernel_size5)第一个参数in_channels1因为MNIST是灰度图只有1个通道。第二个参数out_channels10是卷积核的数量也就是输出特征图的通道数这个值决定了这一层提取多少种不同的特征。10个卷积核意味着检测10种不同的局部模式。kernel_size5表示卷积核是5×5的窗口。为什么选5这是MNIST任务里一个折中数字笔画比较细窗口太小比如3×3感受野不足窗口太大比如7×7又容易把局部细节模糊掉。LeNet经典结构用的就是5。nn.Conv2d(10, 20, kernel_size5)第一层输出10个通道第二层输入必须匹配为10。第二层输出20个通道特征更丰富。这里可以看到一个设计惯例通道数逐层增多空间尺寸逐层减小。每层从空间上“压缩”信息从通道上“展开”信息这是CNN最常见的设计范式。nn.MaxPool2d(2)池化窗口大小2×2步长默认等于窗口大小。注意这里没有在__init__里写成self.pool nn.MaxPool2d(2)然后在forward里复用——这是常规写法因为池化层没有需要学习的参数复用一个实例就可以多次调用。nn.Linear(320, 10)320是展平后的特征维度。forward里卷积和池化之后特征图是20×4×4展平后喂给全连接层最后输出10维向量对应10个数字的得分。forward里的x.view(batch_size, -1)是展平操作-1让PyTorch自动推断维度变成二维张量(batch_size, 320)。这一步很多初学者容易写成x.view(-1, 320)也能跑但语义不对——前者保持了batch维度后者把batch和其他维度搞混了如果网络前向传播出错报错信息会非常难排查。3.3 训练循环交叉熵、SGD还是Adam、epoch选多少第9讲的训练代码核心部分长这样import torch.optim as optim model CNN() criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.5) def train(epoch): model.train() running_loss 0.0 for batch_idx, (data, target) in enumerate(train_loader): optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() running_loss loss.item() if batch_idx % 100 99: print(fEpoch {epoch}, Batch {batch_idx 1}, Loss: {running_loss / 100:.6f}) running_loss 0.0为什么用CrossEntropyLoss而不是均方误差MSE这个问题第9讲一定会遇到。MNIST是10分类问题模型的输出是10个实数交叉熵损失会把输出转成概率分布然后计算预测分布和真实标签分布的差异。如果用MSE模型会被迫去拟合“1的概率是0.8其他是0.1”这种数值而交叉熵只关心正确类别的概率是否足够高。分类任务用交叉熵几乎是铁律——梯度传播更稳定收敛更快。为什么用SGD带momentum而不是Adam第9讲的经典配置是optim.SGD(model.parameters(), lr0.01, momentum0.5)。它没用Adam因为MNIST足够简单SGD动量已经能轻松收敛到99%以上。但在实际项目中如果是第一次跑一个新任务我习惯先用Adam把baseline跑出来然后再换SGD调优——Adam对学习率的敏感度低不用怎么调就能收敛SGD调好了上限更高但需要更多调试。epoch选多少第9讲通常跑10个epoch左右。MNIST全量训练集有6万张图片batch_size64一个epoch大约是938个batch。10个epoch也就是9380次参数更新。这种规模在CPU上跑也就一两分钟GPU上更快。判断是否训练充分的标准很简单训练loss持续下降验证准确率不再上升就可以停了。4. 训练曲线里的“鬼故事”——loss不降、acc不动怎么排查4.1 loss炸成NaN先检查学习率而不是模型结构我自己第一次跑这个网络时踩过一个大坑前几个batch的loss还在往下走突然一下变成loss: nan后面全乱套了。第一反应是去查模型结构查了半天没发现问题后来才意识到是学习率的问题。MNIST数据集像素值经过ToTensor处理后范围是[0,1]交叉熵损失的量级通常在0.01到0.1之间。这种情况下lr0.01是安全的。但如果你用了lr1或更大的值梯度更新的步长太大参数一下跳到“悬崖”上梯度直接爆炸成NaN。排查步骤很简单把学习率降到1e-3重新跑。如果不炸了那就是学习率问题。如果还炸再用torch.isnan(loss).any()在训练循环里打印出loss为NaN时的数据索引看是不是数据集里有坏样本。从我的经验看90%的NaN问题都是学习率或者数据没归一化导致的。4.2 loss不降、准确率卡在10%网络根本没在学另一个常见现象loss在一个值附近横盘不动准确率长期在10%徘徊——MNIST有10个类别随机猜就是10%。这种情况一定不是“学习太慢”而是哪里断了。先检查数据有没有加载对打印几个batch的data.shape和target确认标签不是全0或全1。其次检查标签是否和模型输出的维度匹配——如果模型最后一层是nn.Linear(320, 10)而标签是0-9的整数CrossEntropyLoss会正确处理但如果标签恰好是1-10的整数模型永远学不对。还有一个隐蔽的问题model.train()和model.eval()的模式切换。第9讲的模型没有Dropout和BatchNorm不切换问题不大但养成这个习惯很重要。后续学ResNet时BatchNorm层在train和eval模式下的行为完全不同——eval模式下用的是训练阶段统计好的均值和方差如果不切换inference结果会莫名其妙地差。4.3 CPU训练太慢减少epoch和数据的工程技巧如果你是纯CPU环境跑第9讲的代码10个epoch大概也就几分钟完全能接受。但如果改了大模型或者更大的数据集CPU训练就是煎熬。第9讲之后我可以给出两个立竿见影的优化思路一是降低输入分辨率。MNIST本来就是28×28不用降。但如果你在做自己的实验把图片resize到更小的尺寸比如从224×224降到64×64训练时间可能缩短10倍以上准确率下降幅度通常不大。二是减少eval的频率。第9讲的代码里每个epoch都做一次验证如果验证集比较大这个开销很高。改成每5个epoch验证一次训练速度提升明显。三是用torch.no_grad()包裹验证代码。验证阶段不需要计算梯度这个上下文管理器能省不少内存和计算def evaluate(model, test_loader): model.eval() correct 0 total 0 with torch.no_grad(): for data, target in test_loader: output model(data) pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() total target.size(0) print(fAccuracy: {correct / total * 100:.2f}%)很多人会在这个阶段写出“先预测再和标签比较”的代码但忘了torch.no_grad()然后发现内存越跑越大最后OOM。这个坑特别典型。5. 从第9讲往后走——网络加深时绕不开的几件事5.1 为什么说第9讲是“第一个分水岭”第9讲之前你学的是机器学习基础第9讲之后你才真正走在“深度学习”的路上。这并不是夸张。第9讲让你第一次接触到“特征不是人手设计的而是网络自己学出来的”这个核心思想——前面用nn.Linear做分类时特征就是原始像素模型的自由度其实很低而CNN自己学习卷积核的权重相当于自动从数据中挖掘特征。这种方式后来被证明在图像、语音、文本等几乎所有领域都碾压手工特征。从学习路径上看第9讲后面通常紧跟的是用CNN实现CIFAR-10分类三通道彩色图、定义更深的网络VGG、ResNet的雏形、数据增强随机裁剪、翻转。这些内容都以第9讲的代码为起点做增量修改。5.2 把模型改深之后必须注意的几件事我在学完第9讲后很快就把同样的MNIST模型叠深了几层结果遇到了新问题训练集上loss降得很低但验证集准确率反而下降。这就是过拟合。MNIST只有6万张训练图片而加深后的CNN模型容量过大完全有本事背下所有训练样本。应对方法按优先级排序加Dropout。在卷积层和全连接层之间插入nn.Dropout(p0.5)训练时随机屏蔽一半的神经元连接强制网络学到更鲁棒的特征。这是最省事的方法第9讲的模型加上Dropout后准确率还能再涨一点同时泛化能力变好。加数据增强。对图片做随机旋转如±10度、随机平移、随机缩放。MNIST虽然简单但经过增强后训练数据的多样性提升模型不容易死记硬背。PyTorch里用transforms.RandomRotation、transforms.RandomAffine就能实现。加BatchNorm。BatchNorm层在每一层卷积之后做归一化让每层的输入分布保持稳定训练收敛更快对初始化和学习率的敏感度也降低了。现代CNN几乎都有BatchNorm第9讲的模型没有是因为它太简单了用不上。self.conv1 nn.Conv2d(1, 10, kernel_size5) self.bn1 nn.BatchNorm2d(10) self.conv2 nn.Conv2d(10, 20, kernel_size5) self.bn2 nn.BatchNorm2d(20)注意BatchNorm的输入通道必须和上一个卷积层的输出通道一致nn.BatchNorm2d(20)对应第二层卷积的20个输出通道。5.3 进阶方向从MNIST到CIFAR-10学完第9讲一个自然的挑战是把模型从MNIST换到CIFAR-10。CIFAR-10的图片是32×32的彩色图3个通道类别是飞机、汽车、鸟等。这个切换会暴露第9讲没有涉及的问题输入通道从1变3nn.Conv2d(3, ...)。彩色图的信息量更大网络需要更宽或更深。数据增强变得更重要——CIFAR-10只有5万张训练图不做增强很容易过拟合。归一化的均值和标准差不再是0.1307和0.3081而是三个通道分别计算要换成类似(0.4914, 0.4822, 0.4465)和(0.2023, 0.1994, 0.2010)这样的值。实验下来把第9讲的模型直接搬到CIFAR-10上准确率大约在60%左右随机猜测是10%。这说明模型已经学了一些东西但远不足以区分“猫”和“狗”这种高相似类别。这时你会感受到第9讲作为“引路人”的意义——它把CNN的最基本流程跑通了但真正的挑战是后续不断加深和优化网络结构。6. 我个人跑通第9讲后的经验复盘——直接照抄的笔记最后把我的实操经验整理成几条笔记这些内容教材上通常不会写但会帮你少走弯路。关于代码复现。不要直接抄别人的模型再改参数——先把代码原样跑通再一行一行思考“为什么这里要用5×5卷积”“为什么池化用2”。跑通一遍的时间不超过10分钟但这个完整流程的体验是看任何教程都替代不了的。关于可视化。训练结束后我很建议打印一下第一层卷积核的权重视觉化结果。用model.conv1.weight.data取出权重可以看到训练出的卷积核长什么样——有的像横线检测器有的像边缘检测器。这一步会让你对“卷积核在学东西”有非常直观的感知。关于坑和容错。如果你在运行中发现loss下降得特别慢先别急着调网络结构。把batch_size调小到32或16往往能提速把学习率从0.01调大到0.05往往能打破局部最小值。实际项目中训练不收敛时最先该调的三个参数是batch_size、学习率、数据归一化方式而不是网络层数。关于后续扩展。第9讲跑完后我建议你立刻做三件事把同样的代码改到Fashion-MNIST上换个数据集代码几乎不用改把模型加深加宽并加Dropout把训练结果和测试集准确率记录成表格。这三件事做完你对CNN的掌握程度会远超只写完第9讲作业的水平。最后提一个很多人忽略的点第9讲虽然名字叫“深度学习实践”但它教的不是某个高深算法而是一套完整的实验流程——定义问题、准备数据、建立模型、训练调参、评估迭代。这套流程会在你之后所有的深度学习项目里反复出现。把第9讲吃透比盲目往前赶进度对长期学习的作用大得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →