ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ConvNeXtV2自监督预训练:FCMAE与GRN工程实践

ConvNeXtV2自监督预训练:FCMAE与GRN工程实践

1. 为什么全卷积结构在自监督预训练里吃了亏

1.1 从监督学习到掩码预训练,卷积网络原地踏步的那两年

自监督预训练这件事,在 Transformer 阵营里已经跑得很顺了。掩码自编码器(Masked Autoencoder,MAE)那套思路——随机遮掉大部分输入块,让模型从剩下的可见部分去重建被遮住的内容——简单、暴力、有效,在视觉分类、检测、分割上几乎全面开花。可你要是把视线转回卷积网络,会发现一个挺尴尬的事实:同样叫"掩码预训练",搬到 ConvNet 上效果就打折,甚至不如老老实实做监督训练。

ConvNeXtV2 就是在补这个缺口。它不是又一个新架构,而是一次"给全卷积网络适配自监督预训练"的系统性工程改造。核心有两块:一是全卷积掩码自编码器(FCMAE),二是全局响应归一化(GRN)。前者解决"卷积做掩码预训练会漏题"的问题,后者解决"预训练之后一半通道废掉"的问题。这篇文章我会把这两块的动机、实现、调参细节和踩坑记录全部摊开讲。

适合谁看?如果你手上有几万到几十万张的图像数据集,想训一个自己的骨干网络;或者你在做工业质检、遥感、医学影像这类标注贵、数据又有明显领域特征的任务,想靠自监督预训练把 backbone 拉起来——那这套东西值得你认真读完。有一定的 PyTorch 基础、能读懂卷积和反向传播就够了,剩下的我会把每一步的"为什么"讲清楚。

先说结论:ConvNeXtV2 的价值不在刷新榜单,而在于它证明了卷积网络完全可以吃下 MAE 这套预训练范式,并且在中小规模模型上收益比 Transformer 更明显。厂商给的 V1 权重拿来直接微调是常规操作,但如果你自己有条件跑预训练,V2 的那套 FCMAE + GRN 组合拳,能实打实把下游精度往上推一档。

1.2 掩码信息泄漏:卷积核会"偷看"答案

这是整篇的技术核心,必须掰开揉碎讲。

MAE 的玩法是:把图像切成 patch,随机选 75% 遮掉(置零),编码器只处理剩下的 25% 可见 patch,解码器把特征和掩码 token 拼起来重建整张图。在 ViT 里这套很干净,因为自注意力只在 token 之间做,被遮掉的 token 根本不进编码器,没有任何信息通道能让编码器偷看到被遮住的内容。

卷积就不一样了。考虑一个 7×7 的深度卷积,掩码在图上是零散分布的方块。卷积核在滑动时,输出位置即使落在被遮住的像素上,它的感受野也会覆盖到周围的可见像素。也就是说,被遮住位置的特征会吸收邻近可见区域的信息。再叠几层,感受野越滚越大,被遮住的位置会慢慢被周围的信息"填满",模型压根不需要学会重建,只要把邻居的信息搬过来,损失就降下去了。

这就是所谓的信息泄漏(information leakage),也叫 shortcut learning。模型看起来 loss 降得很漂亮,但学到的是一堆低成本的信息搬运技巧,表征质量很差,微调的时候自然拉胯。我在早期试着用普通 ConvNeXt 直接套 MAE 流程,训练 loss 曲线好看得离谱,第一轮就掉到很低,但微调时比从零训练还差——这就是典型的漏题症状。

除了信息泄漏,还有第二个问题:卷积核的平移等变性和局部性假设,在掩码输入上会失效。被遮掉的像素是零,和真实的黑色像素混在一起,卷积核分不清"这里本来就没东西"和"这里被我遮掉了"。这会污染批归一化 / 层归一化的统计量,也会让感受野里出现大量无效响应。

1.3 FCMAE 想解决的三个具体痛点

我把 FCMAE 的设计目标拆成三条,这样后面看代码的时候你会更清楚每一行是为什么:

第一,堵住信息泄漏通道。做法是引入稀疏卷积——只在可见位置计算输出,被遮住的位置严格保持为零,前向传播过程中不允许可见信息渗进掩码区域。这是整个方案里最硬的技术点。

第二,让卷积核适应"有洞"的输入。被掩掉一大片之后,剩下的是零散孤立的像素块。普通卷积在这种输入上会出现大量无效计算和边界伪影,稀疏卷积天然就是为这种场景设计的。

