Delta Layer原理与实现:修复类增量学习中旧类遗忘的偏置校正方案

发布时间:2026/10/1 15:43:46
Delta Layer原理与实现:修复类增量学习中旧类遗忘的偏置校正方案
前阵子做类增量学习实验训完新增10类后模型新类Top-1准确率冲到88%还没高兴两秒回头一测旧类12类准确率从91%掉到64%。如果你也折腾过增量学习肯定懂这种重学新类、忘光旧类的憋屈。后来我把基线从LwF换成带Delta Layer的校正方案旧类准确率回到83%新类也没被拖垮。这篇就顺着这个脉络把Delta Layer的原理、实现、训练细节和适用边界完整过一遍。这篇文章适合正在做类增量学习、持续学习或者只是遇到新分类器把旧分类器挤掉这类偏置问题的同学。你需要的基础是熟悉PyTorch基本训练循环、知道softmax logits是什么。我会把Delta Layer涉及的关键数学直觉补全所以哪怕你只是见过名字也能看懂。1. 增量学习里旧类崩、新类猛的根因得从分类头说起1.1 新类分类器权重范数为什么总是偏大大多数人遇到旧类掉点第一反应是backbone特征被新类带偏了。但我把backbone彻底冻结、只训练新增的分类头之后旧类准确率依然往下掉。这时候才意识到问题出在最后一层分类器的权重尺度上而不是特征本身。用一个简化到极致的数学视角来解释backbone输出的特征记为f(x)分类器第k类的权重向量是w_k那么该类别的logit就是w_k^T f(x)。softmax会把logit取指数再归一化所以w_k的范数直接决定这个类在竞争中占多大优势。增量训练时旧类的w_old是增量前冻结好的范数停在当时的水平。新类的w_new从零或随机初始化开始训练在交叉熵损失下正确类的logit会被持续推高。多轮迭代下来w_new的范数普遍比w_old大。如果旧类范数大概是5新类范数涨到8同样的特征输入下新类logit就比旧类高3个点softmax指数后相当于多了十几倍的概率优势。这就是论文里说的列向聚类偏差column-wise clustering bias——不是某一个类偏心而是新类这一整列分类器权重集体压过了旧类。1.2 蒸馏能保留概率分布的形状但救不了logits的绝对尺度有人会问LwF不是已经用蒸馏损失保住旧类了吗为什么旧类还是崩蒸馏损失约束的是旧类概率分布的形状。假设旧类logits是[3, 2, 1]新类logits是[8, 7, 3]各自softmax之后再归一化两者在分布上的相对排序确实可能很像。但注意旧类的绝对数值被新类的大logits挤压了softmax之后旧类三个类的总概率可能从0.8掉到0.2。蒸馏温度越高输出分布越平滑这个尺度差异越容易被掩盖。温度一低模型虽然能记住旧类内部的相对排序却保不住旧类整体对外的竞争力。所以蒸馏是个必要不充分条件。它能让旧类不遗忘顺序但没法阻止新类抢走所有概率质量。要解决偏置必须从logits的绝对尺度上动手。1.3 现象本质是特征空间和分类器权重之间的几何错位把backbone的输出想象成高维空间里的点云每个类有一个原型方向。旧类的方向在增量前已经稳定了新类一进来因为权重范数偏大等于给新类原型方向加了一根加长杆。整个分类决策边界被这根长杆带着旋转旧类区域被挤压。既然是几何错位自然可以想到在特征空间里做一次几何校正把新类方向拉回合理的位置。这正是Delta Layer思路的来源不重新训练backbone也不只改分类器输出的偏置项而是直接在校准的输入特征上做文章。2. Delta Layer的校正逻辑给网络装一个可学习的残差垫片2.1 Delta Layer到底加在哪、长什么样Delta Layer的结构并不复杂它就插在backbone和分类头之间公式长这样z f(x) delta(f(x))其中f(x)是backbone输出的原始特征delta是一个可学习的线性层输出维度和特征维度一致。也就是说最终送往分类器的特征不再是原来的f(x)而是原始特征加上一个小的校正量。我自己的PyTorch实现是这么写的class DeltaLayer(nn.Module): def __init__(self, dim): super().__init__() self.linear nn.Linear(dim, dim) # 关键的初始化权重和偏置全部置零 nn.init.zeros_(self.linear.weight) nn.init.zeros_(self.linear.bias) def forward(self, x): return x self.linear(x)如果你担心bias全部置零会让模型表达能力受限其实不会。训练开始后梯度会正常流动bias很快就不再是零了只是起点必须保证输出等于输入。2.2 为什么必须零初始化残差连接而不能用普通全连接我第一次实现时偷懒以为随便加个线性层就行结果旧类准确率直接掉到接近随机。原因很好理解普通全连接层初始化之后linear(f(x))是一个随机方向的向量加到原始特征上等于把backbone精心学出来的表示空间搅乱了。增量前的旧分类头是在原始特征空间上训练的特征一旦被随意搅动旧分类器从第一步开始就废了蒸馏损失也无从谈起。零初始化残差的设计从第一轮前向开始就保证了z f(x)。模型在第一条路径上完全保留旧能力梯度只能让delta层慢慢学习需要修正多少而不是从头学习特征应该长什么样。这个起点极其关键决定了Delta Layer是一种微调校正而不是重新学习。2.3 Delta Layer和BiC到底哪里不一样做增量学习的人应该都听过BiCBias Correction它在训练完新类之后用一小部分验证数据统计新旧类logits的均值差异然后拟合一个线性偏置项去校正logits。Delta Layer和它最核心的区别是作用位置不同。BiC只在分类器输出上做全局标量校准旧类logits乘一个alpha再加一个beta。这种校准方式简单高效但自由度很低。它只能把旧类整体放大或缩小无法修正不同类别方向上的非均匀偏差。Delta Layer是在分类器之前加了一个dxd的线性变换可以对特征做旋转、缩放、组合。虽然Delta Layer训起来要动更多参数但它的校正能力在逻辑上更接近问题的本质。3. 手写Delta Layer结构改造、训练流程与代码骨架3.1 模型装配旧分类头冻结新分类头可训练增量学习的普遍做法是新任务到来时把分类头的输出维度从num_old_classes扩展到num_old_classes num_new_classes。旧类的权重保留并冻结新类的权重随机初始化并参与训练。这个流程和Delta Layer完全兼容。模型装配的代码骨架如下# feat_dimbackbone输出的特征维度例如ResNet18最后一层是512 # num_old_classes增量前已有类数 # num_new_classes本批新增类数 backbone load_backbone() # 加载增量前的backbone for p in backbone.parameters(): p.requires_grad False old_fc load_old_classifier() # 增量前的旧分类头 for p in old_fc.parameters(): p.requires_grad False delta DeltaLayer(feat_dim) # 可学习的Delta Layer # 新分类头旧类部分直接拷贝新类部分随机初始化 full_fc nn.Linear(feat_dim, num_old_classes num_new_classes) with torch.no_grad(): full_fc.weight[:num_old_classes] old_fc.weight.data full_fc.bias[:num_old_classes] old_fc.bias.data # 冻结旧类部分只允许新类部分更新 full_fc.weight.requires_grad_(False) full_fc.bias.requires_grad_(False) full_fc.weight[num_old_classes:].requires_grad_(True) full_fc.bias[num_old_classes:].requires_grad_(True)注意这里old_fc和full_fc都要保留。old_fc用来生成蒸馏目标full_fc用来生成当前模型的预测logits。两个分类头共享同一个backbone权重只是分别对应增量前特征空间和经过Delta Layer校正后的特征空间。3.2 训练循环新类硬标签 旧类软标签训练时一个batch里同时包含新类样本和从旧类buffer里抽样出来的少量旧类样本。前向计算分两条路径旧模型路径backbone输出原始特征再过old_fc得到旧类的soft target。新模型路径backbone输出特征过delta再过full_fc得到所有类的logits。其中旧类部分要和旧模型的soft target做蒸馏新类部分和新类的真实标签做交叉熵。核心训练代码for x, y in loader: x, y x.cuda(), y.cuda() # 旧模型蒸馏目标不经过delta层 with torch.no_grad(): old_logits old_fc(backbone(x)) # 只有旧类的logits # 新模型delta层校正后的特征 feat backbone(x) z delta(feat) logits full_fc(z) # 切分新旧类logits old_logits_new_space logits[:, :num_old_classes] new_logits logits[:, num_old_classes:] # 新类硬标签交叉熵 loss_ce nn.CrossEntropyLoss()(new_logits, y) # 旧类温度蒸馏 T 2.0 loss_distill ( T * T * nn.KLDivLoss(reductionbatchmean)( nn.functional.log_softmax(old_logits_new_space / T, dim1), nn.functional.softmax(old_logits / T, dim1) ) ) loss loss_ce lambda_distill * loss_distill loss.backward()有一点必须强调old_logits和old_logits_new_space分别来自两个不同特征空间。前者是原始backbone特征过旧分类头后者是delta校正后的特征过同一个旧分类头。蒸馏迫使delta层对旧类的投影方向和原始特征保持一致这等于告诉模型你想调整可以但旧类判别方向千万别动。这是整个方法稳定性的来源。3.3 训练流程参数更新范围和轮次控制参数更新的范围很窄只更新delta的参数和full_fc新类部分的参数。backbone和full_fc旧类部分全程不动哪怕backbone的requires_grad本来就是False也要在optimizer构造时明确过滤。优化器建议这样配trainable_params list(delta.parameters()) list(full_fc.parameters())由于full_fc只有新类部分的requires_grad为True直接传进去即可。实测下来新类头的学习率可以比delta层大3到5倍。我这里用的是delta_lr1e-3new_fc_lr4e-3优化器选择Adam。轮次控制在10到20轮不是越多越好。跑太久delta层会逐渐偏离恒等初始点旧类蒸馏损失虽然能压住一部分但整体经验是20轮以后收益递减、风险递增。4. 实测表现与最容易忽略的训练细节4.1 我的复现实验三组方案的对比我在一个CIFAR-100风格的实验上对比了几种方案初始10类然后每轮新增10类backbone用冻结的ResNet18特征旧类buffer只保留5%的样本。结果大致如下方法新类Top-1旧类Top-1平均Top-1LwF无校正88%64%76.0%BiC输出层校正85%72%78.5%Delta Layer本文实现84%83%83.5%表格是我个人复现的趋势参考不代表严格基准。但能看到一个很典型的现象LwF的新类准确率最高因为它毫无保留地偏向新类BiC通过输出偏置救回了一部分旧类但新类也损失了几个点Delta Layer的新类略低于LwF旧类却明显稳住了平均准确率是三组里最高的。4.2 影响最大的三个参数学习率、蒸馏温度、旧类样本配比第一是学习率。如果delta层的学习率和新类头一样大delta层会快速离开恒等初始点旧类蒸馏损失很难拉住它旧类准确率出现锯齿状震荡。我的经验是新类头lr可以是delta层的3倍左右这样新类学得快delta层只做缓慢校正。第二是蒸馏温度。T2到3之间是比较常见的区间太小的T会让软标签过于尖锐蒸馏损失对logits绝对尺度的容忍度变得很差太大的T会把所有类的分布都抹平蒸馏失去区分性。我目前固定T2。第三是旧类样本配比。即使有buffer也不建议让旧类样本在batch里占太高比例否则训练会偏向旧类新类学不上去。我通常让旧类样本占每个batch的20%到25%对应总数里的5%到10%。如果没有旧类buffer纯蒸馏也能跑只是收敛速度明显变慢旧类准确率方差会变大。4.3 训练时观察哪些曲线来判断delta层是否学偏调试增量学习模型最忌讳只盯着一张准确率曲线。我每次训练都会额外记录三组数值旧类logits的均值、新类logits的均值、蒸馏损失。训练刚开始时delta层是恒等映射旧类和新类的logits均值差应该接近0。随着训练推进新类logits均值会上升但旧类logits均值应该保持平稳。假如你看到旧类logits均值在持续走低说明delta层输出的特征方向正在偏离旧类判别面这是旧类遗忘的前兆。等到准确率掉了再回头调整就晚了。另一个实用经验是如果新类和旧类logits均值差超过1.5可以预判最终旧类Top-1会明显下滑需要马上降低delta层的学习率或者增大蒸馏权重。5. Delta Layer的适用边界以及帮你们提前排雷5.1 什么场景下Delta Layer特别值得用Delta Layer的定位决定了下述场景中它的性价比很高backbone必须冻结、不能为了新类重新训练全部网络的项目。这种情况下模型可动参数极少Delta Layer几乎是成本最低的校正手段。类别增量学习而且分类头是简单的线性层。如果分类器本身是复杂的非线性结构在校准层后面重新训练分类器的代价会变大。旧类样本极少甚至没有的情况。Delta Layer不依赖大量旧类数据蒸馏损失加零初始化残差就能在无样本场景下运转。5.2 什么场景下它会显得很吃力Delta Layer不是万能的我踩过不少坑后发现它在下面几种情况里效果会明显变弱域增量问题。新旧任务之间特征分布差异过大线性残差层能做的空间变换有限不足以把两个域的空间错位拉回来。新类数量远大于旧类。新类部件太多会让full_fc可训练参数暴增同时蒸馏损失还要拼命拽住旧类方向两者冲突增大训练容易顾此失彼。训练过程中backbone没有被冻结。虽然Delta Layer还是有效但backbone每更新一次特征空间就整体漂移一次delta层只能跟在后面做最后一层的局部补偿纠错能力会打折扣。5.3 一个容易踩的坑Delta Layer后面别随便接ReLU我见过不少复现代码在delta层后面加ReLU激活理由很朴素神经网络里不都得有点非线性吗但实际上这一下就把方法带偏了。Delta Layer的校正量既可能是正也可能是负。比如原始特征在某个维度的值是2校正量是-0.5最优输出应该是1.5。如果你接ReLU负校正量被直接截断成0模型就失去了调低某个维度响应的能力只能靠其他维度上升来补偿。训练出来的效果往往是旧类logits整体偏高或偏低准确率始终上不去。我实际的链路是backbone输出特征 - delta层线性输出 - 分类头。原论文的核心思路里也强调它是一个校准向量场不需要额外非线性。5.4 Delta Layer和样本回放、权重对齐的搭配Delta Layer和样本回放是很好的组合。回放5%到10%的旧类样本能显著稳定蒸馏训练让delta层找到更平滑的校正方向。权重对齐weight alignment是在分类头权重层面的操作和Delta Layer作用在不同层可以同时用。我的建议是先用Delta Layer把基础偏置修掉再看分类头权重是否还有明显的新旧范数差如果有再叠加权重对齐。最后说点实际体会。我最近几个增量实验基本都会先在backbone后面挂一个Delta Layer作为标配因为它真的便宜——只多训练一个d×d的小矩阵显存和训练时长的增加都很少。最让我受用的是零初始化残差这个设计它保证任何增量方案的前几步不会把已有模型毁掉。如果你也遇到新类精度高但旧类崩盘的偏置问题可以先写几行代码把这个垫片加上跑完看旧类logits均值曲线大概率会找到一个稳定点。希望这篇对正在折腾增量学习的你有帮助。