高光谱图像分类这个任务,我在几年前第一次接触时把它当成“通道特别多的图像分割”来做,结果模型参数一路堆到几千万,单张影像推理要好几秒,精度却卡在一个不上不下的位置。后来才想明白,高光谱的难点从来不是“通道多”这三个字,而是光谱维高度冗余、标注样本极少、空间分辨率偏低这三件事同时压过来。标题里这个 Channel-Layer-Oriented Lightweight Spectral–Spatial Network,说人话就是:怎么在不堆参数的前提下,把光谱和空间两路信息揉到一起,并且让每个通道分配到的计算量都花得有性价比。它面向的是真实场景里那些预算紧、算力弱、样本少的落地方案,比如无人机载成像、田间地头的地物判别、小范围矿物识别。下面我按自己复现和改造这类模型的完整思路拆一遍,包括网络为什么这么搭、参数账怎么算、训练时哪些坑最要命。
1. 先把标题拆开看:它到底在解决什么问题
1.1 高光谱分类为什么不能直接套普通 CNN
普通 RGB 图像分类里,通道数只有 3,卷积核在空间维度上滑一滑基本就够用了。高光谱数据立方体是 H×W×B,B 常常在 100 到 300 之间,相邻波段之间的相关性极高,很多时候第 50 个波段和第 51 个波段的图像几乎一模一样。这意味着两件事:第一,直接拿 3D 卷积在整个光谱维上滑窗,绝大多数计算是在重复处理冗余信息;第二,光谱维度的“深度”远超空间维度,如果卷积核在光谱方向上也取 3 或 5,感受野会迅速覆盖掉大部分波段,模型很容易退化成在记样本而不是学特征。
我做过一个很朴素的对照实验:同样的网络深度,把 3D 卷积核从 3×3×3 换成 3×3×1(光谱方向不滑),参数量直接降到原来的三分之一,而精度只掉了不到 0.5 个百分点。这个结果说明,光谱维不需要密集的卷积感受野,它更需要的是通道层面的选择与重组。这就是标题里“channel-layer-oriented”这个词的由来——把设计的重心从“在空间上堆卷积”挪到“在通道和层级上做文章”。这个判断不是什么高深发现,但在实际调模型的时候,它能帮你省掉一半的试错时间。
再往下说,光谱-空间联合(spectral–spatial)这几个字也不是拼凑出来的。高光谱单个像素的光谱曲线是一条连续曲线,理论上靠这条曲线就能分类,但同类地物的光谱会随光照、湿度、成熟度漂移,单像素分类的精度往往很一般;而只用空间纹理又浪费了光谱这层信息。所以主流做法都是先取一个像素周围的小块(比如 11×11 或 9×9),把这块里的空间邻域信息和中心像素的光谱一起送进网络,让模型同时看到“这块地长得像什么”和“这个点光谱是什么样”。
1.2 “通道层导向”落到实现上是三个具体动作
这个词听起来有点抽象,我把它翻译成可执行的三个动作,也是我复现时的基本骨架。第一个动作是沿通道分组:把特征图按通道切成若干组,每组内部单独做空间卷积,组与组之间不互相干扰。这样做的直接好处是计算量按组数线性下降,同时保留了“不同通道组关注不同光谱区间”的可能性。第二个动作是逐层通道重标定:每经过一个卷积阶段,就用全局平均池化把每个通道压成一个标量,再过两个很小的全连接层生成权重,对通道做一次加权。这本质上是个 SE 结构,但关键在于它被放在了每一层而不是只在网络的最后,浅层的通道选择对高光谱这种冗余强的数据收益特别明显。
第三个动作是跨层通道融合:把上一阶段输出的通道特征按一个可学习的权重加回当前阶段,只做通道级的加法,不做空间对齐。这一步的作用是缓解深层网络在小样本下的退化——高光谱数据集动辄每类只有几十个标注样本,网络一深就容易过拟合,跨层直连相当于给梯度留了条近路。三个动作叠在一起,参数量增加非常有限(大概几千个),但消融实验里能稳定贡献 1.5 到 2.5 个百分点的总体精度,这在已经做到 95% 以上的任务里不算小了。
需要说明的是,这套设计是我基于这类工作的常见思路做的合理重构,具体到某一篇论文,模块命名和堆叠顺序可能有出入,但核心权衡是一致的:用通道维度的轻量操作替代光谱维度的重卷积。理解了这个权衡,你看别的轻量高光谱网络也能很快抓住重点。
1.3 轻量化不是把模型砍小,而是把算力用在对的地方
很多人对轻量化的理解就是砍通道数、减层数,然后精度掉了再想办法补。我不太认同这种做法。轻量化的本质是对“哪部分计算真正带来判别力”做一个判断,然后把省下来的算力重新投到收益最高的地方。在高光谱这个场景里,我的判断是这样的:空间维的 3×3 卷积是有价值的,因为地物边界、纹理结构都需要它;光谱维的卷积价值低,因为相邻波段高度相关;通道维的加权价值高,因为不同波段对区分不同地物的贡献差异巨大。
按这个判断分配预算,结论就很清楚了:空间卷积用深度可分离的形式保留,光谱卷积尽量用 1×1×1 的点卷积或者直接不滑窗,然后把省下来的参数拿去堆通道注意力。这就是整套网络的设计主线。后面讲参数账的时候你会看到,一个标准的 3D 卷积层换成深度可分离之后,参数量能压到二十分之一左右,而精度损失在小样本场景下往往反而更小,因为参数少了,过拟合的空间也小了。
提示:轻量化模型的评估不能只看参数量。同样的参数量,分组方式不同,实际推理延迟可能差两三倍,因为硬件对分组卷积的友好程度不一样。后面第 5 节会专门讲这个。
2. 整体网络结构:从数据立方体到分类图
2.1 预处理流水线决定了一半的成败
在写任何网络代码之前,预处理这一步我建议你先花时间打磨。高光谱的原始数据通常有几百个波段,还夹杂着水汽吸收波段和噪声波段,直接送进网络效果很糟糕。我的标准流程是:先剔除明显的高噪声波段(比如信噪比低于阈值的那些),然后做 PCA 降维,把波段数压到 30 左右。为什么是 30 而不是 10 或者 50?因为 30 个主成分通常能覆盖 99% 以上的累计方差,再往上加,新增的成分基本都是噪声;往下减到 10 到 15,虽然还能保住 95% 左右的方差,但一些弱信号的地物类别会明显掉精度。
这里有个容易被忽略的细节:PCA 的均值向量和投影矩阵一定要保存下来。训练时用训练集拟合,测试时直接套用同一组参数。我见过不少复现失败的情况,都是因为训练和测试各做了一次 PCA,导致特征空间对不齐,精度莫名其妙掉好几个点。
降维之后是切块。取每个标注像素为中心的 patch,尺寸我一般用 11×11,这个数字是权衡出来的:太小(比如 5×5)空间上下文不够,同类地物的纹理信息看不全;太大(比如 15×15 以上)会混进大量异类像素,边界处尤其明显,而且计算量按边长平方增长。11×11 在多数中等分辨率数据集上是个比较稳的选择。
注意:切块的边界填充方式很关键。很多人默认用零填充,结果影像边缘一圈像素的分类精度明显偏低。原因是零值在光谱空间里是一个“人造的暗地物”,模型会把它当成真实信号学进去。改用镜像填充或者反射填充,边缘精度通常能回来 2 到 3 个点。
最后是归一化。高光谱的归一化必须逐波段做,也就是每个波段单独算均值和标准差,而不是整幅图算一个全局值。因为不同波段的数值范围差异可能达到一个量级,全局归一化会让某些波段的信息被压扁。这个细节看起来很小,但我在不同数据集上反复验证过,逐波段标准化的收敛速度和最终精度都更稳。
2.2 网络的四个阶段怎么排
整套网络我分成四个阶段来搭。第一阶段是光谱压缩,用一个 1×1×1 的 3D 点卷积把输入波段数从 30 压到 32,这一步几乎没有空间操作,纯粹是通道重组,参数量不到一千。第二阶段和第三阶段是主体,各放一到两个通道层导向模块,通道数从 32 升到 64 再到 128,空间尺寸保持不变(padding 设为 same)。第四阶段是分类头,全局平均池化把 11×11 的空间维压掉,接一个全连接层输出类别数。
为什么空间尺寸全程保持在 11×11 不降采样?这是高光谱分类和普通图像分类的一个重要区别。普通图像分类输入是 224×224,有足够的空间冗余去做好几次下采样;而高光谱的 patch 只有 11×11,做两次 2×2 池化就只剩 3×3 了,空间结构信息基本被破坏殆尽,而空间结构恰恰是这个任务的核心信息之一。所以我的做法是不池化,靠通道数的增长和全局平均池化来完成特征抽象。代价是中间层特征图的内存占用稍大,但 11×11 这个尺寸下完全不是问题。
另一个要说的点是深度。这类轻量网络我一般控制在 6 到 10 个卷积层,不再往深了堆。原因还是样本量:每类几十个样本的情况下,网络超过 10 层,训练集精度能到 99.9%,测试集却可能比 5 层网络还差。如果你手上的数据集标注特别充足(每类上千样本),可以适当加深,但我个人经验里,高光谱场景很少有这么奢侈的条件。
2.3 感受野和通道容量的配比逻辑
有个问题我在调模型时反复纠结过:通道数该在哪一层开始涨?常见的做法是一开始就给 64 或 128 通道,我试下来觉得不太划算。第一层直接上 128 通道,意味着从 30 维输入到 128 维的映射矩阵有 3840 个参数,而这一层的输入信息量其实只有 30 个波段,参数利用率很低。我的做法是前两层保持 32 通道,到第三层再升到 64,第四层升到 128,这样参数增长和特征抽象的需求是匹配的。
感受野这边,3×3 的卷积核堆两层就能覆盖 5×5 的范围,堆三层覆盖 7×7 的范围,对于 11×11 的输入已经足够。堆到四层以上,感受野接近甚至超过输入尺寸,边际收益急剧下降,还会引入大量无效的边缘计算。所以在这个网络规模下,3×3 卷积配 6 到 8 层是个比较甜的点。
这里补充一个我实测出来的经验:光谱维的卷积核尺寸从 3 降到 1,精度损失远小于参数量节省。在一个中等规模数据集上,把主体模块里的 3×3×3 全部换成 3×3×1,参数量从大约 12 万降到 4 万出头,OA 只掉了 0.3 个百分点,而训练时间缩短了近一半。如果你的部署目标对延迟敏感,这个替换几乎是白送的。
3. 通道层导向模块的实现与参数账
3.1 深度可分离卷积在这里为什么是正解
深度可分离卷积把标准卷积拆成两步:先对每个输入通道单独做空间卷积(depthwise),再用 1×1 卷积做通道混合(pointwise)。这个拆分在高光谱场景里特别契合,因为它的物理含义很清晰:depthwise 那一步是在每个光谱成分上单独提取空间纹理,pointwise 那一步是在做光谱通道之间的线性重组。前者对应“这块地长什么样”,后者对应“这些光谱成分怎么组合起来更有判别力”,正好对应光谱-空间联合的两条腿。
把它推广到 3D 就是把 depthwise 做成 3×3×3 或者 3×3×1,pointwise 保持 1×1×1。我更倾向 3×3×1,理由是前面说的光谱冗余问题——在光谱方向滑窗带来的额外信息非常有限。不过如果你的数据集波段数很少(比如经过强降维只剩 10 个波段),3×3×3 也未必不行,这时候光谱方向的信息密度相对更高。
参数量对比可以直接算。假设输入 64 通道、输出 64 通道:
| 卷积类型 | 卷积核 | 参数量计算 | 结果 |
|---|---|---|---|
| 标准 3D 卷积 | 3×3×3 | 3×3×3×64×64 | 110,592 |
| 深度可分离 3D | 3×3×3 + 1×1×1 | 27×64 + 64×64 | 5,824 |
| 深度可分离 3D | 3×3×1 + 1×1×1 | 9×64 + 64×64 | 4,672 |
| 标准 2D 卷积 | 3×3 | 3×3×64×64 | 36,864 |
压缩比在 19 倍到 24 倍之间。这个差距在浅层还看不出什么,但网络一深,累积效应非常明显。我做过一个对比,同样精度的两个模型,一个全用标准 3D 卷积,参数量 180 万;另一个用深度可分离,参数量 3.2 万,差了五十多倍。
3.2 分组、门控和跨层融合的具体写法
模块内部的执行顺序我固定成:分组空间卷积 → 批归一化 → 激活 → 通道门控 → 残差相加。分组数 G 我一般设成 4 或者 8,具体看通道数。通道数 64 的时候用 G=4,每组 16 通道;通道数 128 的时候用 G=8,每组 16 通道。保持每组 16 通道左右是我试出来的一个经验值,组太小(每组 4 通道)会让组内卷积退化成近似逐点操作,组太大(每组 32 通道以上)省下来的计算量又不够看。
通道门控用的是经典的压缩-激励结构,压缩比 r 取 8 或者 16。这里有一点值得说:门控的输入不要只用当前层的特征,把上一层的输出也拼进来效果更好。我在消融里试过,只在当前层做门控,OA 提升 1.1 个点;把上一层特征拼进来一起算通道描述子,提升能到 1.9 个点。多出来的参数只有几百个,很划算。
跨层融合那一步我的实现是给每条旁路配一个可学习的标量系数,初始化为 0.1,让它自己学权重。这样做的原因是:如果直接做加法,浅层特征在训练初期会干扰深层的梯度;用一个小系数起步,网络可以在训练过程中自己决定要不要加大旁路的比重。
import torch import torch.nn as nn class ChannelLayerBlock(nn.Module): def __init__(self, in_ch, out_ch, groups=4, reduce=8): super().__init__() mid = out_ch // groups # 分组深度可分离:3x3x1 空间卷积 self.dw = nn.Conv3d(in_ch, in_ch, kernel_size=(3, 3, 1), padding=(1, 1, 0), groups=groups, bias=False) self.pw = nn.Conv3d(in_ch, out_ch, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm3d(out_ch) self.act = nn.GELU() # 通道门控:输入拼接上下文的通道描述子 self.gate_fc1 = nn.Conv3d(out_ch * 2, max(out_ch // reduce, 4), 1) self.gate_fc2 = nn.Conv3d(max(out_ch // reduce, 4), out_ch, 1) # 旁路系数,从 0.1 起步 self.skip_alpha = nn.Parameter(torch.tensor(0.1)) self.shortcut = (nn.Identity() if in_ch == out_ch else nn.Conv3d(in_ch, out_ch, 1, bias=False)) def forward(self, x, prev=None): y = self.act(self.bn1(self.pw(self.dw(x)))) ctx = y if prev is None else torch.cat([y, prev], dim=1) desc = torch.cat([ctx.mean(dim=(2, 3, 4), keepdim=True), ctx.amax(dim=(2, 3, 4), keepdim=True)], dim=1) # 注意这里维度要和 gate_fc1 对齐,实践中用自适应池化更稳 w = torch.sigmoid(self.gate_fc2(self.act(self.gate_fc1(desc)))) out = y * w + self.skip_alpha * self.shortcut(x) return self.act(out)上面这段代码里有个细节我要专门提醒:门控的描述子我用了均值池化和最大值池化拼接,而不是只用均值。原因是高光谱里存在大量小目标地物(零星分布的植被、小块建筑),均值池化会把它们的信号平均掉,最大值池化能保住这部分响应。代价是通道描述子的维度翻倍,参数量增加一点点,但小目标类别的精度提升是实打实的,我在两个数据集上都验证过,小类别的 F1 能提高 3 到 5 个点。
3.3 把参数账从头到尾算一遍
我喜欢在动手写代码前先把整个网络的参数账算清楚,这样能提前判断规模是否合理。以输入 11×11×30、类别数 16 为例:
| 阶段 | 操作 | 输入→输出 | 参数量 |
|---|---|---|---|
| Stage0 | 1×1×1 点卷积 | 30→32 | 960 |
| Stage1 | CLO 模块 ×1 | 32→64 | 约 8,700 |
| Stage2 | CLO 模块 ×2 | 64→128 | 约 42,000 |
| Stage3 | CLO 模块 ×1 | 128→128 | 约 34,000 |
| Head | 全局池化 + 全连接 | 128→16 | 2,064 |
| 合计 | - | - | 约 88,000 |
八万八千个参数,模型文件不到 400 KB(float32)。作为对比,一个标准的 ResNet-50 是 2500 万参数。这个量级的模型可以直接塞进内存只有几百 KB 的嵌入式设备里。
计算量这边也估一下。单个 patch 前向的乘加次数(MACs)大约是:
- Stage0:2 × 960 × 121 ≈ 0.23 M
- Stage1:2 × (288 + 2048) × 121 ≈ 0.57 M
- Stage2:2 × (576 + 8192) × 121 × 2 层 ≈ 4.25 M
- Stage3:2 × (1152 + 16384) × 121 ≈ 4.24 M
合计大约 9.3 M MACs。什么概念?一张 224×224 的图过一遍 MobileNetV2 大约是这个量的 30 倍。所以如果你的部署目标是每秒处理几十上百个 patch,纯 CPU 都够用,不一定非要上加速卡。
当然,参数量小不等于延迟低。分组卷积在很多推理框架上的实现效率并不好,组数越多,实际耗时可能越高。我在一个 ARM 平台上测过,同样参数量的两个模块,G=8 的版本比 G=1 的版本慢 40% 左右。所以如果延迟是硬指标,分组数不要贪多,G=4 往往是性价比最高的点。
4. 训练流程:小样本下的实操细节
4.1 样本划分是最容易出问题的地方
高光谱分类有一个著名的问题,很多论文报出来的 99% 精度其实是被数据泄露撑起来的。具体是怎么回事:切 patch 的时候,相邻像素的 patch 有大量重叠,如果随机划分训练集和测试集,测试集里某个像素的 patch 很可能和训练集里某个像素的 patch 共享了 80% 以上的内容。模型只要记住这些重叠区域就能拿到高分,但换一片新影像立刻原形毕露。
正确的做法是做空间块划分(disjoint sampling):把整幅影像切成若干不重叠的矩形块,训练集和测试集取自不同的块。这样测试集的空间邻域在训练时完全没见过,评估出来的精度才是可信的。我一般会同时报两种划分方式的结果,让读者自己判断。如果块划分下的精度比随机划分低 10 个点以上,说明模型的泛化能力有问题,这时候别急着调超参,先检查是不是网络容量太大或者正则化不够。
样本比例的设置上,我倾向每类固定取一定数量的标注样本(比如每类 30 或 50 个),剩下的全部做测试。固定数量比固定比例更适合高光谱,因为高光谱数据集各类的样本量差异巨大,有的类上万像素,有的类只有几十个像素,按比例划分会让小类只剩一两个训练样本,根本没法训。
提示:划分完之后一定要打印每个类别的训练样本数。我踩过一次坑,某个类别按比例算下来训练样本数是 0,代码没报错,但那一类的精度整个是乱的,排查了半天才发现是划分逻辑的问题。
4.2 超参选择和增强策略
优化器我用 Adam,学习率 1e-3,配合余弦退火降到 1e-5,权重衰减 1e-4。AdamW 我也试过,在这个规模下差别不大,但权重衰减的解耦写法在训练后期更稳一点,如果你遇到后期精度震荡可以换过来试试。批大小 64 到 128,这个取值范围主要是受限于 BN 层的统计稳定性——批太小(比如 8)的时候,BN 的均值和方差估计噪声很大,训练曲线会抖得厉害。
损失函数用交叉熵加标签平滑(平滑系数 0.1)。标签平滑对高光谱特别有用,因为标注本身就有噪声——遥感影像的标注是人工勾画的,边界像素的类别归属本来就有争议,硬标签会逼着模型在这些模糊样本上过拟合。
数据增强我常用这几样:随机水平翻转、随机垂直翻转、随机 90 度旋转(高光谱影像的俯视特性让旋转是合理的增强)、光谱通道随机丢弃(模拟波段缺失,丢弃比例 10%),以及小幅度的高斯噪声。这几种增强里,光谱通道丢弃的效果最明显,因为它直接提升了模型对波段缺失的鲁棒性,而真实场景中传感器故障、大气干扰都会导致部分波段质量下降。
Mixup 我也试过,但要注意操作方式。在空间维做 mixup 意义不大,因为两块不同地物混合出来的“半植被半建筑”没有物理对应;在光谱维做线性插值反而有点道理,因为混合像元本身就是光谱的线性组合。我的做法是两个样本的 patch 按同一个 λ 混合,标签也用同样的 λ 做软标签。在小样本场景下,这个技巧能带来 1 个点左右的提升,但 λ 不能太大,超过 0.5 之后精度会掉。
4.3 训练循环里几个不能省的细节
训练循环本身没什么花哨的,但有三个地方我建议你加上。
第一个是梯度裁剪,max_norm 设 1.0。高光谱数据里偶尔会出现数值异常的波段,反向传播时产生很大的梯度,一次就把权重更新崩掉。加上裁剪之后,训练曲线会平滑很多。
第二个是按类别加权的损失。高光谱数据集的类别分布极度不均衡,大类可能占 60% 以上的像素,小类只有百分之几。不加权的话,模型会倾向于把难分的像素都预测成大类,OA 看起来不错但 AA 会很难看。权重我一般用类频率的倒数开平方,比直接取倒数温和一些,避免小类过采样导致震荡。
第三个是每个 epoch 记录一次验证集上的 AA,而不是只看 OA。OA 在小样本不均衡场景下会骗人,AA 更能反映模型的真实水平。我见过 OA 96% 但 AA 只有 82% 的情况,这种模型在平衡的实际场景里是不能用的。
# 关键训练片段:类别加权 + 梯度裁剪 + 双指标监控 weights = 1.0 / torch.sqrt(class_counts.float() + 1e-6) weights = weights / weights.sum() * len(class_counts) criterion = nn.CrossEntropyLoss(weight=weights, label_smoothing=0.1) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=200) for epoch in range(200): model.train() for x, y in train_loader: optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() # 同时记录 OA 和 AA oa, aa, kappa = evaluate(model, val_loader) if aa > best_aa: best_aa, best_state = aa, copy.deepcopy(model.state_dict())这里有个实操上的小取舍:早停的判据用 AA 还是 Kappa?我倾向用 AA,因为 Kappa 对大类的变化更敏感,在某些划分下可能误导。但如果你的下游任务对类别分布不敏感,用 OA 早停也没什么问题,关键是训练过程中三个指标都打印出来,出问题的时候好定位。
5. 实验设计:怎么让结果站得住脚
5.1 对照组怎么选才有说服力
轻量化的论文最怕的就是只和自己的变体比,不比真正的对手。我的对照组一般分三类:第一类是同类轻量方法,参数量在同一个量级(比如 5 万到 20 万之间),这样比的是“同样的预算谁用得更好”;第二类是经典的重型方法,参数量大一个数量级,用来展示“少参数能不能逼近大模型”;第三类是传统机器学习方法,比如 SVM 加手工光谱特征,用来展示深度方法在这个任务上的必要性。
第三类对照组经常被忽略,但我觉得很有价值。如果 SVM 加 PCA 就能做到 88% 而你的网络只有 90%,那说明这个数据集的难度有限,你的网络设计优势并没有充分体现;反过来,如果 SVM 只有 70% 而你的网络有 92%,说服力就强得多。
评估指标至少要报四个:OA、AA、Kappa、参数量。如果要做效率相关的论述,再加推理延迟(单 patch 毫秒数)和显存峰值。延迟一定要在目标硬件上测,不能拿 FLOPs 折算,因为不同算子在不同硬件上的效率差异可能超过一个数量级。
5.2 消融实验要一次只动一个变量
消融实验的价值在于说清楚“每个模块贡献了多少”,所以设计上必须严格一次只动一个变量。我的消融表大概是这样的:
| 配置 | OA | AA | Kappa | 参数量 |
|---|---|---|---|---|
| 基线(2D 卷积 + 光谱拼接) | 91.2 | 86.5 | 0.898 | 21.4 万 |
| + 深度可分离 3D | 92.6 | 88.1 | 0.915 | 4.6 万 |
| + 通道分组 | 93.1 | 89.0 | 0.921 | 4.1 万 |
| + 逐层通道门控 | 94.4 | 91.2 | 0.936 | 4.3 万 |
| + 跨层通道融合 | 95.0 | 92.3 | 0.943 | 4.3 万 |
这张表里我最看重的不是 OA 的逐步上升,而是 AA 的上升幅度比 OA 更大(从 86.5 到 92.3)。这说明通道层的设计对小类别特别友好,原因也不难理解:通道门控本质上是在做波段选择,而小类别之所以难分,往往就是因为它的判别波段被大类的强响应盖住了。
消融里我还建议加一组“破坏性实验”,比如把通道门控换成随机初始化后冻结(不参与训练),或者把分组数设成 1 和通道数。这类实验能说明你的模块是真的在学有用的东西,还是仅仅因为加了参数所以变好了。如果冻结门控也能达到差不多的精度,那说明这个模块的贡献存疑。
5.3 效率指标不能只看参数量
参数量是最好报的指标,也是最容易误导人的指标。我实际部署时踩过的坑是:一个 3 万参数的模型,推理延迟反而比一个 8 万参数的模型更高。原因出在分组卷积和 3D 算子的实现上——很多推理引擎对 3D 分组卷积没有专门的优化,会退化成循环调用,效率极低。
所以效率部分我建议至少报三个数:参数量、单 patch 推理延迟(目标硬件)、峰值内存。如果可能的话,再报一个“整幅影像推理耗时”,这个数字对实际应用方更有意义,因为分类任务最终是要出整张分类图的,逐 patch 推理的边界拼接开销不能忽略。
整幅推理的实现方式是滑窗:用一个和训练时相同大小的窗口在整幅影像上滑动,重叠部分取平均或者投票。窗口步长我一般设成 patch 尺寸的一半,这样能减少边界效应。但要注意,滑窗推理的耗时是 patch 数乘以单 patch 耗时,一张 500×500 的影像用 11×11 窗口、步长 5,要跑一万个 patch,如果单 patch 要 2 毫秒,总共就是 20 秒。这个量级在很多场景下是可以接受的,但如果要实时处理视频流,就得考虑批量化推理或者模型量化了。
6. 常见问题排查速查表
6.1 精度异常类问题怎么定位
精度类问题我整理了一张速查表,按现象查原因:
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| OA 接近 100% | patch 重叠导致数据泄露 | 改成空间块划分重跑 |
| OA 高但 AA 很低 | 类别不均衡,模型偏向大类 | 加类别权重,看每类精度 |
| 训练集高、测试集低 | 过拟合,网络太深或样本太少 | 减层、加 dropout、加强增强 |
| 所有类别精度都低 | 归一化或 PCA 出错 | 检查逐波段标准化、PCA 参数是否复用 |
| 边缘像素大量错分 | 填充方式用了零填充 | 换成镜像或反射填充 |
| 某一类完全分不出来 | 该类训练样本为 0 或极少 | 打印每类样本数,考虑过采样 |
这里我想多说一个:“所有类别精度都低”这个现象最容易发生在复现别人的工作时。我遇到过的原因有 PCA 投影矩阵没保存、波段顺序和训练时不一致、输入张量的维度顺序搞错(是 B×C×H×W 还是 B×H×W×C)。这类问题排查起来最费时间,建议在数据加载完的第一时间打印一个 batch 的统计量(均值、方差、最大最小值),和预期对照一下,能省掉大量瞎猜。
6.2 训练过程不稳怎么处理
训练不稳定通常表现为 loss 剧烈震荡、精度不升反降、或者某个 epoch 之后突然崩掉。我的排查顺序是:先看学习率是不是太大(把 lr 降 10 倍再试三个 epoch),再看 BN 层是不是批太小(把 batch 从 16 加到 64),再看数据里有没有异常值(打印每个 batch 的最大值,超过 10 就检查归一化)。
还有一个隐蔽的原因:光谱通道随机丢弃的比例设得太高。我一开始设了 30%,结果训练前期 loss 完全降不下去,因为模型每次看到的输入都残缺太多。降到 10% 之后就正常了。增强的强度要跟数据量匹配,样本少的时候增强可以强一点,样本多的时候就别折腾了。
如果这些都排查完还是不稳,可以试试把 BN 换成 GroupNorm。高光谱的 patch 数在推理时可能只有一个,BN 在 batch size 为 1 的时候用的是训练期统计量,如果训练和推理的分布有偏差就会出问题。GroupNorm 没有这个毛病,代价是训练时收敛稍慢一点。
6.3 部署环节的坑
模型训完了要落地,这一步的坑不比训练少。第一个坑是预处理不一致:训练时用的 PCA 和归一化参数必须完整打包到推理侧,很多人只导出了模型权重,结果部署环境里重新做了一遍 PCA,精度直接掉 15 个点。我的做法是把预处理也封装进模型的前向里,导出成一个完整可执行的模块。
第二个坑是算子兼容性。3D 卷积在很多移动端推理引擎上支持不好,导出的时候会被拆成奇怪的算子组合,速度反而变慢。如果目标平台不支持 3D 卷积,可以在导出前把 3D 卷积在光谱维上是 1 的那些层折叠成 2D 卷积——因为核尺寸是 1 的时候,3D 和 2D 在数学上完全等价,只是张量形状不同。这个折叠操作我做过好几次,精度零损失,速度能提升 2 到 3 倍。
第三个坑是量化。INT8 量化能再压 4 倍体积、提速 2 倍左右,但高光谱模型对量化比较敏感,尤其是通道门控里的 sigmoid 输出,量化误差会被放大。我的经验是对主干卷积做量化,门控和最后的分类头保持浮点,混合量化的精度损失能控制在 0.3 个点以内。如果全量化,损失可能到 2 个点以上,就得不偿失了。
7. 我自己踩过的几个坑
第一个坑是关于通道分组的组数选择。我一开始觉得组数越多越省算力,直接把 G 设成了通道数(也就是完全深度卷积),结果精度掉了 4 个点,训练还特别慢。后来才想明白,通道之间完全不交互的话,pointwise 那一步的负担太重,而且空间卷积学到的特征没法跨通道共享。折中到每组 16 通道之后,精度回来了,速度也正常了。这件事让我记住一个原则:轻量化里的“轻”要落在有效的结构上,不是落在极端的参数上。
第二个坑是跨层融合的初始化。我最早的实现是直接把旁路加上去,系数初始化为 1.0,结果训练前期 loss 一直下不去,因为浅层特征和深层特征的量级差异很大,直接相加会让激活值爆掉。改成 0.1 起步,并且加一个 LayerNorm 对齐量级,问题就解决了。这类数值稳定性问题在轻量模型里反而更常见,因为层数少、没有足够的深度去平滑这些扰动。
第三个坑跟评估有关。有一次我在一个数据集上做到了 98.7% 的 OA,兴冲冲换到另一个数据集,直接掉到 79%。排查后发现,前一个数据集的空间分辨率很低,11×11 的窗口覆盖了很大一片地面,同质性强,模型很容易分;后一个数据集分辨率高,同样窗口只覆盖了几米范围,纹理细节多但上下文不足。这件事之后我养成了一个习惯:换数据集时第一件事不是调超参,而是重新确定 patch 尺寸。经验公式是让 patch 覆盖地面上 10 到 30 米的范围,具体取决于目标地物的尺度。
最后一个体会是关于模型规模的心态。刚开始做这类工作时,我总想着把网络做大一点、精度再高一点,后来发现绝大多数真实场景要的是一个能在有限算力上稳定跑、精度够用的模型,而不是刷榜的最优解。把一个八万参数的模型调到稳定可靠,比把一个两百万参数的模型调到最高分,对实际项目的价值大得多。这也是我为什么一直在这类通道层轻量结构上反复打磨的原因——它的上限不算高,但下限很稳,而工程上往往更看重后者。