卷积神经网络ConvNet:参数量、感受野与推理优化实战
ConvNet 这个词我在不同场合听过两种完全不同的理解:一种是把它当成"一个卷积层加上几层全连接"的入门玩具,另一种是把它当成一个需要反复调结构、调超参、调部署的精调对象。两种理解都没错,但差距在于——前者只能跑通 MNIST,后者能在真实图像任务上把精度和延迟同时压到可交付的水平。这篇内容不讲教科书式的推导流水账,而是围绕卷积神经网络(ConvNet)模型本身,把"为什么要卷积""尺寸和感受野怎么算""主干怎么搭""代码怎么跑""训不动怎么查""落地怎么快"这几件事拆开讲透。适合刚学完反向传播想动手复现一个完整模型的人,也适合已经能训练但精度总卡在某个水平、说不出原因的从业者。我会给出可直接抄的参数、代码和排查顺序,也会把每一步"为什么这么做"的理由摊开——因为只会抄配置的人,换个数据集就又不会了。
1. 先把全连接层的账算给你看:ConvNet 到底在省什么
1.1 一张 224×224 的彩色图足以压垮第一个全连接层
很多人学 ConvNet 是从"卷积能提取边缘纹理"这句话开始的,但真正促使这个结构成为图像任务默认选择的,是一笔非常朴素的经济账。假设输入是一张 224×224 的 RGB 图,展平后是 224×224×3 = 150,528 维向量。如果第一个隐藏层只设 1000 个神经元,这一层的权重矩阵就是 150528×1000 ≈ 1.5 亿个参数。这还只是网络的第一层,后面还有分类头、还有若干隐藏层。相比之下,一个 32 通道、3×3 卷积核、输入 3 通道的卷积层,参数量只有 3×3×3×32 = 864 个。两者差了五个数量级,而后者在图像上的实际效果通常还更好。这不是工程上的优化技巧,这是结构层面的降维打击。
参数量的差距只是表面,更麻烦的是第二个问题:位置敏感性。全连接层把图像当成一个无序的长向量,像素在左上角还是右下角,对它来说是完全不同的两维输入。同一只猫向左平移二十个像素,输入向量就面目全非,网络得重新学一遍。而真实世界的图像里,物体的位置本来就是随机的,把"位置"当成必须单独记忆的信息,等于让模型把大量容量浪费在记住"猫可能出现在任何地方"这件事上。
1.2 局部连接与权值共享:两个便宜到不真实的假设
卷积层用两个假设把上面的问题一次性解决了。第一个是局部连接:一个 3×3 的卷积核只盯着输入的 9 个像素,它假设相邻像素之间的相关性远强于远处像素。这个假设在图像上几乎总是成立的——一条边缘由相邻像素的亮度差构成,一朵花的纹理也只在局部重复。第二个是权值共享:同一个卷积核在整张图上滑动,用的是同一组参数。检测竖直边缘的滤波器出现在图像左上角还是右下角,工作原理完全一样,那就没必要学两遍。
这两个假设合起来,把参数量从"与图像尺寸的平方相关"变成了"只与卷积核大小和通道数相关"。图像从 224×224 变成 448×448,卷积层的参数量一个都不增加,只是计算量翻四倍。这个性质非常重要,它意味着同一套模型结构可以直接吃不同分辨率的输入,后面讲落地时我们会用到这一点。
顺带说一个容易被讲混的概念:卷积加池化带来的严格来说是平移等变性(输入平移,输出跟着平移)而不是平移不变性。真正的不变性来自最后的全局池化或者足够大的感受野把位置信息"糊掉"。这个区别在调试时有用——如果你的任务需要输出物体位置(比如检测框),那你要的就是等变性,别急着用全局平均池化把空间信息压没。
1.3 归纳偏置是优势,也是天花板
局部性和平移等变性属于模型的归纳偏置——就是模型在看数据之前就"预先相信"的东西。相信得对,样本效率就高,小数据集上也能训出不错的效果;相信得不对,就是天花板。ConvNet 在纹理、边缘、局部形状这些模式上极强,但对"图像左上角的物体和右下角的物体是什么关系"这类长距离依赖,需要堆很多层把感受野撑大才能覆盖,代价是参数和延迟。这也是后来各种全局建模结构被引入的动机之一。
我想强调的是:不要因为出现了新结构就认为 ConvNet 过时了。在数据量中等、延迟敏感、部署环境受限的场景里,一个结构干净的 ConvNet 依然是性价比最高的选择。真正决定成败的往往不是结构名,而是通道数怎么排、归一化放哪、增强怎么配这些细节。
2. 卷积的水面之下:尺寸、感受野与参数量的手工账
2.1 输出尺寸公式:三个变量必须背下来
任何一层卷积,输出特征图的空间尺寸由四个量决定:输入边长 I、卷积核边长 K、填充 P、步长 S。公式是:
O = floor((I + 2P - K) / S) + 1
举个具体例子。输入 224,卷积核 7×7,步长 2,填充 3,则 O = floor((224 + 6 - 7)/2) + 1 = floor(223/2) + 1 = 111 + 1 = 112。正好减半,这就是很多主干网络第一层的配置——用一个较大的卷积核配合步长 2,一次性把分辨率砍半同时把通道从 3 提到 64。
再算一个维持分辨率的:输入 56,卷积核 3×3,步长 1,填充 1,则 O = floor((56 + 2 - 3)/1) + 1 = 56。这就是所谓的 same padding,输出和输入一样大。记住一个结论:K 为奇数、P = (K-1)/2、S = 1 时,输出尺寸等于输入尺寸。3×3 配 P=1,5×5 配 P=2,7×7 配 P=3,都是这个规律。为什么偏爱奇数核?因为奇数核有唯一的中心像素,填充对称时不会产生位置偏移,实现起来最省心。
参数量则要看是否带偏置:K×K×C_in×C_out(加 C_out 个偏置)。一个 3×3、输入 128 通道、输出 256 通道的卷积层,参数量是 3×3×128×256 = 294,912,加上偏置约 29.5 万。这个数字在你设计网络时是最直接的资源指标,比 FLOPs 更好估——参数量决定模型文件大小和显存占用,FLOPs 决定推理延迟。
2.2 感受野递推:三层 3×3 为什么能顶一个 7×7
感受野指的是输出特征图上一个点,能"看到"原图多大范围。递推公式是:
RF_l = RF_{l-1} + (K_l - 1) × stride_1 × stride_2 × ... × stride_{l-1}
从 RF_0 = 1 开始。连续三层 3×3、步长全为 1 时:第一层 RF=1+(3-1)=3,第二层 RF=3+(3-1)×1=5,第三层 RF=5+2×1=7。三层 3×3 堆叠,感受野等于一层 7×7。
那为什么不直接用 7×7?算参数:假设输入输出都是 64 通道,三层 3×3 是 3×(3×3×64×64) = 110,592;一层 7×7 是 7×7×64×64 = 200,704。前者参数少了将近一半,而且中间多了两次非线性激活,表达能力强得多。这就是 3×3 连续堆叠成为主流主干设计基本砖块的核心原因——不是我偏爱小核,是算术结果逼着人选小核。
但有一步很关键:只要有步长大于 1 的层,后面所有层的感受野计算都要乘以累积步长。比如 3×3/s2 后面接 3×3/s1,第二层的感受野是 5 + 2×2 = 9 而不是 5。这个乘积累积很容易在纸上算错,我的做法是画一张表,逐层记录 K、S、输出尺寸、感受野、参数量四列,设计完一遍再回头检查最后几层的感受野有没有覆盖到目标物体的典型尺寸。如果目标任务里的物体在原图上占 200 像素宽,而你的主干最后一层感受野只有 120,那模型从结构上就不可能"看全"这个物体,再怎么调参也补不回来。
2.3 步长卷积与池化:两种下采样方式的取舍
降分辨率的手段有三种:最大池化、平均池化、步长大于 1 的卷积。三者的差别比想象中大。
| 方式 | 参数量 | 是否可学习 | 特点与适用 |
|---|---|---|---|
| 最大池化 2×2/s2 | 0 | 否 | 保留最强响应,对纹理边缘友好,小模型首选 |
| 平均池化 2×2/s2 | 0 | 否 | 平滑响应,适合最后做全局池化聚合 |
| 步长卷积 3×3/s2 | K²C_inC_out | 是 | 下采样的同时做特征变换,参数量换表达能力 |
我自己的经验是:模型容量紧张、部署端算力吃紧时,用最大池化下采样,省下的参数留给通道数;模型容量充足、追求精度时,用 3×3/s2 的卷积做下采样,让网络自己学怎么降维。中间路线是 1×1 卷积降通道 + 3×3/s1 卷积提特征 + 3×3/s2 下采样,用在瓶颈结构里。
还有一个细节值得提醒:池化层在反向传播时只对最大值位置回传梯度,这意味着大约一半的位置拿不到梯度。层数浅的时候无所谓,层数很深时连续多个池化会让有效梯度路径变稀疏。这也是深网络更倾向用步长卷积的一个隐性理由。
2.4 1×1 卷积:看起来什么都没做,实际干了三件事
新手最容易困惑的就是 1×1 卷积——核只有一个像素,能提取什么空间特征?答案是它根本不负责空间特征,它做的是通道维度上的线性组合。
第一件事是降维和升维。1×1×256×64 的卷积把 256 通道压到 64 通道,参数量 16,384,比 3×3×256×64 的 147,456 省了将近九成。在 3×3 卷积前面插一个 1×1 降维,就是所谓的瓶颈结构,能让深层网络的参数量降一个量级。
第二件事是跨通道信息融合。每个输出通道都是所有输入通道的加权和,网络由此学会了"把纹理通道和颜色通道按某个比例混合"。
第三件事是配合归一化和激活,在不改变空间分辨率的前提下增加一层非线性。深度可分离卷积里的逐点卷积,本质就是一个 1×1 卷积——先用 3×3 的深度卷积处理空间,再用 1×1 处理通道,把标准卷积的计算量拆成两部分,是移动端模型压缩的常用手段。
3. 主干怎么搭:通道节奏、残差与归一化的工程取舍
3.1 通道翻倍、分辨率减半:这个节奏不是玄学
几乎所有经典主干都遵循同一个节奏:每经过一次下采样(分辨率减半),通道数翻倍。为什么?因为单层的计算量大致是 H×W×C_in×C_out×K²。分辨率减半意味着 H×W 变成四分之一,通道翻倍意味着 C_in×C_out 变成四倍,两者相乘恰好持平。结果是每一层的计算量大致相等,整个网络的计算负载分布均匀,不会出现某一层特别慢拖垮整体的情况。
这个节奏还有一个附带好处:浅层分辨率高但通道少,负责提取细粒度的边缘纹理;深层分辨率低但通道多,负责编码语义。如果反过来做——浅层就上 512 通道,那一层的计算量会爆炸,显存直接撑不住;如果深层不增加通道,语义容量不够,精度上不去。
一个典型的通道排布是 64 → 128 → 256 → 512,每个阶段内重复若干次同样的块,阶段之间做一次下采样。这个"阶段 + 重复块"的组织方式还带来一个工程便利:你可以通过调整每个阶段的重复次数(比如 2、2、2、2 变成 3、4、6、3)来获得不同容量的模型,而不用改结构逻辑。
3.2 残差连接真正解决的不是梯度消失
关于残差连接,流传最广的解释是"缓解梯度消失"。这个说法不算错,但我认为没说到点子上。真正让我信服的解释来自一个很朴素的实验观察:把 20 层网络加深到 56 层,训练集上的误差反而更高。注意是训练集误差更高,不是验证集。这意味着问题不在过拟合,而在于深层网络连"拟合训练数据"这件事都变难了——优化本身出了障碍。
残差连接的做法是把目标函数从"学习 H(x)"改成"学习 F(x) = H(x) - x",然后输出 F(x) + x。如果某一层根本不需要做什么变换,网络只要把 F(x) 的权重推到接近零就行,这比"学一个恒等映射"容易得多。恒等映射在非线性堆叠里其实很难精确表达,但从零学一个零输出却很容易。所以残差的价值是让深层网络至少不劣于浅层网络,深度增加变成了一件"只有收益没有风险"的事。
实现上有两个细节容易出错。一是降采样时捷径分支必须跟着做变换,否则形状对不上,通常用 1×1 卷积配同样的步长。二是残差相加后的激活位置,标准做法是相加之后再 ReLU,也就是 ReLU(BN(conv) + shortcut),如果把 ReLU 放在相加之前,会破坏恒等路径的线性性,效果会掉。
import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, 1, 1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) self.short = None if stride != 1 or in_ch != out_ch: self.short = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride, bias=False), nn.BatchNorm2d(out_ch), ) def forward(self, x): idt = x if self.short is None else self.short(x) out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) return self.relu(out + idt)注意卷积层全部设置bias=False。这不是偷懒,是因为后面紧跟 BatchNorm,BN 的减均值操作会把偏置完全抵消掉,留着纯属浪费参数和显存。这个小细节在模型转部署、做算子融合时也有影响,后面会讲。
3.3 归一化的位置:Conv-BN-ReLU 这个顺序为什么成了默认
卷积、归一化、激活的排列顺序,绝大多数实现是 Conv → BN → ReLU。理由是:卷积输出的分布随初始化方式浮动很大,BN 先把它拉回到零均值单位方差,激活函数才工作在自己的敏感区间——比如 ReLU 在均值 0 附近梯度最好,如果输入分布整体偏移到 -5 附近,整个通道就全部死掉了。
关于 BN 该放在激活前还是激活后,确实有过讨论,也有实验表明某些任务上放在激活后效果不差。但我的建议是没有特别理由就用默认顺序,因为预训练权重、部署工具链、算子融合的实现都是按这个顺序优化的,你换顺序可能精度持平,但会给自己增加很多转换麻烦。
另一个高频问题是小 batch size 下 BN 失效。BN 用当前 batch 的统计量做归一化,batch 只有 2 或 4 的时候,统计量噪声极大,训练极不稳定。这时候可以考虑 GroupNorm 或者 LayerNorm,它们不依赖 batch 维度。图像任务上 GroupNorm 的精度通常略低于大 batch 的 BN,但稳定性好得多,做小 batch 微调时是很实用的替代。
3.4 深度可分离卷积:什么时候值得用
把标准卷积拆成"逐通道空间卷积 + 逐点通道卷积",计算量能降到原来的 1/C_out + 1/K² 左右。以 3×3、256 通道为例,标准卷积计算量约 589,824 单位,深度可分离是 2,304(空间)+ 65,536(逐点)= 67,840,约为原来的九分之一。
但账不能只看计算量。深度可分离卷积的参数利用率低,同样的参数量下精度通常比标准卷积低一到两个点,而且在实际硬件上因为算子拆得碎、访存密集,加速比往往达不到理论值。我在做端侧模型时的经验是:如果目标设备有专门的深度卷积优化,值得用;如果是通用 CPU,标准卷积配合通道裁剪可能更实在。选型不能只看纸面 FLOPs。
4. 跑通一个最小 ConvNet:代码、数据与训练流程
4.1 数据管线里三个最容易翻车的细节
模型结构再漂亮,数据管线错了就是白训。我见过的问题里,排前三的是这几个。
第一个是归一化用了全量数据的统计量。正确做法是只用训练集算均值和标准差,然后同一个值应用到验证集和测试集。用全量统计量属于典型的数据泄漏,验证精度会虚高一点点,不多,但足以让你在选模型时做出错误判断。
第二个是增强应用到了验证集。随机裁剪、随机翻转、颜色抖动都只应该出现在训练集上。验证集只做确定性的 resize 和中心裁剪。这个错误特别隐蔽,因为验证精度只是偏低了几个点,你可能会以为是模型不够强,然后去加容量,越加越偏。
第三个是 resize 和 crop 的顺序。常见做法是先把短边缩放到目标尺寸的 1.14 倍左右,再随机裁出目标尺寸。如果直接 resize 到目标尺寸再裁剪,裁剪出来的基本都是边角,图像内容严重缺失。这个比例不是随手定的,它给随机裁剪留出了平移的空间,让模型见到的物体位置有变化,这本身就是一种有效的数据增强。
数据管线的代码我一般写成这样,把训练和验证分开两个 transform:
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(96, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(110), transforms.CenterCrop(96), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])4.2 一个能跑起来的完整主干
下面这个结构不大,但包含了前面讲的所有要点:stem 用 3×3/s2 把分辨率砍半,四个阶段通道翻倍、分辨率减半,最后全局平均池化接线性分类头。参数量大约 280 万,在单卡上几分钟就能跑完一个 epoch,适合做各种消融实验。
class MiniConvNet(nn.Module): def __init__(self, num_classes=10, widths=(32, 64, 128, 256), blocks=(2, 2, 2, 2)): super().__init__() self.stem = nn.Sequential( nn.Conv2d(3, widths[0], 3, 2, 1, bias=False), nn.BatchNorm2d(widths[0]), nn.ReLU(inplace=True), ) layers = [] in_ch = widths[0] for i, (w, b) in enumerate(zip(widths, blocks)): for j in range(b): stride = 2 if (i > 0 and j == 0) else 1 layers.append(BasicBlock(in_ch, w, stride)) in_ch = w self.body = nn.Sequential(*layers) self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.1), nn.Linear(in_ch, num_classes), ) def forward(self, x): return self.head(self.body(self.stem(x)))用AdaptiveAvgPool2d(1)而不是固定尺寸的池化,好处是模型对输入尺寸不再敏感,你可以直接换更大的分辨率做微调,不用改结构。代价是换分辨率后 BN 的统计量需要重新校准,微调几个 epoch 就能恢复。
4.3 训练循环与超参:几个非默认但很值得改的地方
优化器我通常用带动量的 SGD 配 cosine 调度,或者 AdamW 配较小的权重衰减。前者在分类任务上泛化往往略好,后者收敛快、对学习率不敏感,适合快速迭代。学习率跟 batch size 大致成正比,batch 翻倍学习率也翻倍,这是常用的线性缩放经验;但超过某个规模后要改成平方根缩放,否则初期容易发散。
混合精度现在基本是标配:前向和反向用半精度算,主权重保持单精度,梯度缩放避免下溢。显存能省三到四成,速度提升也很明显。要注意的是梯度裁剪要在缩放回来之后再做,顺序搞反了阈值就失去意义了。
scaler = torch.cuda.amp.GradScaler() for epoch in range(epochs): model.train() for img, label in train_loader: img, label = img.to(dev, non_blocking=True), label.to(dev, non_blocking=True) opt.zero_grad(set_to_none=True) with torch.cuda.amp.autocast(): loss = criterion(model(img), label) scaler.scale(loss).backward() scaler.unscale_(opt) torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) scaler.step(opt) scaler.update() sched.step() evaluate(model, val_loader)还有一个容易被忽略的设置:opt.zero_grad(set_to_none=True)。默认是置零,改成置 None 之后,反向传播时如果不涉及某个参数,就直接跳过它的梯度计算和内存分配,小模型上差别不大,大模型上能省一点显存和几个百分点的时间。
标签平滑也是我常开的一个小开关,把目标从 one-hot 换成 (1-ε)/K 的软标签,ε 取 0.1。它抑制了模型对单个样本的过度自信,通常能带来零点几到一个点的提升。副作用是损失不再趋近于零,看到 loss 卡在 0.3 左右别以为训崩了,那可能正是标签平滑的正常表现。
4.4 指标怎么读:loss 和 accuracy 打架是正常的
训练日志里最常见的困惑是 loss 在降但 accuracy 不动,或者反过来。先分清两件事:loss 衡量的是预测分布和目标的匹配程度,accuracy 只看最高分那一类对不对。在类别不平衡的数据上,模型把所有样本都预测成多数类,accuracy 可能就有 80%,但 loss 会明显偏高。反过来,开了标签平滑之后,模型很难再输出接近 1 的概率,loss 有下界,但 accuracy 可以到很高。
所以我的建议是至少同时看三个东西:训练 loss、验证 loss、验证 accuracy。训练 loss 降但验证 loss 升,是过拟合;两者都不降,是欠拟合或代码有 bug;验证 loss 震荡但 accuracy 稳步上升,通常是学习率偏大,可以降一点或者加 warmup。只有 accuracy 一个指标,你根本分不清是模型不行还是训练没到位。
5. 训不动、精度卡住:一条可以照着走的排查链路
5.1 第一步永远是"能不能过拟合一个小样本"
这是我所有排查的起手式,从来没有例外。从训练集里抽 32 张图,关掉全部数据增强,用一个明显偏大的学习率(比如 1e-2 配 SGD),训 200 个 epoch,看训练精度能不能逼近 100%。
不能——问题在代码或者数据本身。可能的原因按概率排序:标签和图像没对齐(最常见)、损失函数和输出没匹配(比如多分类用了 BCELoss)、归一化参数写反、模型最后一层的输出维度不对、优化器没把某些参数包含进去。这类问题靠调超参是永远解决不了的,必须先把这条通路打通。
能——说明模型结构和训练代码都没问题,剩下的就是泛化问题,方向立刻清晰了。这个步骤花不了十分钟,能省掉几个小时的瞎调。
5.2 损失曲线形状对应的病灶
| 曲线形态 | 大概率原因 | 处理方向 |
|---|---|---|
| 完全不下降,贴在初始值附近 | 学习率过大导致发散,或数据标签错乱 | 学习率降 10 倍先试,再查标签 |
| 前期快速下降后长期平台 | 学习率没退火,或模型容量饱和 | 加 cosine 调度,或加深加宽 |
| 剧烈震荡,锯齿状 | batch 太小、BN 统计不稳、学习率偏大 | 加大 batch 或换 GroupNorm,降 lr |
| 训练降、验证升且差距扩大 | 过拟合 | 加强增强、加正则、减容量 |
| 训练验证都平且都很高 | 数据泄漏或任务过于简单 | 检查划分逻辑,检查重复样本 |
我最常遇到的是第二种。很多人训到第 30 个 epoch 看 loss 不动了就以为到极限了,实际上是学习率一直维持在初始值,模型在最优解附近来回跳。加一个 cosine 退火,最后十个 epoch 学习率压到接近零,往往能再涨一到两个点,成本几乎为零。
5.3 增强过度与数据泄漏:两个最难自查的坑
数据增强不是越多越好。我见过一个项目在几千张图的数据集上堆了随机裁剪、旋转、颜色抖动、随机擦除、混合增强五件套,训练精度死活上不去 70%。把增强砍到只剩翻转和裁剪,精度直接到 85%。原因很简单:小数据集本身信息量就紧张,过强的增强让模型见到的大部分是"不像真实数据"的样本,等于在噪声里学规律。判断标准是看训练精度——如果训练精度都上不去,增强肯定是过了。
数据泄漏更隐蔽,因为它表现为"精度高得离谱"。典型的泄漏场景是:同一张原图的不同裁剪、不同帧、同一次采集的多个样本,被随机划分到了训练集和验证集。模型在验证集上看到的其实是训练时见过的内容的近亲,精度虚高十几个点,上线就崩。正确的做法是按采集批次、按原始图像、按时间段划分,保证验证集里的样本在训练集里没有任何近亲。这个检查必须做,做完之后精度掉下来别慌,那才是真实水平。
5.4 精度上不去时,先别急着改结构
结构调整的代价很高——要重训、要重新调超参、要重新验证。在动结构之前,有几个更便宜的杠杆值得先拉一遍。
第一是分辨率。把输入从 96 提到 128 或 160,精度通常能涨一到三个点,代价是计算量按平方增长。第二是训练时长,很多模型在 cosine 调度的最后一个周期结束前还在涨,训练轮数翻倍往往比改结构划算。第三是数据本身——清洗掉错标样本、去掉重复样本、补充难例,这些脏活累活的收益经常比换主干大。第四是测试时增强,把原图、水平翻转、几个不同裁剪的预测结果平均,通常能白捡一个点,代价只是推理时间翻几倍,离线场景完全值得。
改结构是我最后才动的手段,而且只在有一种明确假设的时候才动。比如"小目标检测不到",那就去提高 stem 之后的特征图分辨率;"纹理区分不开",那就去在浅层加通道。没有假设就改结构,改完涨了不知道为什么涨,跌了也不知道为什么跌,下次还得重来一遍。
6. 走出训练集:推理端的融合、量化与分辨率取舍
6.1 BN 融合:不花钱的一次提速
训练时 BN 是独立的一层,推理时它其实完全可以被前面的卷积吸收掉。推导很直接:BN 对卷积输出做 y = γ·(x - μ)/√(σ² + ε) + β,展开之后就是 y = x·scale + shift,其中 scale = γ/√(σ² + ε),shift = β - μ·scale。把这个 scale 和 shift 乘进卷积核的权重和偏置里,卷积层就直接输出了 BN 的结果,少一次访存和一次逐元素运算。
前提是卷积层不能有偏置——如果卷积本身带 bias,融合公式要稍微改一下,先把它并进 shift 里。这也是我在前面代码里一律写bias=False的原因之一。融合之后模型精度理论上完全无损(只是浮点误差级别),速度能提升百分之十上下,是所有优化里性价比最高的一项。几乎所有推理框架都会自动做这一步,但如果你自己写推理代码,记得手动实现,别让 BN 白跑。
6.2 量化掉点从哪来
把权重和激活从浮点压到八位整数,模型能小四倍、快两三倍,但精度掉几个点是常态。掉点的主要来源有三个。
一是激活的离群值。某一层某个通道的激活值特别大,为了用一个统一的缩放系数覆盖它,其他正常范围的值就被压得很粗,量化误差陡增。处理办法是用逐通道量化代替逐层量化,或者对激活做截断,把极端值裁掉。
二是第一层和最后一层敏感。第一层直接吃原始像素,输入分布范围固定且宽;最后一层输出直接决定分类,误差无法被后续层吸收。常见做法是把这两层保留为浮点,只量化中间层。
三是深度可分离卷积对量化不友好。它的通道数少、每层动态范围差异大,量化误差累积很快。如果模型里大量使用这种结构,量化后一定要逐层对比输出差异,定位到具体是哪几层掉得最狠,再决定这几层是否保留浮点。
量化之后必须做的一件事是在真实验证集上重测,不能只看量化前后的权重误差。权重误差小不代表输出误差小,尤其是分类边界附近的样本,一点点扰动就会翻转预测。
6.3 输入分辨率不是越高越好
这一点我在实际项目里吃过亏。当时的直觉是分辨率越高看得越清楚,精度肯定越高,于是把输入从 128 一路提到 384。结果精度只涨了不到一个点,推理延迟翻了九倍,完全不可接受。
原因是 CNN 的精度对分辨率的响应是饱和的。当输入分辨率已经远大于目标物体的实际尺寸时,继续提高分辨率只是让同样的信息占据更多像素,模型并没有获得新信息,反而因为感受野相对变小、批归一化统计量变化而引入新的不匹配。
| 输入分辨率 | 相对计算量 | 典型精度变化 | 适用场景 |
|---|---|---|---|
| 96 | 1.0× | 基准 | 端侧实时、目标占比较大 |
| 128 | 1.8× | +1.5 点左右 | 通用平衡点 |
| 192 | 4.0× | +2 点左右 | 服务器端、精度优先 |
| 256 以上 | 7× 以上 | 增益明显收窄 | 小目标密集、需谨慎验证 |
实用做法是先在小分辨率上把结构和超参调好,再逐步提高分辨率做微调,每一步都实测精度和延迟,找到曲线的拐点就停。同一个模型在不同分辨率下的最优超参是不一样的,提分辨率之后学习率要相应调小,微调的轮数不用太多,通常原训练轮数的十分之一就够。
7. 几个我反复验证过的实操习惯
7.1 结构设计的纸上作业不能省
动手写代码之前,我习惯先在纸上列一张表:每一层的输入尺寸、卷积核、步长、填充、输出通道、输出尺寸、感受野、参数量。这张表大概花二十分钟,能提前发现八成以上的形状不匹配和感受野不足问题。尤其是最后几层的感受野有没有覆盖目标尺寸、总参数量有没有超出预算,这两件事在纸上算比在代码里试快得多。
写完表之后,我还会顺手算一下每个阶段的计算量占比。如果某一个阶段占了总计算量的六成以上,那它就是优化的首要目标——通常是第一个阶段,因为分辨率最高。把 stem 的步长从 1 改成 2,或者把第一阶段的块数减一,往往能在精度几乎不掉的情况下砍掉一大块延迟。
7.2 随机种子的处理要前后一致
可复现性这件事,我在早期项目里吃过不少亏。同一份代码跑两次结果差三个点,排查半天找不到原因,最后发现是数据加载的多进程随机种子没设对。完整的做法是:固定 Python 的随机种子、NumPy 的种子、框架的种子,给 DataLoader 的每个 worker 单独设种子,同时关掉框架里可能引入不确定性的加速选项。做完这些,同配置两次训练的差异应该能压到零点几个点以内,剩下的差异来自浮点累加顺序,属于正常范围。
有一点要提醒:开了确定性模式之后训练速度可能会下降,因为某些高性能算子被禁用了。我的做法是调参阶段开确定性保证对比公平,最终训练时关掉换取速度。
7.3 保存检查点要存全,不只是权重
只存模型权重的检查点,在需要恢复训练时会让你抓狂——优化器状态没了,学习率调度器的进度没了,混合精度的缩放系数也没了,恢复之后损失曲线会明显跳一下。完整的检查点应该包含模型权重、优化器状态、调度器状态、当前 epoch、梯度缩放器的状态,以及当时的随机数状态。
另外建议同时保留"最近一个"和"验证指标最好的一个"两份。前者用于断点续训,后者用于最终交付。我见过只存最好模型的情况,训练中途崩了只能从头再来,白白浪费几个小时。
7.4 小数据集上,正则比容量重要
几千张图级别的数据集,我基本不会去堆深度。这个量级下,模型的容量很容易过剩,堆到十几层之后验证精度不升反降。这时候有效的杠杆是:加强数据增强(在不过度的前提下)、加权重衰减、加 dropout、用标签平滑、做模型集成。集成虽然推理变慢,但在离线场景下几个不同初始化的模型投票,涨两三个点是常态,比硬调结构省事得多。
还有一个常被忽略的免费午餐:用在大数据集上预训练好的权重做初始化,然后在小数据集上微调。哪怕领域差得比较远,浅层的边缘纹理特征也是通用的,收敛速度和最终精度都会明显好于从零训练。微调时浅层用小学习率、深层用大学习率,这个分层学习率的小技巧在数据量少的时候效果尤其明显。
最后分享一个我自己踩过好几次的坑:改完代码之后,一定要用一个极小的数据集跑通完整的训练、验证、保存、加载、推理整条链路,再去跑正式训练。我至少三次遇到过训练跑了一晚上,第二天发现保存的检查点加载不回来,或者推理时的预处理和训练时不一致。这类问题在十分钟的小规模验证里就能暴露,别让它在八小时之后暴露。