
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 配 P15×5 配 P27×7 配 P3都是这个规律。为什么偏爱奇数核因为奇数核有唯一的中心像素填充对称时不会产生位置偏移实现起来最省心。参数量则要看是否带偏置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 时第一层 RF1(3-1)3第二层 RF3(3-1)×15第三层 RF52×17。三层 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/s20否保留最强响应对纹理边缘友好小模型首选平均池化 2×2/s20否平滑响应适合最后做全局池化聚合步长卷积 3×3/s2K²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, stride1): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) self.short None if stride ! 1 or in_ch ! out_ch: self.short nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride, biasFalse), 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)注意卷积层全部设置biasFalse。这不是偷懒是因为后面紧跟 BatchNormBN 的减均值操作会把偏置完全抵消掉留着纯属浪费参数和显存。这个小细节在模型转部署、做算子融合时也有影响后面会讲。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 到目标尺寸再裁剪裁剪出来的基本都是边角图像内容严重缺失。这个比例不是随手定的它给随机裁剪留出了平移的空间让模型见到的物体位置有变化这本身就是一种有效的数据增强。数据管线的代码我一般写成这样把训练和验证分开两个 transformfrom 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_classes10, widths(32, 64, 128, 256), blocks(2, 2, 2, 2)): super().__init__() self.stem nn.Sequential( nn.Conv2d(3, widths[0], 3, 2, 1, biasFalse), nn.BatchNorm2d(widths[0]), nn.ReLU(inplaceTrue), ) 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_blockingTrue), label.to(dev, non_blockingTrue) opt.zero_grad(set_to_noneTrue) 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_noneTrue)。默认是置零改成置 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 里。这也是我在前面代码里一律写biasFalse的原因之一。融合之后模型精度理论上完全无损只是浮点误差级别速度能提升百分之十上下是所有优化里性价比最高的一项。几乎所有推理框架都会自动做这一步但如果你自己写推理代码记得手动实现别让 BN 白跑。6.2 量化掉点从哪来把权重和激活从浮点压到八位整数模型能小四倍、快两三倍但精度掉几个点是常态。掉点的主要来源有三个。一是激活的离群值。某一层某个通道的激活值特别大为了用一个统一的缩放系数覆盖它其他正常范围的值就被压得很粗量化误差陡增。处理办法是用逐通道量化代替逐层量化或者对激活做截断把极端值裁掉。二是第一层和最后一层敏感。第一层直接吃原始像素输入分布范围固定且宽最后一层输出直接决定分类误差无法被后续层吸收。常见做法是把这两层保留为浮点只量化中间层。三是深度可分离卷积对量化不友好。它的通道数少、每层动态范围差异大量化误差累积很快。如果模型里大量使用这种结构量化后一定要逐层对比输出差异定位到具体是哪几层掉得最狠再决定这几层是否保留浮点。量化之后必须做的一件事是在真实验证集上重测不能只看量化前后的权重误差。权重误差小不代表输出误差小尤其是分类边界附近的样本一点点扰动就会翻转预测。6.3 输入分辨率不是越高越好这一点我在实际项目里吃过亏。当时的直觉是分辨率越高看得越清楚精度肯定越高于是把输入从 128 一路提到 384。结果精度只涨了不到一个点推理延迟翻了九倍完全不可接受。原因是 CNN 的精度对分辨率的响应是饱和的。当输入分辨率已经远大于目标物体的实际尺寸时继续提高分辨率只是让同样的信息占据更多像素模型并没有获得新信息反而因为感受野相对变小、批归一化统计量变化而引入新的不匹配。输入分辨率相对计算量典型精度变化适用场景961.0×基准端侧实时、目标占比较大1281.8×1.5 点左右通用平衡点1924.0×2 点左右服务器端、精度优先256 以上7× 以上增益明显收窄小目标密集、需谨慎验证实用做法是先在小分辨率上把结构和超参调好再逐步提高分辨率做微调每一步都实测精度和延迟找到曲线的拐点就停。同一个模型在不同分辨率下的最优超参是不一样的提分辨率之后学习率要相应调小微调的轮数不用太多通常原训练轮数的十分之一就够。7. 几个我反复验证过的实操习惯7.1 结构设计的纸上作业不能省动手写代码之前我习惯先在纸上列一张表每一层的输入尺寸、卷积核、步长、填充、输出通道、输出尺寸、感受野、参数量。这张表大概花二十分钟能提前发现八成以上的形状不匹配和感受野不足问题。尤其是最后几层的感受野有没有覆盖目标尺寸、总参数量有没有超出预算这两件事在纸上算比在代码里试快得多。写完表之后我还会顺手算一下每个阶段的计算量占比。如果某一个阶段占了总计算量的六成以上那它就是优化的首要目标——通常是第一个阶段因为分辨率最高。把 stem 的步长从 1 改成 2或者把第一阶段的块数减一往往能在精度几乎不掉的情况下砍掉一大块延迟。7.2 随机种子的处理要前后一致可复现性这件事我在早期项目里吃过不少亏。同一份代码跑两次结果差三个点排查半天找不到原因最后发现是数据加载的多进程随机种子没设对。完整的做法是固定 Python 的随机种子、NumPy 的种子、框架的种子给 DataLoader 的每个 worker 单独设种子同时关掉框架里可能引入不确定性的加速选项。做完这些同配置两次训练的差异应该能压到零点几个点以内剩下的差异来自浮点累加顺序属于正常范围。有一点要提醒开了确定性模式之后训练速度可能会下降因为某些高性能算子被禁用了。我的做法是调参阶段开确定性保证对比公平最终训练时关掉换取速度。7.3 保存检查点要存全不只是权重只存模型权重的检查点在需要恢复训练时会让你抓狂——优化器状态没了学习率调度器的进度没了混合精度的缩放系数也没了恢复之后损失曲线会明显跳一下。完整的检查点应该包含模型权重、优化器状态、调度器状态、当前 epoch、梯度缩放器的状态以及当时的随机数状态。另外建议同时保留最近一个和验证指标最好的一个两份。前者用于断点续训后者用于最终交付。我见过只存最好模型的情况训练中途崩了只能从头再来白白浪费几个小时。7.4 小数据集上正则比容量重要几千张图级别的数据集我基本不会去堆深度。这个量级下模型的容量很容易过剩堆到十几层之后验证精度不升反降。这时候有效的杠杆是加强数据增强在不过度的前提下、加权重衰减、加 dropout、用标签平滑、做模型集成。集成虽然推理变慢但在离线场景下几个不同初始化的模型投票涨两三个点是常态比硬调结构省事得多。还有一个常被忽略的免费午餐用在大数据集上预训练好的权重做初始化然后在小数据集上微调。哪怕领域差得比较远浅层的边缘纹理特征也是通用的收敛速度和最终精度都会明显好于从零训练。微调时浅层用小学习率、深层用大学习率这个分层学习率的小技巧在数据量少的时候效果尤其明显。最后分享一个我自己踩过好几次的坑改完代码之后一定要用一个极小的数据集跑通完整的训练、验证、保存、加载、推理整条链路再去跑正式训练。我至少三次遇到过训练跑了一晚上第二天发现保存的检查点加载不回来或者推理时的预处理和训练时不一致。这类问题在十分钟的小规模验证里就能暴露别让它在八小时之后暴露。