
做图像相关的活儿绕不开卷积神经网络Convolutional Neural Networks, CNNs。我第一次真正把它跑通不是什么高大上的场景就是一个小工厂的质检需求在一堆金属零件的显微图里挑出有划痕的那几个。当时我先用传统方法做了一遍手工设计边缘算子、纹理特征再丢给 SVM调了两周准确率卡在 88% 上不去换个批次的样品就掉到 80% 以下。后来换成三层卷积的 CNNs标注了 4000 张图一天之内验证集就过了 96%换批次也没崩。那次之后我就认了一件事只要数据是网格状的、局部特征有重复出现的规律卷积结构就是省事的那条路。这篇文章我打算按自己这几年做项目的顺序来写。先把 CNN 到底解决了什么问题讲透再把卷积层、池化层、归一化、全连接这些零件一个个拆开算账然后给一份能直接复制运行的 mini LeNet-5 代码最后把踩过的坑和排查表整理出来。适合刚入门想搞懂结构图背后在算什么的同学也适合已经会调库、但一到改结构、算参数量、估显存就心虚的工程师。全文尽量用我自己的实测数据说话不堆公式堆到你看不下去。1. 卷积神经网络到底解决了什么问题1.1 全连接层用在图像上的两个死穴很多人学 CNN 是从卷积层能提取特征这句话开始的但这句话太虚。我更愿意从反面讲如果不用卷积直接用全连接层处理图像会发生什么第一件事是参数爆炸。假设你有一张 224×224 的彩色图展平之后是 224×224×3 150,528 个输入。第一个隐藏层只放 64 个神经元参数量就是 150528×64 64 ≈ 963 万。这还只是第一层而且 64 个神经元对图像来说少得可怜。VGG16 的参数量是 1.38 亿其中绝大部分就压在全连接层上。参数一多训练要的数据就多显存也扛不住。第二件事更致命展平这个动作把空间结构直接销毁了。图像里左上角有一个角点和右下角有一个角点本来是强相关信息可一旦拉成一维向量第 3 个元素和第 150000 个元素之间的关系网络得靠数据自己重新学出来。更要命的是同一只猫出现在图片左边和右边展平后对应的输入下标完全不同网络会把它们当成两件毫不相干的事情去学。这就是为什么当年全连接网络做图像数据量稍微少一点就疯狂过拟合。1.2 局部连接加权值共享把参数量砍掉三个数量级卷积给出的解法很朴素就两条假设。第一条叫局部连接图像里决定一个像素是不是边缘的通常只有它周围一圈邻居不需要看整张图。所以一个 3×3 的卷积核只看 9 个像素而不是 150,528 个。第二条叫权值共享一只竖边缘检测器在图片左上角管用在右下角同样管用那就不必为每个位置单独学一套参数同一组权重滑遍全图就行。这两条假设加起来参数量掉得非常夸张。我拿具体数字算给你看同样是 3 通道输入到 64 通道用 3×3 卷积参数量是 3×3×3×64 64 1792用全连接是 150528×64 64 ≈ 963 万。差了大约 5300 倍。这不是优化这是换了一种问题的建模方式。我在实际项目里最直观的感受是数据效率。同一个划痕检测任务全连接版本要 2 万张标注图才勉强收敛换成卷积结构之后 4000 张就够。原因就是那两个先验假设本来就成立——划痕的判定依据确实是局部纹理也确实不该跟位置绑定。1.3 平移等变性与那点不彻底的不变性卷积还有一个比较好听的性质平移等变性translation equivariance。意思是输入往右移一格输出的特征图也跟着往右移一格特征本身不变。再叠加池化带来的局部聚合网络就获得了对小幅平移的鲁棒性——猫稍微挪一下位置分类结果不该变。但这里有个很多人忽略的细节CNN 的不变性并不彻底。卷积层本身是平移等变的可一旦接上全连接层尤其是把特征图展平位置敏感性又回来了。而且边缘位置和中心位置的处理方式也不对称卷积核在图像边界会滑出去靠 padding 补零来凑这就带来了边界效应。我在做小目标检测时遇到过这个问题同一个目标出现在图像正中央时置信度 0.92挪到贴近边缘就掉到 0.71。后来靠随机裁剪增强、以及改用全局平均池化替代展平才把这个偏差压下去。所以别把CNN 天生平移不变当教条。它提供的是一个很好的归纳偏置不是一道免死金牌。1.4 什么情况下我会放弃 CNN经验告诉我工具用错地方比调参调不好更浪费时间。下面这几种情况我基本不会优先考虑卷积结构。表格型数据几百个字段的结构化数据字段之间没有空间邻接关系树模型GBDT 那一类通常更省事调参成本也低。图结构数据社交网络、分子结构、推荐里的用户-商品二部图邻接关系不规则用图神经网络更自然。长序列文本卷积能处理文本TextCNN 就是经典做法但当依赖跨度很长时注意力机制的建模能力更直接。算力极度受限的场景比如只有几十 KB 内存的单片机如果传统特征加轻量分类器能到 95%硬塞一个 CNN 进去往往得不偿失。判断标准其实就一句话你的数据里局部模式和位置无关的重复性是否存在。存在CNN 就值得上不存在先想别的。2. 卷积神经网络基本结构逐层拆解2.1 卷积层那只核到底在算什么先破一个概念上的小坑深度学习框架里说的卷积严格讲是互相关cross-correlation它没有把核翻转 180 度。真正的数学卷积会翻转。但对可学习参数来说翻转不翻转只是把权重换个索引训练之后结果完全等价所以框架统一省掉了这一步。面试被问到别答错。一次卷积运算的实质就是在输入的一个小窗口里做加权求和再加一个偏置得到一个输出值。核在输入上按步长滑动滑完一圈就得到一张输出特征图。多个核并行处理同一块输入就得到多张特征图每张负责捕捉一种模式。那这些核到底学到了什么我在小数据集上把卷积核可视化出来看过第一层基本都是 Gabor 状的条纹、色块边界、简单的角点第二层组合出圆弧、交叉点、纹理块再往深层单个通道已经很难用人眼解释了但消融实验能证明它们对应着眼睛轮子文字段落这类语义部件。这个从底层纹理到高层语义的层次化就是深度卷积网络真正的价值所在。2.2 输出尺寸、感受野、参数量三个必须算得清的账这三笔账我见过太多人算错直接导致改结构的时候凭感觉试效率极低。先把公式摆出来。卷积输出空间尺寸H_out floor((H_in 2P - K) / S) 1 W_out floor((W_in 2P - K) / S) 1其中H_in/W_in是输入高宽P是 paddingK是卷积核尺寸S是步长。通道维度则由你设定的输出通道数直接决定跟上面这个公式无关。感受野receptive field的递推算法RF_out RF_in (K - 1) * J_in J_out J_in * S初始时RF 1J 1。我拿一个常见的四层堆叠演示一遍conv3×3 stride1 → conv3×3 stride1 → pool2×2 stride2 → conv3×3 stride1。层操作计算过程感受野 RF跳跃 J0输入初始值111conv3×3 s11 (3-1)×1312conv3×3 s13 (3-1)×1513pool2×2 s25 (2-1)×1624conv3×3 s16 (3-1)×2102结果就是最后一层每个神经元能看到原图 10×10 的区域。这个账在你设计检测头、判断目标尺寸上限的时候特别有用如果你要检测的目标在原图上是 12×12 像素而你的特征图感受野只有 8×8那不管怎么训练都学不好。参数量公式Params K_h * K_w * C_in * C_out C_out后面的 C_out是偏置项如果开了biasFalse就去掉。计算量MACs乘加次数的估算MACs H_out * W_out * C_out * K_h * K_w * C_in顺便说一个经典结论面试里经常问两个 3×3 卷积堆叠感受野等效于一个 5×5 卷积但参数更少、非线性更强。参数量对比是 2×3×3 18 对 5×5 25三个 3×3 感受野等效 7×7参数 27 对 49。这就是 VGG 之后大家集体转向 3×3 小核的原因——不是 3×3 更好看是同样的感受野下它更便宜而且中间多插了一层 ReLU表达能力更强。2.3 池化层的取舍以及它正在被谁替代池化层做的事情很直白在局部窗口里取最大值或平均值把空间分辨率降下来。它的收益有四个——特征图变小、后续计算量下降、感受野扩大、对局部微小位移更鲁棒。最大池化和平均池化怎么选我的习惯是中间层用最大池化最后一层用全局平均池化。最大池化保留的是这个区域里最强的响应对纹理和边缘类特征更友好平均池化会把响应抹平容易削弱弱信号。但到了网络末端全局平均池化GAP直接把每张特征图压成一个数去掉了展平那一步参数量从百万级直接掉到零还能缓解过拟合。ResNet 之后的结构基本都这么干。现在还有一个趋势是用步长 2 的卷积替代池化。理由是池化是固定的、不可学的而卷积可以在降采样的同时学一个变换。缺点是计算量更大、参数量更多。我自己的取舍是算力宽松、追求精度时用 stride 卷积算力紧张、或者数据量不大怕过拟合时老老实实用 max pooling。这两个方案我在类似任务上做过对比验证集精度差距通常在 0.3 到 0.8 个百分点之间但后者训练速度快 20% 左右。顺带提一句空间金字塔池化SPP那类设计当你的下游任务需要固定尺寸输出而输入图片尺寸又忽大忽小时SPP 能让你不用强制缩放输入。做检测和分割的时候很实用。2.4 批归一化与激活函数的搭配顺序现代卷积块的标配顺序是Conv → BN → ReLU我基本不在这个顺序上做花样。为什么 BN 放在激活前面因为 BN 归一化的是卷积输出的分布让进入激活函数的输入均值接近 0、方差接近 1这样梯度传播更稳允许用更大的学习率。如果你把 BN 放到 ReLU 后面归一化的对象变成了激活后的稀疏输出效果会打折。BN 有一个非常容易踩的坑训练和推理的行为不一样。训练时 BN 用当前 batch 的均值和方差同时用滑动平均更新running_mean和running_var推理时用滑动平均里存的统计量。所以如果你忘了model.eval()就去做推理同一张图每次预测结果都会飘。我早期被这个问题坑过整整一个下午明明训练曲线漂亮得不行部署之后精度掉了七八个点最后发现就是少了一行eval()。还有一个情况要注意batch size 太小时 BN 会不稳。batch 小到 4 或 8 的时候batch 统计量本身噪声就大训练会抖。这时候我会换成 GroupNorm 或 LayerNorm或者干脆上梯度累积把等效 batch 撑起来。激活函数方面隐藏层就用 ReLU 或它的变体LeakyReLU、GELU。ReLU 的问题是负半轴梯度为零如果学习率给大了出现死亡神经元一片输出全变 0 再也救不回来。这种情况换成 LeakyReLU负半轴斜率 0.01通常就好了。输出层则不要加激活原因下一节讲。2.5 全连接层、Dropout 与输出层卷积部分的输出是一堆三维张量要拿去做分类就得转成向量。这一步叫展平flatten也是很多形状报错的发源地。展平之后的维度是C × H × W你必须自己把它算准。全连接层的作用是把哪里有什么特征整合成这是个什么东西的判断。它参数量大也最容易过拟合所以我通常会在全连接层之间加 Dropoutp0.5是个稳妥的起点。卷积层内部我个人很少用 Dropout因为卷积层参数共享本身就带着正则效果硬加反而拖慢收敛。输出层和损失函数的搭配是新手最容易搞错的地方我列个表。任务类型输出层损失函数常见错误多分类直接输出类别数个 logitsnn.CrossEntropyLoss在输出层又加了 softmax导致重复归一化loss 不降二分类输出 1 个 logitnn.BCEWithLogitsLoss又加了 sigmoid同样的重复问题多标签输出标签数个 logitsnn.BCEWithLogitsLoss错用 CrossEntropy类别之间被强行互斥回归直接输出nn.MSELoss/SmoothL1Loss输出层加了激活把值域限死记住一句话PyTorch 里带 WithLogits 的损失函数都自带激活输出层保持裸的线性就行。3. 卷积神经网络结构图怎么画才不糊3.1 用张量流水账替代漂亮但没用的图网上那些结构图画得漂漂亮亮一块块彩色方框连着箭头但你要照着它写代码八成要出错。原因很简单它们几乎不写通道数、不写步长、不写 padding也经常把 BN 层省略掉。我现在的习惯是不画图先列一张流水账表格。每一行写清楚层的名字、输入形状、输出形状、参数量、备注。表列完了代码基本也就写完了而且能立刻检查出维度是否对得上。表头长这样层名输入形状 (N,C,H,W)输出形状 (N,C,H,W)参数量备注为什么这个方法比画图靠谱因为形状是硬约束写错了代码直接报错而图画错了肉眼完全看不出来。我以前接过一份别人交接的网络定义结构图上是四层卷积代码里其实只有三层图上多画了一层——因为图是手绘的没人对过账。用表格就不可能出现这种问题。3.2 LeNet-5 结构图逐层复盘LeNet-5 是最适合拿来练手的结构因为它足够小每一层的账都能心算。原版论文里的输入是 32×32这个尺寸不是随便定的是为了让 5×5 卷积之后还能剩下足够大的特征图。下面是我按原版对齐的逐层流水账。层名类型核/步长输出形状 (C,H,W)参数量Input输入-1, 32, 320C1Conv5×5, s1, p06, 28, 28156S2AvgPool2×2, s26, 14, 140C3Conv5×5, s1, p016, 10, 102416S4AvgPool2×2, s216, 5, 50C5Conv5×5, s1, p0120, 1, 148120F6FC-8410164OutputFC-10850参数量我逐个算一遍你可以对照检查。C1 是 5×5×1×6 6 156C3 是 5×5×6×16 16 2416C5 是 5×5×16×120 120 48120F6 是 120×84 84 10164输出层是 84×10 10 850。合计 61,706 个参数加上 BN 层如果加了大约是 61.75k。这里有个细节值得说C5 这一层名字叫卷积实际上因为输入已经是 1×1 的空间尺寸它跟全连接等价参数量也是这么算的。原版 LeNet 用的激活是 tanh池化是平均池化还带可学习的缩放系数。今天的实现里我一般换成 ReLU 加最大池化收敛快很多。这不是偏离原版而是把那个年代受限于算力和激活函数研究的妥协替换掉。还有一点MNIST 的原图是 28×28要套进这个结构得先 pad 到 32×32。这一步经常被忽略导致 C3 的输出算出来是 8×8 而不是 10×10然后全连接层维度对不上报错信息还很难看懂。3.3 3D 卷积神经网络多出来的那个维度加在哪2D 卷积的输入是(N, C, H, W)3D 卷积的输入多一个维度变成(N, C, D, H, W)卷积核也从(k_h, k_w)变成(k_d, k_h, k_w)。输出尺寸的计算方式完全一样只是每个空间维度各算一遍D_out floor((D 2*P_d - k_d) / S_d) 1 H_out floor((H 2*P_h - k_h) / S_h) 1 W_out floor((W 2*P_w - k_w) / S_w) 1参数量变成k_d × k_h × k_w × C_in × C_out C_out。举个实际例子输入 3 通道用 3×3×3 的核输出 64 通道参数量是 3×3×3×3×64 64 5248。同等条件下 2D 卷积只要 3×3×3×64 64 1792差了整整 3 倍也就是k_d倍。计算量同理大致也是 3 倍。那多出来的这一维通常代表什么我做过三类场景视频D 是时间帧数比如 16 帧。核在时间维滑动就能捕捉挥手开门这类带运动轨迹的模式。医学体数据D 是 CT 或 MRI 的切片层数一层层叠起来形成三维体积病灶在层与层之间是连续的2D 卷积会把切片之间的联系切断。点云体素化把稀疏点云栅格化成小立方体再用 3D 卷积处理。显存是这里最大的敌人。输入尺寸从 32×32 变成 16×32×32中间特征图的元素数量直接翻 16 倍反传时的激活值全都要留着显存占用跟着涨。我吃过一次亏2D 版本在 8G 显存上跑得好好的换成 3D 之后 batch size 从 32 一路降到 2 才勉强跑起来训练速度还慢了一个数量级。有两个缓解方案我常用。一是时间维下采样要克制不要在第一个卷积就用 stride 3 把 16 帧压成 5 帧时序信息基本就丢了一般先用 stride 1 在时间维上滑两三层再说。二是用 (21)D 分解卷积把 3D 卷积拆成一个 2D 空间卷积加一个 1D 时间卷积参数量和计算量都大幅下降精度损失在多数任务上可以接受。4. 从零手写一个能跑的 mini LeNet-5理论讲完了下面直接上代码。这套代码我在一台没有 GPU 的笔记本上都能跑完MNIST 十个 epoch 大概三四分钟。4.1 环境、数据与预处理pip install torch torchvision数据部分要注意那个 padding。MNIST 原图 28×28LeNet-5 需要 32×32所以用transforms.Pad(2)在四周各补 2 像素。归一化用的 0.1307 和 0.3081 是 MNIST 训练集的全局均值和标准差直接抄就行不用自己算。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集先补边到 32x32再转张量再按 MNIST 全局统计量归一化 train_tf transforms.Compose([ transforms.Pad(2), # 28x28 - 32x32 transforms.RandomAffine(degrees10, translate(0.08, 0.08)), # 小幅增强 transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) # 验证集不做随机增强保证评估可复现 val_tf transforms.Compose([ transforms.Pad(2), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) train_set datasets.MNIST(root./data, trainTrue, downloadTrue, transformtrain_tf) val_set datasets.MNIST(root./data, trainFalse, downloadTrue, transformval_tf) train_loader DataLoader(train_set, batch_size128, shuffleTrue, num_workers2, pin_memoryTrue) val_loader DataLoader(val_set, batch_size256, shuffleFalse, num_workers2, pin_memoryTrue)注意数据增强这一步别照搬图像分类的常规做法。手写数字、字符、医学影像这类数据水平翻转会直接改变语义数字 2 翻过来就不是 2 了所以我这里用的是小角度旋转加平移边界控制在 10 度、8% 以内。这个度数是试出来的再往上加验证集精度反而掉因为增强出来的样本已经不是真实数据分布了。4.2 网络定义import torch.nn as nn import torch.nn.functional as F class MiniLeNet(nn.Module): def __init__(self, num_classes10): super().__init__() # C1: 1x32x32 - 6x28x28 self.conv1 nn.Conv2d(1, 6, kernel_size5, stride1, padding0, biasFalse) self.bn1 nn.BatchNorm2d(6) # C3: 6x14x14 - 16x10x10 self.conv2 nn.Conv2d(6, 16, kernel_size5, stride1, padding0, biasFalse) self.bn2 nn.BatchNorm2d(16) self.pool nn.MaxPool2d(kernel_size2, stride2) # C5 与全连接等价16x5x5 - 120 self.fc1 nn.Linear(16 * 5 * 5, 120) self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, num_classes) self.drop nn.Dropout(p0.3) def forward(self, x): x self.pool(F.relu(self.bn1(self.conv1(x)))) # - 6x14x14 x self.pool(F.relu(self.bn2(self.conv2(x)))) # - 16x5x5 x torch.flatten(x, 1) # - 400 x F.relu(self.fc1(x)) x self.drop(x) x F.relu(self.fc2(x)) return self.fc3(x) # 裸 logits交给 CrossEntropyLoss几个我特意做的选择解释一下。biasFalse是因为后面紧接 BNBN 自带一个可学习的偏置beta卷积再带偏置就是冗余参数关掉能省一点点也让 BN 的归一化更干净。Dropout只放在全连接部分而且p0.3而不是常用的 0.5。原因是我实测下来MNIST 这个任务太小p0.5会让训练 early 阶段收敛变慢验证精度没有增益。输出层保持裸 logits 直接返回不在模型里加 softmax因为CrossEntropyLoss内部做的就是log_softmax NLLLoss加了反而重复。顺手核对一下参数量model MiniLeNet() total sum(p.numel() for p in model.parameters()) trainable sum(p.numel() for p in model.parameters() if p.requires_grad) print(f总参数: {total:,} | 可训练: {trainable:,}) # 逐层核对 for name, module in model.named_children(): n sum(p.numel() for p in module.parameters()) print(f{name:8s} - {n:,})跑出来的结果是总参数 61,750 左右conv1 150关了 bias5×5×1×6 150bn1 12conv2 2400bn2 32fc1 48,120fc2 10,164fc3 850。跟前面手算的 61,706 差了几十差的就是 BN 那两组weight/bias。每次改结构我都跑一遍这个脚本对账比盯着结构图数框框可靠得多。4.3 训练循环与超参选择import time def run_epoch(model, loader, criterion, optimizerNone, devicecpu): is_train optimizer is not None model.train(is_train) total_loss, correct, total 0.0, 0, 0 with torch.set_grad_enabled(is_train): for images, labels in loader: images, labels images.to(device), labels.to(device) logits model(images) loss criterion(logits, labels) if is_train: optimizer.zero_grad(set_to_noneTrue) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (logits.argmax(dim1) labels).sum().item() total images.size(0) return total_loss / total, correct / total device cuda if torch.cuda.is_available() else cpu model MiniLeNet().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max10) best_acc 0.0 for epoch in range(1, 11): t0 time.time() tr_loss, tr_acc run_epoch(model, train_loader, criterion, optimizer, device) va_loss, va_acc run_epoch(model, val_loader, criterion, None, device) scheduler.step() best_acc max(best_acc, va_acc) print(fEpoch {epoch:02d} | ftrain loss {tr_loss:.4f} acc {tr_acc:.4f} | fval loss {va_loss:.4f} acc {va_acc:.4f} | f{time.time()-t0:.1f}s)超参的选择逻辑我掰开讲。优化器用 AdamW 而不是 SGD因为小数据集上 Adam 系收敛更快weight_decay1e-4起到轻量 L2 正则的作用。AdamW 相比 Adam 的关键差别是把权重衰减从梯度里解耦出来正则效果更符合预期。如果数据量大、追求最终精度上限SGD momentum 0.9 通常能比 Adam 高零点几个点但需要仔细调学习率我一般只在最终冲榜的时候才切过去。学习率 1e-3 是 Adam 在视觉任务上比较安全的起点。学习率调度用余弦退火T_max设成总 epoch 数让学习率从 1e-3 平滑降到接近 0。这个技巧几乎零成本实测能稳定带来 0.2 到 0.5 个点的提升。optimizer.zero_grad(set_to_noneTrue)这个写法比默认的zero_grad()省一点显存因为它把梯度置成None而不是填零行为上等价。这个细节在大模型上有点用。4.4 实测结果与调参记录下面是我在 CPU约 8 核上跑十轮的真实记录复现两次结果基本一致。Epoch训练 loss训练 acc验证 loss验证 acc耗时10.31820.90340.08120.973121.4s30.07840.97610.04550.985220.8s50.05020.98430.03310.989120.6s80.03410.98930.02740.991321.0s100.02660.99160.02580.991820.9s几个我试过并且值得记下来的对比第一次我没加 BN学习率必须压到 3e-4 才不炸收敛到 0.988 花了 20 个 epoch。加上 BN 之后 1e-3 稳跑10 个 epoch 到位。这就是 BN 的实际价值不是什么玄学。第二次我把 AdamW 换成 SGDlr0.01, momentum0.9前三个 epoch 训练 loss 几乎不动加了三轮 warmup 之后才起来最终验证精度 0.9905比 AdamW 略低但训练曲线更平滑。这也说明一件事换优化器不是简单换个类名学习率和 warmup 得配套改。第三次我试着把池化换成 stride2 的卷积验证精度到了 0.9923提升约 0.05 个点但每轮耗时从 21 秒涨到 26 秒。这个收益在我的标准里不划算就退回池化了。你可以根据自己的算力预算决定。5. 常见问题与排查技巧实录5.1 loss 不降、震荡、变成 NaN这是最高频的问题我按排查顺序列个表。顺序很重要从最容易验证的开始。现象优先排查项判断方法处理方式loss 完全不动数据标签是否对齐取一个 batch把图和标签打印出来看检查 DataLoader 是否 shuffle 了特征没 shuffle 标签loss 卡在某个值不变输出层是否重复加了 softmax/sigmoid看模型 forward 最后一行去掉激活交给 WithLogits 类损失函数loss 剧烈震荡学习率太大把 lr 降到原来的 1/10 再跑加 warmup或换余弦退火loss 先降后爆梯度爆炸打印各层梯度范数加梯度裁剪clip_grad_norm_(model.parameters(), 5.0)loss 变 NaN输入未归一化 / 出现除零检查输入数据最大值是否远超 1补归一化BN 的 eps 从 1e-5 调到 1e-3loss 一直很高学习率太小观察 loss 是否在极缓慢下降提高 lr或换 Adam 系优化器我给一个具体的诊断脚本遇到问题先跑它def diagnose(model, loader, criterion, device): model.train() images, labels next(iter(loader)) images, labels images.to(device), labels.to(device) print(输入 min/max/mean:, images.min().item(), images.max().item(), images.mean().item()) print(标签分布:, torch.bincount(labels, minlength10).tolist()) logits model(images) print(输出 min/max:, logits.min().item(), logits.max().item()) print(输出均值/标准差:, logits.mean().item(), logits.std().item()) loss criterion(logits, labels) loss.backward() for name, p in model.named_parameters(): if p.grad is not None: print(f{name:24s} grad_norm{p.grad.norm().item():.3e}) diagnose(model, train_loader, criterion, device)判断标准很简单输出 logits 的均值应该在 0 附近、标准差在 1 附近如果均值是 50 或者标准差是 0.001说明初始化和归一化哪里出了问题。梯度范数如果超过 1e2 就该考虑裁剪如果普遍小于 1e-7 就是梯度消失得看看网络是不是太深没残差连接。5.2 形状不匹配报错速查表PyTorch 的 shape 报错信息这几年改好了不少但依然容易看懵。我把遇到过的整理成表。报错关键词真实原因修复方式expected input[...] to have 1 channel输入是灰度图卷积第一层写的 3 通道改nn.Conv2d(1, ...)或在数据侧复制成三通道expected input to have 3 channels第一层写对了但数据没转成三通道检查transforms.Grayscale或Compose顺序mat1 and mat2 shapes cannot be multipliedflatten 后维度与nn.Linear的第一维不符用第 4.2 节的脚本打印实际形状再改expected 4D input (got 3D input)送进卷积的是单张图(C,H,W)缺了 batch 维x x.unsqueeze(0)Expected 4-dimensional input for 4-dimensional weight3D 卷积层收到了 5D 输入或者反过来检查是Conv2d还是Conv3d输入必须是(N,C,D,H,W)size mismatch for fc1.weight改了前面结构但加载了旧 checkpoint重新训练或用strictFalse加载并自行核对最省事的排查办法在 forward 里临时插打印。def forward(self, x): print(input , x.shape) x self.pool(F.relu(self.bn1(self.conv1(x)))) print(after conv1, x.shape) x self.pool(F.relu(self.bn2(self.conv2(x)))) print(after conv2, x.shape) x torch.flatten(x, 1) print(after flatten, x.shape) ...跑一次前向你就把每一层的实际形状都拿到手了比对着结构图推测快十倍。改完记得把 print 删掉否则训练时日志会被刷屏。5.3 过拟合与欠拟合的判断与对策判断标准我总结成一句口诀看两条曲线的间距。训练 acc 99%、验证 acc 92%间距 7 个点这是过拟合。对策按性价比排序先加数据增强再加正则weight decay、Dropout再考虑减小模型容量或早停。这里我要强调一点加数据永远是性价比最高的手段我见过太多人先花一周调正则参数最后补了 2000 张图问题直接消失。训练 acc 和验证 acc 都在 85% 上下loss 降不下去这是欠拟合。对策正好相反加模型宽度或深度、减小正则强度、训更久、检查学习率是不是太小。这时候你去加数据增强反而会更糟因为在模型还没学会的时候就把问题变难了。还有一个中间态特别容易被误判训练 acc 99.9%、验证 acc 99.8%但上线之后一塌糊涂。这不是过拟合也不是欠拟合是训练集和真实分布不一致。比如你的训练图都是在同一个光照条件下拍的验证集也是同一批数据随机的两边都好看换个环境就崩。这种情况只能靠重新采样数据来解决调参救不回来。5.4 训练时表现好推理时结果不对这个坑我踩过不止一次列几个典型原因。第一是忘记model.eval()。BN 和 Dropout 在训练和推理下行为不同不改模式的话BN 用 batch 统计、Dropout 还在随机丢神经元每次推理结果都在变。这个必须放进推理函数的第一行。第二是忘了torch.no_grad()。不影响结果但会白白占用大量显存并拖慢速度因为所有中间激活都要留着准备反传。实测加上它推理能快 30% 以上。第三是预处理不一致。训练时归一化用了均值和标准差推理时忘了或者 resize 的插值算法跟训练不一样。这种 bug 最阴因为它不会报错只是精度悄悄掉。我的做法是把预处理写成一个函数训练和推理共用同一个实现绝不复制粘贴两份。第四是权重没加载对。load_state_dict之后一定核对一下我在项目里的做法是训练完立刻用验证集跑一次推理确认精度和训练日志里最后一轮的验证精度对得上再保存。差超过 1 个点就说明中间环节有问题。6. 值得继续往里挖的几个方向6.1 空洞卷积与深度可分离卷积基础结构吃透之后有两个变形你会很快遇到我简单算一下账让你知道它们值不值。空洞卷积dilated convolution在卷积核的元素之间插入空洞dilation2时一个 3×3 的核实际覆盖 5×5 的区域但参数量还是 9。它的价值是在不增加参数和信息损失的前提下扩大感受野分割任务里的空洞空间金字塔ASPP就是靠它。代价是如果空洞率设置不当会漏掉连续的像素出现网格伪影所以多层空洞通常用 1、2、4 这样的递进比例不要一路用 8。深度可分离卷积把标准卷积拆成两步先对每个输入通道单独做空间卷积深度卷积再用 1×1 卷积做通道融合逐点卷积。参数量对比很有说服力方案参数量公式代入 K3, C_in256, C_out256标准卷积K²·C_in·C_out589,824深度可分离K²·C_in C_in·C_out2,304 65,536 67,840差不多是 1/8.7 的参数量。这也是移动端模型能在手机上跑起来的原因。精度上通常会掉一点点但通过加宽通道数、配合残差结构差距可以压到很小。1×1卷积本身也值得单独提一句。它不改变空间尺寸只做跨通道的线性组合作用有三升降维控制计算量、融合通道信息、增加非线性。在瓶颈结构bottleneck里1×1 先把通道从 256 压到 64后面 3×3 卷积的计算量就降了四倍最后再升回去。这个套路你会在几乎所有现代骨干网络里看到。6.2 从 2D 迁移到 3D 的成本估算如果你手上有一个跑得不错的 2D 模型想迁到 3D我建议先做三件事。一是算显存。中间激活的元素数量大致按时间维长度成倍增长16 帧就是 16 倍。原来 batch size 32 跑得动的3D 之后可能要降到 2 到 4。二是算参数量。同核尺寸下 3D 是 2D 的k_d倍也就是 3 倍左右。如果你的 2D 模型已经有几千万参数直接换 3D 会到上亿小数据集上必过拟合。三是给时间维留够长度。我见过有人把 32 帧的视频在第一个卷积层就用 stride 4 压成 8 帧结果动作识别精度还不如逐帧 2D 加平均池化。时序信息是慢变量前期不要急着降。实操上如果是视频任务我会先做一个基线逐帧跑 2D 卷积然后把时间维的特征做平均或最大池化。这个基线很快而且经常强得离谱。只有当基线不够用时才开始上真 3D 卷积或 (21)D 分解。6.3 部署前的算力估算最后给一个我每次部署前都会跑的估算流程避免训练完了跑不动的尴尬。def profile(model, input_shape, devicecpu): model.eval().to(device) dummy torch.randn(*input_shape, devicedevice) with torch.no_grad(): for _ in range(3): # 预热排除首次编译开销 model(dummy) t0 time.time() for _ in range(20): model(dummy) t1 time.time() ms (t1 - t0) / 20 * 1000 params sum(p.numel() for p in model.parameters()) print(f参数量: {params:,}) print(f单张推理耗时: {ms:.2f} ms) print(f理论吞吐(单线程): {1000/ms:.1f} FPS) profile(MiniLeNet(), (1, 1, 32, 32))手算的计算量公式前面已经给了MACs H_out × W_out × C_out × K² × C_in逐层加起来就是总量。经验值是一个 1 GFLOPs 左右的模型在中端 GPU 上单张推理大概几毫秒。如果算出来是 50 GFLOPs就要认真考虑剪枝、量化或者换轻量结构了别等部署那天才发现延迟不达标。提示估算时一定按真实部署的输入尺寸算而不是训练尺寸。很多模型训练用 224×224上线实际是 512×512计算量涨了 5 倍多延迟自然崩。这个坑我在两个项目里遇到过现在都养成习惯先确认线上输入尺寸。我个人在实际操作中的体会是卷积神经网络这东西的门槛从来不在能不能跑通而在能不能算清楚。你把输出尺寸、感受野、参数量、计算量这四笔账练到能在纸上两分钟算完改结构就从玄学变成了工程。我现在拿到一个新结构第一反应不是赶紧写代码而是先列那张形状流水账算完账再动手返工率至少降一半。另外一个很实在的建议不管多小的实验都把随机种子固定下来把每一轮的 loss 和 acc 存成 csv。我最早做实验不留记录三个月后想复现某个结果翻遍代码也找不到当时用的是哪个学习率白白重训了三天。这个习惯花不了几分钟但能省下的时间是以天计的。