第三,保证预训练学到的东西能迁移。这个坑很隐蔽:即便前两个问题解决了,作者发现预训练出来的模型里有大量通道的输出幅值接近于零,等于白占参数。这就引出了 GRN,我们放到第 3 章细讲。

注意:FCMAE 的稀疏卷积是加在 stem 之后的,不是加在 stem 上。输入图像本身不做稀疏化,否则第一次卷积就没法算了。

2. FCMAE 的整体设计与关键取舍

2.1 编码器:稀疏卷积怎么堵住那道缝

先说清楚"子流形稀疏卷积"(submanifold sparse convolution)到底做了什么。常规卷积在稀疏张量上运算时,输出位置只要感受野里有一个非零元素,这个位置就会被"激活",于是零区域会像水波一样一轮一轮扩散,越往深处越糊。子流形稀疏卷积的约束是:输出位置只在输入位置本身是激活态时才计算,否则强制为零。这样一来,激活区域的数量在整个前向过程中保持不变,被遮住的地方永远是零,没有任何通道能让可见信息流进去。

落到 ConvNeXtV2 上,具体流程是这样:

  1. 图像先过一个 4×4、stride 4 的卷积 stem 做下采样,得到特征图。
  2. 按固定的 patch 大小(论文里用 32×32)在特征图网格上划分出 patch,随机遮掉 60%。
  3. 把特征图转换成稀疏张量:可见位置(未遮)作为激活点,遮掉的位置直接丢弃,不参与任何运算。
  4. 主干网络的所有卷积层都换成子流形稀疏卷积,特征只沿着可见点传播。
  5. 解码器接收稀疏特征,在遮掉的位置上补回可学习的 mask token,然后做重建。

这套流程听起来简单,但工程实现上有几个坑我要提前说:

坑一:批归一化和层归一化的统计量会被污染。稀疏张量里大量位置是零,如果你对整张特征图做 mean/var,统计量会被稀释。ConvNeXt 用的是 LayerNorm,作用在通道维度上,这个问题相对小,但你在写自定义实现的时候还是要注意,归一化必须在通道维做,不要跨空间维聚合。

坑二:稀疏卷积库对算子支持有限。常用的两个库(一个做三维稀疏、一个做二维稀疏)在功能和性能上各有取舍,有些版本对 depthwise 分组卷积支持不好,需要你退化成 1×1 卷积 + 7×7 空间卷积分开写。我实际做过一次对比:把 7×7 depthwise 换成普通 7×7 卷积,参数量从 C 涨到 49C,Tiny 规模下每层多出 27 万参数,整体涨了 8% 左右,不划算,还是想办法让库支持分组。

坑三:稀疏张量的 batch 组装开销很大。你把 N 张图的所有可见点拼成一个大稀疏张量,这个转换本身在 CPU 上很吃时间,容易变成数据管线的瓶颈。实测下来,把掩码生成放到 GPU 上、只传索引不传稠密张量,能省掉大量拷贝时间。

如果你想先用简单方案验证效果,可以退一步用掩码乘法的近似实现:普通卷积照做,但前向结束后立刻用掩码把非可见位置重新清零。这能缓解泄漏扩散的速度,但没法根治——因为单层卷积内部仍然会跨过掩码边界吸收信息。作为快速验证够用,作为最终方案不行。

2.2 解码器:为什么敢做得这么轻

MAE 的一个关键洞见是解码器不需要重,因为它只在预训练阶段用,推理时直接扔掉。FCMAE 把这个思路贯彻得更彻底:解码器只有一层 block,通道数也很窄(基础配置里大概 512 维),最后接一个线性层把特征映射回 patch 像素。

这个设计背后的逻辑是:重建任务的难度不该压在解码器上。如果解码器太强,它会自己学会"补齐",编码器反而学不到有判别力的表征。让解码器弱一点,梯度压力就会传导回编码器,逼着编码器输出信息量更大的特征。这跟知识蒸馏里"教师不能太强也不能太弱"是一个道理。

具体到参数量占比:Tiny 模型编码器 28.6M 参数,解码器只有 2M 出头,不到 7%。Base 是 89M 配约 5M。这种悬殊的比例是有意为之的。

