CNN 入门代码常常“看起来都对”,真正运行时却在全连接层报形状错误,或者训练半天只得到一条没有解释力的准确率。本文用一次形状实验把卷积、池化、通道和分类头串起来:先在纸面计算,再用断言验证,最后构造一个可替换输入尺寸的小模型。重点不是背公式,而是建立一套遇到新网络也能自行检查的推导方法。
先把目标改成可验证问题
很多 CNN 教程从Conv2d开始,十分钟后就跳到训练曲线。中间最关键的事情却被一笔带过:一张28×28的图片经过卷积和池化,究竟变成了什么?如果这个问题靠猜,模型越深,报错就越像拆盲盒。
这次不先追求准确率。我们把实验目标改成一句更可验证的话:给定输入形状,能够在每一层之前说出输出形状,并让代码中的断言证明推导一致。
实验一:先读懂四个维度
PyTorch 图像批次通常采用NCHW:
N:批大小,一次送入多少张图;C:通道数,灰度图通常是 1,RGB 图通常是 3;H:高度;W:宽度。
MNIST 的一个 32 张图片批次是[32, 1, 28, 28],不是[28, 28, 32]。卷积层的in_channels必须等于输入的C,而不是图片宽度。
二维卷积在单个空间维度上的输出公式是:
out = floor((in + 2 * padding - dilation * (kernel - 1) - 1) / stride + 1)当卷积核为 3、步长为 1、填充为 1 时,28仍是28;紧接一个核为 2、步长为 2 的池化层,空间尺寸减半为14。
先用纯 Python 把公式变成可执行检查:
frommathimportfloordefout_size(size,kernel,stride=1,padding=0,dilation=1):returnfloor((size+2*padding-dilation*(kernel-1)-1)/stride+1)height=out_size(28,kernel=3,stride=1,padding=1)height=out_size(height,kernel=2,stride=2)assertheight==14height=out_size(height,kernel=3,stride=1,padding=1)height=out_size(height,kernel=2,stride=2)assertheight==7flatten_features=32*height*heightassertflatten_features==1568print(flatten_features)两组“同尺寸卷积 + 二倍池化”后,空间尺寸从28变成7,若最后通道数是 32,展平长度就是32×7×7=1568。这正是分类头第一层需要的输入数。
实验二:让模型自己报告形状
下面是一个用于 MNIST 的小型 CNN。它不追求排行榜成绩,重点是结构清楚、输入输出可检查。
importtorchfromtorchimportnnclassShapeFirstCNN(nn.Module):def__init__(self,classes=10):super().__init__()self.features=nn.Sequential(nn.Conv2d(1,16,kernel_size=3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Conv2d(16,32,kernel_size=3,padding=1),nn.ReLU(),nn.MaxPool2d(2),)self.classifier=nn.Sequential(nn.Flatten(),nn.Linear(32*7*7,64),nn.ReLU(),nn.Dropout(0.2),nn.Linear(64,classes),)defforward(self,x):x=self.features(x)returnself.classifier(x)model=ShapeFirstCNN()fake_batch=torch.randn(8,1,28,28)logits=model(fake_batch)assertlogits.shape==(8,10)print(logits.shape)这里输出的是 logits,不要在模型最后手动加Softmax再交给CrossEntropyLoss。该损失函数期望未归一化分数,并在内部完成数值更稳定的LogSoftmax与负对数似然计算。推理展示概率时,再使用logits.softmax(dim=1)。
如果环境尚未安装 PyTorch,仍可先运行上一段纯 Python 形状测试;本段需要与系统和加速设备匹配的 PyTorch 2.x 环境。不要把“代码能导入”误写成“模型已完成训练”。
实验三:消灭写死的 1568
一旦输入从28×28换成32×32,写死的Linear(1568, 64)就会报错。可以选择自适应池化,让分类头收到固定尺寸:
classFlexibleCNN(nn.Module):def__init__(self,in_channels=1,classes=10):super().__init__()self.features=nn.Sequential(nn.Conv2d(in_channels,16,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Conv2d(16,32,3,padding=1),nn.ReLU(),nn.AdaptiveAvgPool2d((4,4)),)self.head=nn.Sequential(nn.Flatten(),nn.Linear(32*4*4,classes),)defforward(self,x):returnself.head(self.features(x))model=FlexibleCNN()assertmodel(torch.randn(2,1,28,28)).shape==(2,10)assertmodel(torch.randn(2,1,32,32)).shape==(2,10)自适应池化不是“永远更好”,它只是明确了一份接口契约:无论前面的空间尺寸是多少,分类头看到的都是4×4。如果任务需要保留精细位置,例如分割或关键点检测,就不能随意压缩空间信息。
实验四:卷积学到的不是“整张猫”,而是局部模式
一个3×3卷积核每次只观察九个位置,同一组权重会在整张图上滑动。这带来两个关键性质:局部连接减少参数,共享权重让同一种边缘或纹理可以在不同位置被识别。第一层往往响应方向、明暗变化等简单模式;多层叠加后,后层才可能组合出更大的结构。
感受野描述一个输出位置能“看到”多大的原图区域。连续两个步长为 1 的3×3卷积,感受野会从 3 扩展到 5,而不是简单得到 6。池化或步长卷积还会增大相邻输出点在原图上的间隔。网络很深却识别不了大结构时,不只要看参数量,也要看有效感受野是否覆盖目标。
卷积参数量可以直接计算。输入通道为 16、输出通道为 32、卷积核为3×3时,权重数量是32×16×3×3,若启用偏置再加 32。它与图片是28×28还是256×256无关;分辨率主要影响计算量和中间特征图内存。
defconv_parameters(in_channels,out_channels,kernel,bias=True):weights=out_channels*in_channels*kernel*kernelreturnweights+(out_channelsifbiaselse0)assertconv_parameters(16,32,3)==4640这个简单计算能识别另一个常见误区:把全连接层替换成卷积层后,参数可能明显减少,但特征图很大时,训练内存和计算时间仍然可能很高。
实验五:先尝试过拟合一个小批次
正式训练前,从训练集中固定抽取 16 或 32 张样本,关闭随机增强,让模型反复训练同一小批数据。一个有足够容量、训练循环正确的模型,通常应该能把这批样本的损失压得很低。若做不到,优先检查标签、损失输入、优化器参数、梯度是否清零,而不是立刻增加层数。
这个测试不是为了获得泛化能力,恰恰是在有意制造过拟合。它像电路通电前的导通测试:证明数据确实流经模型并能改变参数。通过之后再恢复数据增强、训练/验证划分和正则化。
还应固定随机种子并记录软件版本,但要理解“固定种子”不等于所有硬件上完全逐位复现。某些 GPU 算法、并行数据加载和第三方库仍可能引入非确定性。实验记录至少包含数据版本、划分方式、模型提交号、超参数、设备和主要依赖版本,才能解释两条曲线为什么不同。
输入归一化也属于模型契约。训练集使用的均值和标准差必须原样用于验证、测试和部署,不能为每张待预测图片临时计算一套。灰度图扩成三通道、RGB 顺序变成 BGR、像素范围从0-1变为0-255,都会让形状看似正确而语义完全改变。因此测试不仅要断言张量尺寸,还应检查数据类型、数值范围、通道顺序和标签取值范围。
从一条准确率升级为可解释评估
假设 100 张图片中有 90 张数字 0、10 张数字 1。模型全部猜 0,也有 90% 准确率,却完全认不出数字 1。因此分类实验至少同时查看混淆矩阵,以及每一类的精确率、召回率和 F1。
对某一类别:
precision = TP / (TP + FP) recall = TP / (TP + FN) F1 = 2 * precision * recall / (precision + recall)分母可能为零,计算时必须设定处理策略。多分类任务还要说明使用宏平均、微平均还是按样本数加权平均;只写一个“F1=0.92”并不完整。
训练、验证和测试集也要分工明确:训练集更新参数;验证集选择结构和超参数;测试集只用于最终评估。如果反复根据测试集调整模型,测试集就被间接“训练”了。
一个不会偷看测试集的训练骨架
defrun_epoch(model,loader,loss_fn,optimizer=None,device="cpu"):training=optimizerisnotNonemodel.train(training)total_loss=0.0total_correct=0total_samples=0context=torch.enable_grad()iftrainingelsetorch.no_grad()withcontext:forimages,labelsinloader:images,labels=images.to(device),labels.to(device)logits=model(images)loss=loss_fn(logits,labels)iftraining:optimizer.zero_grad()loss.backward()optimizer.step()batch=labels.size(0)total_loss+=loss.item()*batch total_correct+=(logits.argmax(1)==labels).sum().item()total_samples+=batchreturn{"loss":total_loss/total_samples,"accuracy":total_correct/total_samples,}损失累加时乘以当前批大小,最后除以真实样本数,可以正确处理最后一个不足整批的批次。验证时不创建梯度,既节省内存,也避免误更新参数。
常见错误观察表
| 现象 | 优先检查 |
|---|---|
mat1 and mat2 shapes cannot be multiplied | 池化后的C×H×W与Linear输入是否一致 |
| 第一层就提示通道错误 | 数据是灰度还是 RGB,是否错误排列 NHWC/NCHW |
| 损失几乎不下降 | 标签范围、学习率、输入归一化、参数是否加入优化器 |
| 训练准确率高、验证低 | 过拟合、数据泄漏、增强差异、划分是否合理 |
| 指标异常漂亮 | 测试集是否参与调参、类别是否严重不均衡 |
| GPU 报内存不足 | 批大小、图片分辨率、中间特征图、是否忘记关闭验证梯度 |
验证顺序
正式训练前按固定顺序做五次检查:单样本形状、单批前向、损失可计算、一个批次反向传播、极小数据集能否过拟合。最后一项很实用:如果模型连几十个样本都记不住,通常是数据、标签或训练循环有错误,而不是“模型还不够大”。
总结
CNN 不是一串神秘层名,而是一条形状不断变化的数据管道。先写出NCHW,逐层计算空间尺寸和通道数,用假数据断言接口,再开始训练;评价时从单一准确率扩展到类别级指标。掌握这种形状优先的实验方式后,换数据集、加残差块或迁移到更深网络,仍然有一条可重复的检查路径。
先验证,再训练。