去年我接手一个图像分类项目时,遇到一个非常典型的迁移学习问题:源域的标注样本和实际部署场景的图像在光照、视角、噪声水平上差了一大截。模型在源域测试集上表现优秀,一到目标域数据上准确率就明显下滑,而目标域又没有标注数据可以用来微调。当时我采取的关键技术路线,就是基于DANN的图像分类任务迁移学习,它用领域对抗的思路让模型在不接触目标域标签的情况下学出“域无关”的特征表示。这篇文章就把我从原理到实现、再到踩坑调参的完整过程整理出来,供做类似视觉任务的从业者参考。
如果你手头也有“训练环境一套,部署环境另一套”的问题,比如实验室拍的产品图要应用在产线实拍图、公开数据集训练的模型要迁移到自己收集的数据上,而且目标域没有标注,那这篇文章应该能帮你省下一大半试错时间。我会尽量把网络原理、梯度反转层的一行代码、训练时的隐藏坑和调试链路都讲透,保证每一步都能照着复现。
1. 为什么普通图像分类模型会在换场景后失灵:DANN要解决的核心问题
1.1 源域和目标域之间的分布偏移
任何图像分类任务都可以抽象成“学习一个从图像到标签的映射”,但大多数分类模型默认训练集和测试集来自同一个分布。在迁移学习里,我们把有标注、用来训练的数据所在的环境叫源域,把实际部署、通常没有标注的数据所在的环境叫目标域。
两个域之间的差异往往不是一句“风格不同”能概括的。举个例子:我负责的模拟任务是让模型识别产品外观缺陷,源域图像是在恒定光源、白色背景、固定拍摄角度下采集的;目标域则是现场自然光、背景杂乱、角度随机。表面上看都是“产品外观图”,但像素分布差很远。这种差异在领域自适应里叫协变量偏移,也就是输入边缘分布P(X)变了,但标签条件分布P(Y|X)基本不变。
更麻烦的是还有概念偏移,比如源域里“完好”和“缺陷”的界限比较清晰,到了目标域现场工艺变化后,某些本来不算缺陷的纹理被判定成了缺陷,标签的判定标准都变了。DANN这类无监督域自适应方法主要解决协变量偏移和一部分温和的概念偏移,它假定两个域共享相同的类别集合和相近的类别判定逻辑,但不是要求像素层面的完全对齐。
1.2 传统迁移方法为什么不够用
碰到“换场景掉点”这个问题,大多数人第一反应是先在目标域上收集几百张标注图,然后做微调。这个思路没毛病,但现实往往是目标域标注非常贵,甚至暂时无法标注。没有目标域标签时,常见替代方案有三种,我都试过,各有各的问题:
- 图像级风格迁移:用生成网络把源域图像转成目标域风格再训练,理论上简单,但实际上生成过程又引入了二次噪声,且很难保证生成后类别语义不丢失。
- 特征统计对齐:比如把源域和目标域特征图的均值、方差强行对齐到一致,通过BN层统计修正可实现,实现快,但它只对齐了低阶统计量,遇到复杂纹理差异基本不够用。
- 希尔的EDA之类传统流形对齐方法:适合手工特征和线性映射,放到深度特征上要么算不动,要么效果不如直接用深度网络端到端学习。
真正让我觉得“方向对了”的是对抗式领域自适应,也就是DANN的思路:不直接做像素对齐,而是在特征空间里让源域和目标域尽可能分不开。这样一来,分类所需的语义信息被保留,而与域相关的变化因素被主动丢弃。
这里要强调一个关键假设:如果源域和目标域本身类别结构不同,比如源域有“猫”但目标域只有“狗”,DANN的对抗机制会把两个域的差异全部视为“域差异”,从而把本应区分的类别差异也抹掉。所以在动手前,先确认任务确实属于“同类别、不同风格”的领域自适应,而不是开放集识别或零样本分类。
2. DANN的算法拆解:梯度反转层与三方网络博弈
2.1 网络结构:特征提取器、标签分类器和领域判别器
DANN的整个网络由三部分组成:特征提取器F、标签分类器C和领域判别器D。特征提取器是一个卷积网络,负责把输入图像编码成高维特征向量;标签分类器是一个接在特征后面的全连接层,负责输出类别概率;领域判别器也是一个全连接网络,输出“这个特征来自源域还是目标域”的二分类概率。
训练时,源域图像带着真实类别标签,目标域图像不带任何标签。对源域图像,我们正常计算分类损失,让C学会分类;对来自两个域的图像,我们给它们打上领域标签(源域=0,目标域=1),让D学会预测领域来源。关键在设计F的学习目标:它一方面要让C把类别分对,另一方面要反向让D分不出领域。
这三个组件的关系也可以理解成一场博弈:特征提取器像翻译官,它要把源域和目标域的图像都翻译成一种“通用语”;标签分类器是内容考官,只关心翻译里有没有保留类别信息;领域判别器是口音鉴别师,专门判断翻译过来的话到底是哪种口音。翻译官要做的,就是让口音鉴别师彻底猜不出来源,同时让内容考官给出正确的主题判断。
2.2 梯度反转层(GRL)的数学直觉
DANN之所以实现简单,核心就在于一个不起眼的层:梯度反转层(Gradient Reversal Layer,GRL)。这个层的前向传播完全什么都不干,直接把输入原封不动地传给下一层;但在反向传播时,它会把梯度乘以一个负的系数-λ,再传回去。
数学上可以写为:
- 前向:( G_{\lambda}(x) = x )
- 反向:( \frac{\partial G_{\lambda}}{\partial x} = -\lambda I )
λ是一个从0逐步增大到一个预设最大值(通常为1)的超参数。怎么理解这个负数的作用?如果没有GRL,领域判别器正常训练会得到一个能够区分源域和目标域特征的方向,特征提取器也会因为普通梯度传播而往“让领域更容易区分”的方向走。GRL把这一路梯度反号之后,特征提取器就会往反方向跑,让自己学到的特征反而让领域判别器更分不清。
λ从小到大的调度曲线我参考了DANN原论文的做法:
[ \lambda = \frac{2}{1+\exp(-10 \cdot p)} - 1 ]
其中p是当前训练进度,从0线性增长到1。这样设计的意图很清晰:训练早期,领域对抗的强度几乎为零,特征提取器先以正常的分类任务为主,把基础语义特征学好;训练逐步推进,对抗信号逐渐加大,特征提取器才开始慢慢把“域相关的特征”从表示中剥离出去。我自己的经验是p可以按全局iteration计算,而不是按epoch,这样在数据集大小不均衡时更稳定。
2.3 损失函数与整体训练目标
DANN的总目标可以写成两部分损失之和:
[ \mathcal{L} = \mathcal{L}_C - \lambda \mathcal{L}_D ]
其中 (\mathcal{L}_C) 是源域图像在标签分类器上的交叉熵损失, (\mathcal{L}_D) 是源域和目标域图像在领域判别器上的二分类交叉熵损失。需要注意的是,(\mathcal{L}_D) 前面那个负号并不是手动写的,而是GRL在反向传播时天然实现的。实际写代码时,领域判别器本身的损失还是正常的,只不过它的梯度经过GRL后会反向影响特征提取器。
我在这里最开始也绕晕过:既然总损失是减号,为什么代码里又把两个loss直接相加?其实相加减只差在梯度流方向。PyTorch里如果直接在总loss上加负号,会把领域判别器的梯度也弄反,导致D完全不收敛。正确做法是分别计算两个loss,各自反向传播到自己的网络路径上,而特征提取器在接收领域判别器梯度时经过GRL自动取负。理解这一点,后面调试才不会在loss组合上栽跟头。
从优化角度看,这个对抗目标非常接近一个两层博弈的均衡:当特征提取器学到的特征完全令领域判别器无法区分时,领域判别器损失理论上是0.5(二分类随机猜),这时特征提取器已经找到了一个“域不变”的特征空间。但实际训练中很少能达到完美均衡,所以我们要关注的不是D的loss越低越好,而是特征提取器和D两者保持一个相对稳定的对抗状态。
3. 从零搭建一个DANN图像分类模型:数据、网络与训练细节
3.1 我用来验证DANN的模拟任务
为了让实验可控,我构建了一个模拟域自适应的图像分类任务。源域用的是某公开手写字符识别数据集,目标域则是同一批字符图像经过高斯噪声、随机旋转、对比度拉伸和轻微透视变换之后的版本。从人眼看上去,两个域明显是同一类东西,但风格差异足够大,普通模型直接跨域测试会掉点10个百分点以上。这个任务非常适合验证DANN,因为它没有引入真实场景里的复杂漏标问题,梯度方向更清晰。
我还额外把目标域随机分成两部分:一部分保留标签作为“有监督微调的上限对比”,另一部分只拿图像作为无标签目标域。这样做的目的是为了做消融对比,能知道无监督DANN到底能逼近有多好。如果你也想复现,强烈建议保留一小部分目标域标注作为你的“验算基准”,但注意这部分千万不能进入无监督训练流程。
3.2 网络结构选择与关键配置
DANN对底层的特征提取器没有硬性要求。我的模拟任务里,因为输入是单通道小尺寸字符图,我用了一个四层卷积的特征提取器;如果你做更复杂的自然图像分类,把底层换成常见的残差网络结构也完全可行。下面是我当时用的组件配置:
| 组件 | 结构 | 说明 |
|---|---|---|
| 特征提取器F | 4层Conv+BatchNorm+ReLU,接全局平均池化 | 输出512维特征 |
| 标签分类器C | 两层全连接,中间ReLU和Dropout | 输出为类别数 |
| 领域判别器D | 三层全连接,中间ReLU和Dropout | 输出为2,源域/目标域 |
图中的关键trick是D不要做得比F更深更宽。领域判别器本质上要足够灵敏,但又不能太强,否则它会迅速找到一条特征提取器很难反制的捷径,导致对抗训练崩溃。我试过把D从三层加到六层,D的精度立刻冲上99%,但特征提取器无论怎么训都骗不过D,最终目标域分类准确率反而下降了。
超参数上,我使用SGD优化器,初始学习率0.01,动量为0.9,batch size为64。D和C使用同一个优化器,F也使用同一个优化器,但F的有效梯度有一部分来自GRL,因此相当于F的学习率相对D被λ缩放。原论文里还提到用专门的优化器调整学习率,我这里为省事共用一套也work,关键还是λ的调度要平滑。
3.3 GRL层和训练循环的PyTorch实现
GRL的自定义层代码非常简单,核心只在backward里对梯度乘负系数。下面是我在PyTorch里的写法:
import torch import torch.nn as nn from torch.autograd import Function class GradientReversalLayer(Function): @staticmethod def forward(ctx, x, lambd): ctx.lambd = lambd return x.clone() @staticmethod def backward(ctx, grad_output): return -ctx.lambd * grad_output, None # 方便调用 def grad_reverse(x, lambd): return GradientReversalLayer.apply(x, lambd)训练循环里,我构造了一个DANN类,forward过程分两路:源域图像经过F后得到特征,同时给C和D;目标域图像经过F后得到特征,只给D,不给C。
class DANN(nn.Module): def __init__(self, feature_extractor, classifier, domain_discriminator): super().__init__() self.F = feature_extractor self.C = classifier self.D = domain_discriminator def forward(self, src_img, tgt_img, lambd): src_feat = self.F(src_img) tgt_feat = self.F(tgt_img) src_logits = self.C(src_feat) src_domain = self.D(grad_reverse(src_feat, lambd)) tgt_domain = self.D(grad_reverse(tgt_feat, lambd)) return src_logits, src_domain, tgt_domain训练时按标准步骤来回传。这里要特别留意:目标图像只计算域判别损失,不要计算分类损失;源图像同时计算分类损失和域判别损失;GRL放在F输出到D的路径上,不能放在C路径上。许多第一次写DANN的人把GRL放到所有特征后面,结果分类损失也被取反,整个loss变成负数,模型直接原地爆炸。
我用的损失组合如下:
criterion_cls = nn.CrossEntropyLoss() criterion_domain = nn.BCEWithLogitsLoss() for src_img, src_label, tgt_img in train_loader: src_img = src_img.cuda() tgt_img = tgt_img.cuda() src_label = src_label.cuda() p = (global_step + 1) / total_steps lambd = 2.0 / (1.0 + torch.exp(torch.tensor(-10.0 * p))) - 1.0 src_logits, src_domain, tgt_domain = model(src_img, tgt_img, lambd) loss_cls = criterion_cls(src_logits, src_label) n = src_img.size(0) domain_label_src = torch.ones(n, 1).cuda() domain_label_tgt = torch.zeros(tgt_img.size(0), 1).cuda() loss_domain = criterion_domain(src_domain, domain_label_src) + \ criterion_domain(tgt_domain, domain_label_tgt) total_loss = loss_cls + loss_domain optimizer.zero_grad() total_loss.backward() optimizer.step()3.4 训练过程中的监控和可视化
训练DANN只看分类准确率是不够的,还要同时看领域判别器的准确率。如果领域判别器准确率很快接近100%,说明特征提取器还远没有学到域不变特征,当前对抗压力过大;如果领域判别器准确率一直卡在50%,说明两个域的特征已经基本混合,但此时还要检查分类准确率是否正常,如果分类同时也掉到接近随机,那很可能是特征崩塌而不是健康的混淆。
我日常监控四个指标:源域分类准确率、目标域分类准确率、目标域判别器准确率、分类损失。训练到后半段,目标域判别器准确率应该缓慢降到50%附近,而目标域分类准确率逐步回升。与此同时,我会用T-SNE把源域和目标域特征在训练中和训练后各画一次,直观看两个域的分布是否交叠。如果两张图依然能明显分成两团,说明对抗没有生效,需要回头检查GRL的梯度流。
4. 训练DANN最常见的四个坑:梯度反转、判别器过强、收敛震荡、维度崩塌
4.1 坑一:领域判别器训练太快,特征提取器被吊打
现象很典型:前几百个iteration,D的域分类准确率直接冲到95%以上,loss掉到很小,之后F无论怎么学,目标域分类准确率也就比随机略好。原因在于D的容量或学习率相对F过大,导致D总是能轻易找到区分两个域的简单特征维度,而F要通过高维空间的连续扰动来骗过D,难度被无限放大。
解决方式我按优先顺序整理如下:
- 削弱D:减少D的隐藏层宽度或层数,增大Dropout概率到0.5以上;
- 调整学习率比例:让D的学习率等于F学习率的一半甚至四分之一,而不是共用同一个学习率;
- 调低λ上限:如果峰值1太激,可以把λ上限压到0.5或0.6,训练稳定后再慢慢往上加;
- 给D加梯度惩罚:让D在特征空间上的判别边界平滑,避免它利用某些高频毛刺特征。
我最终选的是“把D的宽度从512降到256,同时学习率保持F的50%”,目标域准确率立刻回升了三个百分点。别小看这种工程微调,DANN一大半经验都在这种平衡上。
4.2 坑二:λ的调度节奏不对,收敛震荡甚至发散
如果λ从0加到1的速度过快,F会过早地把部分类别判别信息也当成域信息扔掉,表现为训练中段分类损失和域损失交替上升,loss曲线像在跳绳。这是DANN调参里最常见的震荡。
我一般把“warmup阶段”拉长,让前10%到20%的iteration完全不设对抗(λ=0),只训练普通的源域分类。这样F先学到一套相对稳定的初始特征,再开始对抗,相当于给对抗博弈一个更可靠的起点。之后λ的上升斜率再放缓,用余弦式的上升而不是线性上升,也能让训练稳定不少。
另外,如果batch size太小,比如16或8,D的梯度方差很大,也容易震荡。我建议图像分类任务至少使用32以上的batch,最好64起步。如果显存不够,可以用梯度累积,也能部分缓解。
4.3 坑三:特征空间坍缩,两个域“混淆”了但类别也分不清
有些时候目标域判别器准确率确实降到50%附近,但目标域分类准确率也同步崩了。这通常意味着F找到了一个“万能骗术”:它把两个域的特征都压缩到一个极小的区域,D当然分不出,但C也分不出。这就是特征维度崩塌。
这类情况在DANN里不少见,尤其是特征提取器后面接了全局平均池化、直接输出一维特征时。解决思路是让特征空间保持足够的结构:
- 在F的末端特征上施加谱归一化,约束特征表示的奇异值,不要坍缩到低秩子空间;
- 分类器和域判别器从不同层取特征,比如分类用最后一层,D用倒数第二层,让D无法指导F把所有层都压扁;
- 给F的特征做一个简单的L2正则,让特征范数不要过小。
我实际判断特征崩塌靠的还是T-SNE图:如果源域和目标域的颜色块重叠且都是一小坨,而正常情况应该是一簇一簇的,那就是崩塌了。把λ峰值降低、加特征范数正则,很快能看到特征恢复成几团类别簇,同时两个域在簇级别上互相渗透。
4.4 坑四:对调试链路没有全局认知,一上来就乱调参
这是最不值钱的坑。我见过很多人在D训不好时先去调学习率、λ、网络层数,最后调了半天也没有结论。DANN的调试应该是一条固定链路:先看loss曲线,然后看D的域准确率,再看特征可视化,最后做消融。中间任何一步都能定位问题方向。
| 现象 | 先检查项 | 大概率问题 |
|---|---|---|
| 分类loss不降 | 源域是否过拟合?F层先用正常分类训练 | 数据泄漏或学习率过大 |
| D的域准确率迅速飞升至99% | D的参数规模、dropout、学习率 | D过强 |
| 源域分类正常,目标域分类也正常但D准确率不降 | λ是否太小?GRL是否生效? | 对抗信号不足 |
| 源域和目标域都崩 | 特征范数、特征可视化 | 维度崩塌 |
| loss曲线剧烈震荡 | warmup、batch size、损失尺度 | 学习率过大或λ太快 |
每次改动只动一个变量,然后对比源域/目标域准确率曲线。DANN非常容易被“两个改动叠加”的幻觉骗过去,所以严格一点,一个一个变量过。
5. 实验结果对比与进阶空间:从DANN到更稳的域自适应方案
5.1 不同方案在模拟任务上的指标对比
我把几个方案放在同一套源域/目标域设置下做对比。普通模型只拿源域训练,目标域测试掉点很明显;上DANN后目标域准确率回升;再加上基础的数据增强和更稳的对抗调度,还能再涨一截。作为参考,我也给了“目标域有10%标注并做微调”的上限成绩。
| 方案 | 目标域测试准确率 | 备注 |
|---|---|---|
| 仅源域训练 | 78.4% | 直接跨域,分布偏移明显 |
| DANN(线性λ) | 84.1% | 基线版本 |
| DANN + 低容量D + warmup | 85.6% | 缓解判别器过强 |
| DANN + 数据增强 | 86.9% | 旋转、缩放、色彩抖动 |
| 目标域10%标注微调 | 88.2% | 有监督上限,仅作对比 |
从结果看,DANN在没有目标域标签的情况下,能逼近有10%标注微调的大部分收益;但距离有监督方法还是有差距。如果目标域是“完全不可标注”的场景,DANN作为无标签基线非常有性价比,后续再叠加别的技巧可以继续逼近有监督上限。
5.2 从DANN往前一步:条件对抗与伪标签
DANN本身是对特征整体做对抗,但在多分类任务里,不同类别的特征偏移方向往往不同,整体对抗会忽略这些类别条件。因此有了条件对抗域自适应(CDAN):把标签分类器输出的类别概率和特征拼接起来,再一起送入领域判别器。这样可以迫使不同类别的源域/目标域特征都对齐,而不是只在全局平均上对齐。
我当时在模拟任务上试过CDAN,目标域准确率又比DANN高了大约一个百分点。代价是计算量变大,且当分类器本身误差较高时,把不可靠的类别条件特征拼进去会给对抗带来额外噪声。所以我会先用DANN跑通,再换CDAN做精调。
另一个简单、效果直接的进阶组合是伪标签+自训练:用训练好的DANN模型对目标域无标签样本做预测,挑出预测置信度超过0.9的样本,给它们打上伪标签放进训练集,再从头训练一遍。这个方法能进一步逼近有监督上限,但要注意伪标签噪声会累积,如果目标域分布和源域差异特别大,置信度阈值要提高到0.95以上,否则越训越偏。
5.3 我的个人体会与选择建议
说了这么多,最后想分享一点我反复踩坑后形成的判断。DANN在图像分类任务里的价值,不是让我们追求一个完美的、一步到位的结果,而是它几乎提供了一个所有域自适应模型都绕不过的基线框架。结构简单、依赖少、容易理解、方便调试,这些特点比它本身的精度优势更重要。
如果你接到的任务里,目标域和源域差异集中在亮度、颜色、纹理这类低层视觉特征上,DANN的收益非常直接;如果差异主要是视角和布局的变化,那通常需要更强的空间特征对齐,可以考虑在F里加Transformer类结构,或者对数据做更强的几何增广;如果目标域出现了源域没有的类别,DANN不能解决,要换成开放集识别思路。另一个很实际的选择是,不要一开始就把DANN当最终方案,而是先把它作为跨域评估的baseline,跑出无监督上限,再决定要不要花力气去标注目标域样本。我一直觉得,无监督方法最大的价值不是“不标注”,而是帮我们判断标注多少才值得。这大概也是DANN在我项目里最实用的地方。