还有一个细节值得说:loss 只在被遮住的位置上算。可见位置的像素目标值模型直接看得见,在那里算损失属于送分题,不加权也无所谓,但会稀释梯度信号。只在掩码位置算 MSE,梯度密度更高,收敛更快。实现上就是一个掩码加权的均方误差。

2.3 掩码策略与损失函数:那几个数字是怎么定下来的

掩码比例 0.6 这个数不是拍脑袋来的。ViT 那边用 0.75,是因为 patch 大(16×16),信息冗余低。卷积的感受野是局部的,相邻 patch 之间的相关性比 ViT 强,遮得太狠会导致可见信息不足,重建任务过难,反而学不好。0.6 是在"够难"和"信息够用"之间找的平衡点。

patch 大小 32 也是同理。太小的 patch 稀疏度不够,泄漏风险上升;太大的 patch 遮掉之后信息太少。32 在 224 输入下对应 7×7 的网格,遮掉 60% 还剩大约 20 个可见点,这个密度能支撑起卷积运算。

损失函数用的是逐 patch 归一化后的 MSE。为什么要逐 patch 归一化?因为不同区域的像素方差差别很大(天空区域几乎是常数,纹理区域方差极高),如果不归一化,梯度会被高方差区域主导。实现上就是每个 patch 减均值除标准差再算 MSE。

下表是我复现时用的基础配置,供参考:

配置项取值说明
掩码比例0.6高于 0.7 收敛变慢,低于 0.5 收益下降
patch 大小32需能被 stem 的 stride 整除
预训练轮数800低于 400 轮收益明显缩水
批大小4096可用梯度累积凑
优化器AdamWbetas 取默认值
基础学习率1.5e-4 量级随批大小线性缩放
权重衰减0.05预训练阶段偏大是正常的
warmup40 轮少了会炸
层间学习率衰减0.65越靠底层学习率越小

提示:学习率的具体数值和模型规模强相关,小模型(Nano 以下)通常要高一些,大模型要降。上表只是量级参考,落到你自己的数据上一定要先跑个 50 轮的短程实验看曲线。

3. GRN:解决特征坍塌的那一层

3.1 特征坍塌长什么样,怎么发现

这个问题我第一次遇到的时候完全没意识到,是看了可视化才明白的。

现象是这样的:你用 FCMAE 预训练完一个模型,把某一层的所有通道激活值统计一遍,会发现有相当比例的通道,在几乎所有输入上的响应都接近零。这些通道的卷积核权重不是死的,但输出幅值被压得很低,相当于被"静音"了。更糟的是,这些通道在微调阶段也很难被救活,因为它们的初始梯度太小,学习信号传不进去。

论文里把这个叫feature collapse(特征坍塌)。我自己的观察是,在一个 Base 规模的模型上,预训练后大约有 15%~20% 的通道处于这种半死状态,主要集中在中后段。这直接导致你的模型看起来有 89M 参数,实际有效容量可能只有 70M 出头。

为什么会坍塌?我的理解是:掩码预训练的任务是重建被遮住的区域,模型会倾向于用少数几个"通用模板"通道去覆盖大部分情况,因为这样最省事。那些没被选中当模板的通道,在梯度竞争里逐渐被压制,最后就成了摆设。通道之间的响应强度严重不均衡,强者恒强,弱者恒弱。

3.2 GRN 的数学形式与直觉解释

GRN 全称 Global Response Normalization,翻译过来是全局响应归一化。它的计算分三步,输入是形状为(N, H, W, C)的特征图:

第一步,全局聚合。对每个通道单独算它的 L2 范数,把空间维压成一个标量:

g_i = sqrt( sum_{h,w} X_{i,h,w}^2 )

得到的是一个长度为 C 的向量,代表每个通道在这张图上的"总响应强度"。

第二步,归一化。用通道自己的强度除以所有通道强度的均值:

n_i = g_i / ( mean_j(g_j) + eps )

这一步的关键在于,它是一个跨通道的相对比较。如果一个通道响应远低于平均,它的 n_i 会接近 0;高于平均,n_i 会大于 1。

第三步,校准。用 n_i 去缩放原特征,再加一个可学习的偏置和残差:

Y = gamma * (X * n_i) + beta + X

gamma 和 beta 是逐通道的可学习参数,初始化为零。初始化成零意味着训练开始时 GRN 是恒等映射(Y = X),不会干扰预训练初期的收敛。随着训练推进,模型自己决定要放多大。

