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×224(ImageNet常见尺寸),展开是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×28(1个通道,高28,宽28)
- 第一个卷积层:
nn.Conv2d(1, 10, kernel_size=5),步长默认1,无填充。输出通道10,尺寸变为 (28-5)/1+1 = 24,得到 10×24×24 - 最大池化:
nn.MaxPool2d(2),尺寸减半,得到 10×12×12 - 第二个卷积层:
nn.Conv2d(10, 20, kernel_size=5),输入通道10,输出通道20,尺寸变为 (12-5)/1+1 = 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', train=True, download=True, transform=transform)。但那个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或128,train=True时shuffle设为True,train=False时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_size=5) self.conv2 = nn.Conv2d(10, 20, kernel_size=5) 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_size=5):第一个参数in_channels=1,因为MNIST是灰度图只有1个通道。第二个参数out_channels=10是卷积核的数量,也就是输出特征图的通道数,这个值决定了这一层提取多少种不同的特征。10个卷积核意味着检测10种不同的局部模式。kernel_size=5表示卷积核是5×5的窗口。为什么选5?这是MNIST任务里一个折中:数字笔画比较细,窗口太小(比如3×3)感受野不足,窗口太大(比如7×7)又容易把局部细节模糊掉。LeNet经典结构用的就是5。
nn.Conv2d(10, 20, kernel_size=5):第一层输出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(), lr=0.01, momentum=0.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(f'Epoch {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(), lr=0.01, momentum=0.5)。它没用Adam,因为MNIST足够简单,SGD+动量已经能轻松收敛到99%以上。但在实际项目中,如果是第一次跑一个新任务,我习惯先用Adam把baseline跑出来,然后再换SGD调优——Adam对学习率的敏感度低,不用怎么调就能收敛;SGD调好了上限更高,但需要更多调试。
epoch选多少?第9讲通常跑10个epoch左右。MNIST全量训练集有6万张图片,batch_size=64,一个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之间。这种情况下lr=0.01是安全的。但如果你用了lr=1或更大的值,梯度更新的步长太大,参数一下跳到“悬崖”上,梯度直接爆炸成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(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() total += target.size(0) print(f'Accuracy: {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(p=0.5),训练时随机屏蔽一半的神经元连接,强制网络学到更鲁棒的特征。这是最省事的方法,第9讲的模型加上Dropout后准确率还能再涨一点,同时泛化能力变好。
加数据增强。对图片做随机旋转(如±10度)、随机平移、随机缩放。MNIST虽然简单,但经过增强后训练数据的多样性提升,模型不容易死记硬背。PyTorch里用transforms.RandomRotation、transforms.RandomAffine就能实现。
加BatchNorm。BatchNorm层在每一层卷积之后做归一化,让每层的输入分布保持稳定,训练收敛更快,对初始化和学习率的敏感度也降低了。现代CNN几乎都有BatchNorm,第9讲的模型没有是因为它太简单了,用不上。
self.conv1 = nn.Conv2d(1, 10, kernel_size=5) self.bn1 = nn.BatchNorm2d(10) self.conv2 = nn.Conv2d(10, 20, kernel_size=5) 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变3,
nn.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讲吃透,比盲目往前赶进度,对长期学习的作用大得多。