这一套下来,弱通道会被进一步压制(因为 n_i 小),强通道会被放大——听起来好像加剧了不均衡?关键在于梯度:弱通道因为被压制,会产生更大的梯度信号去调整 gamma 和 beta,模型有机会把它们拉回来。同时,gamma 从零开始学,相当于给每个通道一个独立的、可学习的重要性开关,打破了原先"强者恒强"的路径依赖。

可以这么类比:原来的情况是一间教室里老师只点几个熟面孔回答问题,其他人越来越沉默。GRN 相当于每次课前先统计一下每个人的发言量,然后按比例调整话筒音量——原本声音小的人,音量旋钮的调节空间反而更大。

实现代码如下,注意维度处理:

import torch import torch.nn as nn class GRN(nn.Module): """Global Response Normalization, 输入格式 (N, H, W, C)""" def __init__(self, dim, eps=1e-6): super().__init__() self.eps = eps self.gamma = nn.Parameter(torch.zeros(1, 1, 1, dim)) self.beta = nn.Parameter(torch.zeros(1, 1, 1, dim)) def forward(self, x): # 第一步:通道级 L2 范数,(N,1,1,C) gx = torch.norm(x, p=2, dim=(1, 2), keepdim=True) # 第二步:除以通道平均值 nx = gx / (gx.mean(dim=-1, keepdim=True) + self.eps) # 第三步:校准 + 残差 return self.gamma * (x * nx) + self.beta + x

这里有个极易踩的坑:gx.mean(dim=-1)是对通道维求平均,不是对空间维。如果你写成dim=1,得到的是空间平均,整个逻辑就崩了,模型还是能训,但效果会掉一截,而且你不会报错。我当初就是因为这个丢了两周时间,最后靠打印中间张量形状才发现。

3.3 GRN 插在哪一层:四个候选位置的实测对比

GRN 不是随便塞的。ConvNeXtV2 的 block 结构大致是这样:

输入 → 7×7 depthwise conv → LayerNorm → 1×1 升维(4×) → GRN → GELU → 1×1 降维 → LayerScale → 残差

GRN 放在升维之后的 4× 通道空间里,这是作者对比了多个位置之后选出来的。我自己的实验结论和论文基本一致,这里把四个候选位置的对比列出来:

位置参数量开销效果备注
升维后、激活前8C最好论文采用,通道多、归一化粒度细
升维后、激活后8C略差非线性之后再归一化,信息已变形
降维后、残差前2C明显差通道数少,归一化的区分度不够
depthwise 之后2C最差空间特征刚出来,统计意义不强

规律很清楚:通道数越多的地方,GRN 越有效。因为它的本质是通道间的竞争归一化,通道维度太小的话,区分度不够,反而变成噪声。

另外补充一个实测细节:GRN 的 gamma/beta 参数量是 8C(升维后),在 Tiny 规模下每层大概 2K 参数,整体加起来不到总参数的 0.5%,开销可以忽略。但它必须逐层加,只在几层加效果不明显。我试过只在每个 stage 的最后一层加,特征坍塌的缓解幅度从 70% 掉到 30% 左右。

注意:GRN 和 LayerNorm 的角色不冲突。LayerNorm 在通道维做标准化,是"把每个样本的每个位置拉到零均值单位方差";GRN 是在空间维聚合之后做跨通道竞争,两者关注的角度不同,可以共存。

4. 动手复现:从数据管线到预训练脚本

4.1 环境与依赖,以及我建议的替代路线

原始实现依赖稀疏卷积库,这类库安装起来相对麻烦,对 CUDA 版本、编译工具链都有要求。我列一下关键依赖:

# 基础环境(示例,版本按自己环境调整) pip install torch torchvision pip install timm # 提供 ConvNeXtV2 的推理结构 pip install --no-build-isolation <sparse-conv-lib>

如果稀疏卷积库在你的环境里装不上(这在某些云主机上很常见),有两条替代路线:

路线 A:用掩码乘法近似。普通卷积 + 每层输出后重新乘掩码。实现简单,能跑通,但泄漏依旧存在,预训练收益大概只有完整版的六到七成。适合做可行性验证。

路线 B:降低掩码密度 + 加大 patch。如果泄漏的主要来源是感受野跨越掩码边界,那就让掩码块大一些,边界占比就低。把 patch 调到 64、掩码比例提到 0.7,泄漏的相对影响会小一些。这是个"绕过"而不是"解决"的思路,胜在零依赖。

路线 C(我推荐的):不自己跑预训练,直接用官方发布的 V2 权重做微调。预训练那 800 轮不是谁都烧得起的,如果只是想在自有数据上拿个好结果,从公开权重出发做微调,性价比高得多。自建预训练只在两种情况下值得:数据领域和自然图像差异极大,或者你要的模型规模官方没提供。

4.2 稀疏卷积块的实现骨架

下面这段是我实际用的简化骨架,保留了核心逻辑,去掉了工程优化。你可以先照着跑通,再逐层优化:

import torch import torch.nn as nn class MaskedDepthwiseConv(nn.Module): """带掩码约束的深度可分离卷积(简化示意版)""" def __init__(self, dim, kernel_size=7): super().__init__() self.conv = nn.Conv2d( dim, dim, kernel_size, padding=kernel_size // 2, groups=dim, bias=True, ) def forward(self, x, mask): # mask: (N,1,H,W),1 表示可见,0 表示被遮 x = x * mask # 输入侧先清零 y = self.conv(x) # 关键:输出侧再用掩码约束,阻断可见信息向遮罩区域扩散 return y * mask, mask

这段代码的作用是在每层卷积前后都用掩码卡一道,把信息扩散的速度降到最低。它做不到子流形稀疏卷积那种"完全不产生输出"的严格约束,但比什么都不做强很多。真正上生产还是建议用稀疏卷积库。

再补一个数据管线上的关键点:掩码必须在 GPU 上生成。如果你在 DataLoader 的 worker 里做掩码再传过来,每次都要传一张额外的掩码张量,I/O 翻倍。正确的做法是只传图像,掩码在训练循环里用同一个随机种子生成,保证同一张图的前向和 loss 计算用的是同一套掩码。

def random_mask(batch_size, h, w, patch, ratio, device): gh, gw = h // patch, w // patch num_patch = gh * gw num_keep = int(num_patch * (1 - ratio)) # 每个样本独立生成 noise = torch.rand(batch_size, num_patch, device=device) ids = noise.argsort(dim=1) keep = torch.zeros(batch_size, num_patch, device=device) keep.scatter_(1, ids[:, :num_keep], 1.0) keep = keep.reshape(batch_size, 1, gh, gw) keep = keep.repeat_interleave(patch, dim=2).repeat_interleave(patch, dim=3) return keep

这段代码有几个细节值得说:用argsort而不是torch.rand > threshold,是因为后者在比例接近极端值时数量不稳定,可能多一个少一个,导致不同 batch 之间的掩码数不一致,torch.stack或 reshape 的时候就报错。repeat_interleave把 patch 级的掩码展开到像素级,比F.interpolate的最近邻插值更直观也更快。

4.3 GRN 的维度对齐与常见错误

前面给了 GRN 在 NHWC 下的实现。如果你的代码库是 NCHW(PyTorch 默认),有两个选择:一是在 block 内部 permute 到 NHWC 做 GRN 再 permute 回来,二是直接写一个 NCHW 版本。后者效率更高:

class GRN_NCHW(nn.Module): def __init__(self, dim, eps=1e-6): super().__init__() self.eps = eps self.gamma = nn.Parameter(torch.zeros(1, dim, 1, 1)) self.beta = nn.Parameter(torch.zeros(1, dim, 1, 1)) def forward(self, x): gx = torch.norm(x, p=2, dim=(2, 3), keepdim=True) # (N,C,1,1) nx = gx / (gx.mean(dim=1, keepdim=True) + self.eps) # 注意这里是 dim=1 return self.gamma * (x * nx) + self.beta + x

两个版本行为完全一致,但 NCHW 版省掉了两次 permute,在 4× 通道空间上能省下可观的显存搬运时间。实测在 Tiny 规模下,每个 block 能省 8% 左右的前向时间。

还有一些容易忽略的点:

gamma/beta 一定要初始化成零,不是一,也不是随机。初始化成一的话,训练初期 GRN 会对特征做一次较强的缩放,配合较大的学习率很容易炸。初始化成零意味着它从"什么都不做"开始,渐进式地学会该做什么。

eps不能取太小。如果你把 eps 设成 1e-12,在某些通道全零的情况下会出 NaN。1e-6 是个稳的值。

不要在推理时冻结 gamma/beta。有些人为了"稳定"把它们设成不可训练,这等于废掉了 GRN 的校准能力。它的作用恰恰是通过训练调整每个通道的重要性。

4.4 预训练超参与显存估算

这一块是我觉得最需要提前算清楚的,因为 FCMAE 的显存占用和普通训练不太一样——它同时存在稠密张量和稀疏张量。

先给个粗算公式。以 Base 规模(通道从 128 递增到 1024)为例,预训练阶段显存主要由三部分构成:

组成估算方式Base 规模下的量级
参数 + 梯度 + 优化器状态参数 × 4(fp32 全套)89M × 4 ≈ 1.4 GB
稠密激活值(可见部分)批大小 × 各 stage 特征图与批大小线性相关
稀疏索引与元数据可见点数量 × 每点若干字节通常为稠密的 10%~20%

关键结论是:掩码预训练的显存占用其实比同规模的全图训练更低,因为前向只在 40% 的位置上算。但反过来,稀疏运算的 kernel 效率不如稠密卷积,吞吐可能只有稠密版的 60%~70%。也就是说,你省了显存,但没省时间。

批大小 4096 这个配置在小显存上肯定跑不动,用梯度累积。具体做法是把累积步数设成4096 / 单卡批大小,但要注意:梯度累积和批归一化的统计量不是一回事,LayerNorm 不受影响,但你如果改了归一化方式就要重新算。另外累积的时候学习率要按等效批大小缩放,别用单步批大小。

warmup 一定要留够。40 轮看起来夸张,但稀疏卷积的梯度分布比稠密训练更不均匀,前几轮 loss 抖动会很大。我用 20 轮 warmup 试过一次,第 5 轮就出现 loss 尖峰,虽然最后收敛上来了,但最终精度比 40 轮的低了 0.4 个点。

层间学习率衰减(layer-wise lr decay)这个技巧在预训练里很关键。深层学习率大、浅层学习率小,衰减系数 0.65 左右。理由是浅层学的是通用纹理特征,预训练之后不需要大变;深层学的是语义,需要更多调整空间。

4.5 微调阶段:从预训练权重到下游任务

微调和预训练之间有几个参数必须换掉,这是很多人第一次跑会搞错的地方:

第一,学习率要降一个数量级。预训练用 1.5e-4,微调大概 5e-5。因为预训练权重已经是一个不错的解,学习率太大会把它冲掉。

第二,层间学习率衰减要放松。预训练用 0.65,微调用 0.8。预训练时希望浅层稳定,微调时任务变了,浅层也需要适当调整。

第三,drop path 要重新调。预训练阶段通常设置得比较低(0.1 左右),微调时根据数据量调,数据少就调高(0.2~0.3),数据多可以降到 0.05。

第四,去掉解码器。微调时只加载编码器部分,解码器的权重直接丢弃。如果你用load_state_dict会报 key 不匹配,用strict=False或者手动过滤前缀。

还有一个隐蔽的坑:归一化层的统计量要不要重新估?LayerNorm 没有 running mean/var,不存在这个问题。但如果你在某个环节用了 BatchNorm,务必在微调开始时重新统计,不要沿用预训练的值——预训练时输入是带掩码的,统计量分布和完整图像不一致。

微调轮数上,ImageNet 级别的数据大概 100 轮就够,你自己的小数据集通常 30~50 轮,配合早停。判断收敛的信号看验证集精度而不是训练 loss,预训练权重初始化之后训练 loss 会降得很快,但过拟合也来得快。

5. 实测结果解读与下游落地

5.1 分类任务上的收益到底来自哪里

把 V1 和 V2 放一起比,同规模下 V2 普遍有 0.5 到 1.5 个百分点的提升,规模越小提升越明显。Nano 这个级别能到 1.5 个点以上,Base 大概 1 个点,Large 就只有 0.5 左右。

这个规律值得琢磨。为什么小模型收益大?我的理解是两点:一是小模型的参数冗余少,特征坍塌带来的损失更致命,GRN 把那些半死通道救活,边际收益自然高;二是小模型的容量有限,更依赖预训练提供的先验,FCMAE 学到的表征质量提升对小模型帮助更大。

但要注意,这个对比的前提是两边都做了自监督预训练。如果你是把 V2 的预训练权重和 V1 的监督训练权重比,那差距会大得多,参考意义反而不大。真正公平的对照是:同样的预训练轮数、同样的数据、同样的微调流程,只换架构。

另外一个被低估的点是预训练轮数的边际效应。400 轮到 800 轮,Base 规模大概还能涨 0.8 个点;800 轮到 1200 轮,只涨 0.2 个点左右。所以如果你的算力有限,400 轮是个性价比拐点,别硬冲 800。

5.2 检测和分割任务上的适配要点

分类任务的提升不一定能平移到密集预测任务上,这是常识。ConvNeXtV2 在检测和分割上的表现,我的观察是提升幅度比分类还略大一点,尤其是分割。

原因是 FCMAE 学到的表征更强调空间连续性——它是全卷积的,没有 ViT 那种把图像切碎再拼回去的操作,特征图的空间结构保持得更好。这对需要精细定位的任务(语义分割、实例分割)是天然优势。

具体到落地,有几个适配点:

骨干输出层要多尺度融合。ConvNeXtV2 有四个 stage,输出 stride 分别是 4/8/16/32。检测框架通常需要这四层,分割框架需要后三层。直接取最后一层会丢掉小目标的信息。

归一化层的位置要检查。有些检测框架会在骨干后面接 FPN,FPN 里如果用了 BatchNorm,和骨干里的 LayerNorm 混用会有分布不一致的问题。我一般统一改成 GroupNorm 或 LayerNorm,牺牲一点速度换稳定。

输入分辨率要重新调。预训练是 224,检测通常是 800 或者更大。分辨率变化后,7×7 卷积的相对感受野变小了,需要适当增加训练轮数让它适应。这一步不调的话,检测精度会掉 1 个点左右。

GRN 在推理时不能去。有些人为了加速推理,把 GRN 折叠进前面的卷积,这在数学上可以做(因为 gamma 是逐通道的缩放),但会丢掉 beta 的偏置和残差项。如果一定要折叠,记得把残差单独保留。

5.3 小数据场景下这套方案的性价比

这是我最想强调的一点,因为大部分人不是在做 ImageNet。

假设你有 2 万张标注图像,任务明确,那你的选择大概是三条路:从零训练、用公开预训练权重微调、自己跑自监督预训练。ConvNeXtV2 相关的方案在第三条路上的价值,取决于成本和收益的比值。

我给个粗略的经验值:自建预训练的成本,大约相当于把微调轮数乘 8 到 10 倍。800 轮预训练 vs 100 轮微调,就是这个比例。所以问题变成:这多花的 8 倍算力,能不能换来足够的精度提升?

我的实测结论是:如果数据领域和自然图像差异不大(比如普通商品、日常场景),直接微调公开权重就够了,自建预训练提升在 0.3 个点以内,不值。但如果领域差异很大(工业缺陷、遥感、显微镜图像),自建预训练能带来 2 到 5 个点的提升,那就非常值。

判断标准很简单:先用公开权重微调一版,看验证集精度和训练 loss 的差距。如果微调很快过拟合(训练 loss 一直降、验证 loss 第 10 轮就回升),说明预训练表征和你的数据不匹配,自建预训练有空间;如果两者同步下降,说明预训练表征迁移得很好,别折腾了。

6. 常见问题与排查清单

6.1 训练不收敛或者 loss 卡住

这是最高频的问题,我把遇到过的情况整理成一张表:

症状大概率原因处理办法
loss 前 10 轮剧烈震荡warmup 太短 / 学习率太大warmup 拉到 40 轮,学习率减半
loss 降到某个值就不动了掩码比例过高 / 解码器太强掩码降到 0.5,解码器减到 1 层
loss 一直很高且不降掩码泄漏 / 归一化统计量被污染检查每层是否都加了掩码约束
前几轮正常,中期突然发散学习率调度有断层检查 cosine 的起始值是否连续
loss 正常但微调效果差特征坍塌 / 预训练轮数不足加 GRN,轮数拉到 400 以上

第二条"loss 停在某个值"最容易被误判成收敛了。判断方法是同时看重建的可视化结果:如果重建出来的是模糊的平均色块,那是真卡住了;如果细节清晰只是 loss 不降,那可能是 loss 的量纲问题(比如逐 patch 归一化没做对)。

6.2 特征坍塌的自查方法

这个诊断不需要复杂工具,几行代码就行:

def check_channel_collapse(model, loader, threshold=0.01, device='cuda'): """统计各层接近零响应的通道比例""" stats = {} hooks = [] def make_hook(name): def hook(module, inp, out): # 按通道算平均绝对响应 v = out.abs().mean(dim=(0, 2, 3)) dead = (v < threshold).float().mean().item() stats.setdefault(name, []).append(dead) return hook for name, m in model.named_modules(): if isinstance(m, nn.Conv2d): hooks.append(m.register_forward_hook(make_hook(name))) model.eval() with torch.no_grad(): for i, (x, _) in enumerate(loader): if i >= 20: break model(x.to(device)) for h in hooks: h.remove() return {k: sum(v)/len(v) for k, v in stats.items()}

如果某一层的死通道比例超过 15%,基本可以确认有坍塌。健康的值应该在 5% 以下。

发现坍塌之后怎么办?先确认 GRN 有没有加对位置、gamma 是不是从零初始化。如果都对还没改善,试试把预训练轮数拉长——坍塌在中后期更明显,短程预训练反而看不出来。再不行就检查数据增强,过度激进的增强(比如极端的色彩抖动)会让模型倾向用少数通道,加剧不均衡。

6.3 吞吐和显存调优的实操记录

稀疏运算的效率是个老大难。我做过几组对比,结论如下:

第一,稀疏度在 60% 掩码时性能最好。掩码率太低(信息多)稀疏加速不明显,掩码率太高(信息少)稀疏张量的索引开销占比上升,反而不划算。0.6 是个甜点。

第二,批大小对稀疏运算的效率影响很大。稀疏卷积的 kernel 需要把不同样本的可见点拼起来算,批越大,拼接的并行度越高,GPU 利用率越好。批大小从 128 提到 512,吞吐能提升 40% 左右。小批量跑稀疏预训练是纯亏。

第三,梯度检查点是个好选择。预训练的激活值占显存大头,开梯度检查点能用 30% 的速度换 50% 的显存。考虑到预训练本来就要跑很久,多 30% 时间通常可以接受,换来的是批大小能翻倍,总体是赚的。

第四,混合精度要小心。稀疏卷积的某些实现在半精度下有精度问题,索引溢出会导致段错误。建议用 bf16 而不是 fp16,bf16 的动态范围更大,不容易出问题。

6.4 迁移到自有数据集上的那些坑

最后说几个我在实际项目里踩过的坑,都是那种文档里不会写、但一踩就卡住的:

坑一:图像尺寸不是 224 的整数倍。预训练的 patch 划分依赖特征图的整除关系,你的图像如果尺寸奇特,缩放的时候要么裁掉一部分,要么补边。我建议统一缩放到 224 或 256 再中心裁剪,别用随机裁剪——预训练学到的空间先验会被破坏。

坑二:类别极度不均衡。预训练阶段不受影响,微调阶段要用重采样或者类权重。但注意,用类权重的话,学习率要适当降低,否则少数类的梯度会爆炸。

坑三:数据量小于 5000 张时,别动浅层。冻结前两个 stage,只训后两个 stage 加分类头。全量微调一定过拟合,而且会破坏预训练学到的通用特征。冻结之后精度的损失通常只有 0.5 个点,但稳定性提升明显。

坑四:验证集的构建要独立。自监督预训练虽然没有用标签,但如果你预训练的数据和验证集重叠了,精度会虚高。我见过有人直接把全部数据拿去做预训练,再切分训练/验证,结果报出来的数字好看但不真实。预训练数据里不能包含验证集的任何图像,这条必须守住。

坑五:不要盲目相信论文里的超参。论文是 ImageNet 规模调出来的,你几万张的规模直接套,大概率不合适。我的做法是先跑三组不同学习率的短程实验(各 20 轮),看哪条曲线最好,再在那个基础上做完整训练。多花三天,省下的是两周的返工。

我个人在实际项目里的体会是,ConvNeXtV2 这套东西真正的门槛不在模型结构,而在稀疏运算的工程落地和超参的适配。结构照着改就行,代码也不长,但稀疏卷积库在不同环境下的表现差异很大,同一份代码在两台机器上跑出来的吞吐可能差一倍。所以我的建议是:先用掩码近似版把整个流程跑通、把下游任务接上、确认收益方向是对的,再花时间去死磕稀疏卷积的性能。顺序反过来,很容易在环境配置上耗掉热情。另外,如果你只是想在自己的任务上拿到好结果,从公开的 V2 权重出发做微调,再针对领域数据做一轮轻量的继续预训练(比如 50 轮),这条路的性价比往往比从头跑 800 轮高得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进