最近这两年做深度学习落地项目,我最怕听到的一句话就是“模型上线后效果不对”。明明训练集上准确率已经刷到97%,一换到新的采集设备、新的光照环境,或者换了标注渠道,准确率直接掉回70%上下。这种数据集分布不一致的问题,就是典型的域漂移。解决它的技术路线很多,但如果你不想重新收集大量标注数据,又希望模型自动学会“两个域通用的特征”,那对抗迁移学习几乎是绕不开的方案。
这篇文章要聊的DANN,全称是Domain-Adversarial Neural Network,中文一般叫“域对抗神经网络”。它最早由Yaroslav Ganin等人提出,思路特别巧妙:让网络一边做分类任务,一边通过对抗训练来忘掉数据来自哪个域。你不需要给目标域数据打标签,只需要提供目标域的图片或样本就行。本文会用PyTorch手写一份完整可运行的DANN代码,从原理、数据准备、模型搭建到训练调参、Bug排查,一步一步拆开讲清楚。
项目标题: “DANN实战:用Python手把手教你实现对抗迁移学习(附完整代码)” 关键词: DANN, Python, 对抗迁移学习, 完整代码 摘要描述: 用PyTorch从零实现域对抗神经网络,以MNIST旋转噪声版为目标域,完整演示对抗迁移学习从理论到代码的全过程,附可直接运行的完整代码和调参经验。
1. 理解DANN核心思路再动手:为什么对抗能让特征“去域化”
很多初学者拿到代码就直接跑,跑通之后其实并不清楚里面在干什么。等到换数据集、换任务场景时,立刻不知所措。所以我觉得有必要先用一整节把DANN的原理讲透。
1.1 域漂移是什么:一个能秒懂的现实场景
假设你做了一个手写数字识别模型,训练数据是标准MNIST,也就是“规规矩矩”写在白纸中间的数字。模型在测试集上表现不错,99%的准确率。可一旦你把模型部署到某个老旧扫描仪上传来的图像上,数字可能会歪、可能有倾斜、还可能带着噪点。同一个数字“7”,在标准MNIST里是垂直的,在扫描件里可能是向右歪了20度的。模型从来没见过歪着的“7”,它就会犯迷糊。
这种“训练集和测试集虽然类别相同,但数据分布不同”的现象,就叫域漂移。为了便于讨论,一般把有标签的数据集合称为源域,把没有标签、但实际部署时遇到的分布叫目标域。传统做法要么在目标域上重新标几百张数据做微调,要么用大量数据增强硬扛,但前者成本高,后者不一定覆盖真实分布。DANN选择了一条不同的路:网络自己学出对“来自哪个域”不敏感的特征。
1.2 DANN的三块积木:特征提取器、标签分类器、域判别器
DANN的结构不复杂,它就包含三个核心部件:
- 特征提取器:负责把原始图片压缩成一个特征向量。
- 标签分类器:基于特征向量预测样本的类别,比如数字0到9。
- 域判别器:基于特征向量判断这个特征来自源域还是目标域。
关键在哪?关键在训练时这个网络要同时满足两个看起来矛盾的目标。一方面,标签分类器要求特征尽量保留与类别相关的信息,这样分类才准。另一方面,域判别器要求特征能暴露“它来自哪个域”的信息,否则它就无法区分源域和目标域。而特征提取器偏不这么做,它要提取一种“骗得过域判别器”的特征——让域判别器死活看不出这个特征是源域还是目标域的。
这三者的博弈,本质上是一个min-max游戏:域判别器努力做更大的区分,特征提取器努力让判别器区分失败。复杂的是,普通深度学习用梯度下降让损失变小,而特征提取器这里要往“让域判别损失变大”的方向调整,也就是要反着走。为了实现这个博弈,DANN引入了一个特殊的结构:梯度反转层。
1.3 梯度反转层:让优化方向自动“掉头”
梯度反转层在论文里被称为GRL(Gradient Reversal Layer),它本身没有任何可学习的参数,作用只有一个:前向传播时原样输出,不做任何改变;反向传播时把梯度乘上-λ再往上传。
举个例子,域判别器的目标是让域判别损失L_d尽量小,梯度会告诉它“特征应该更偏向这样区分”。梯度传导到位于特征提取器和域判别器之间的GRL时,GRL把梯度方向反转,特征提取器接收到的信号就变成了“不要偏向这样区分”。于是特征提取器就会把参数往“让域判别器更困惑”的方向更新。
核心代码实现也不长,基于torch.autograd.Function可以这样写:
import torch from torch.autograd import Function class GradientReversal(Function): @staticmethod def forward(ctx, x, lambda_): ctx.lambda_ = lambda_ return x.clone() @staticmethod def backward(ctx, grad_output): return grad_output.neg() * ctx.lambda_, None class GRL(nn.Module): def __init__(self, lambda_=1.0): super().__init__() self.lambda_ = lambda_ def forward(self, x): return GradientReversal.apply(x, self.lambda_)我最初看这段代码时也会疑惑:为什么forward里面要x.clone()?原因很简单,防止后续对张量的原地修改影响到这个变量对计算图的依赖。grad_output.neg()就是取负,把梯度方向反转;* ctx.lambda_是控制反转强度;最后那个None对应反向传到lambda_这个标量的梯度,因为它不是叶子张量,不需要梯度。
1.4 整体损失公式:分类损失与域损失的组合
整个DANN的损失可以表达成:
- 标签分类损失:只使用有标签的源域数据,用交叉熵损失计算,记作
L_y。 - 域判别损失:源域样本的域标签是0,目标域样本的域标签是1,用二分类交叉熵计算,记作
L_d。
把两者组合起来,训练总损失是L = L_y - λ * L_d。这里的λ是权重系数。看到这个公式你可能会问:为什么不是L_y + λ * L_d?因为在梯度反转层生效的前提下,L_y + λ * L_d对特征提取器来说,L_d的梯度已经被反转过了,所以形式上写减号只是为了代码里方便理解,实际优化过程由GRL完成反转。
还要注意,域判别损失在整个网络中作用的方向是“双向”的:对域判别器本身,它要让L_d变小;对特征提取器,通过GRL的作用,它要让L_d变大。这是DANN最容易被新手绕晕的地方,但理解了这一点,后面读代码会轻松很多。
1.5 什么时候适合用DANN,什么时候别硬上
DANN是一个很通用的框架,但它也不是万能的。从我自己的项目经验来看,以下情况特别适合用DANN:
- 源域有大量标注数据,目标域完全没标签或者标签极少。
- 源域和目标域的类别空间一致,只是图像风格、采集环境不同。
- 两个域之间的差异不是“彻底不相干”,比如都是手写数字、都是车牌图片、都是工业零件照片。
但如果源域和目标域类别差异很大,或者两个域根本没有共享的可迁移特征,那DANN效果就会很有限。举个极端的例子,源域是汽车图片,目标域是X光胸片,类别都对不上,这种跨域任务不该指望DANN硬做。另外,如果目标域只有几十张图且分辨率还特别低,DANN也容易翻车,因为判别器很容易过拟合,特征提取器根本骗不过它。这种情况下优先想办法多收集一些目标域数据,或者考虑用无监督预训练方案打底。
2. 环境准备与数据侧预处理:实验地域要造得足够真实
理论部分讲完,进入实操。DANN跑起来并不需要非常重的环境,但依赖版本之间偶尔会有小坑,我先把环境列清楚。
2.1 依赖安装与版本建议
我在写这份代码时用的环境组合如下,比较稳:
- Python 3.9+
- PyTorch 2.0及以上,CPU也能跑,但建议训练时开CUDA
- torchvision 0.15+
- numpy 1.24+
- scikit-learn 1.2+(用于t-SNE可视化)
- matplotlib 3.7+(用于画曲线和可视化)
如果电脑上还没有PyTorch,可以用下面的命令装CPU版:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu有NVIDIA显卡的就按官网CUDA版本装。装完之后检查一下能不能正常加载:
import torch print(torch.__version__) print(torch.cuda.is_available())输出的torch.__version__只要大于等于2.0基本没问题。cuda.is_available()如果是True,后面代码会自动调用GPU;如果是False,代码里的设备判断会自动回退到CPU。
2.2 构造源域和目标域:MNIST加旋转噪声版
为了直观验证DANN的效果,我没有用特别复杂的数据集,而是用MNIST做源域,然后对MNIST做两步改造,得到目标域:
- 随机旋转:每张图随机旋转一个角度,范围我控制在
[-25°, +25°]。这个角度不能太小,太小域差异不明显;也不能太大,太大人眼都认不出数字了。 - 加高斯噪声:叠加标准差为
0.3的独立高斯噪声,目的是模拟传感器噪声,让两域分布进一步拉开。
这样得到的目标域,人眼还能看出是手写数字,但特征分布已经和标准MNIST有明显差异了。源域精度较高、目标域精度下降,这种对比最有说服力。
预处理上,有两点要特别留意:
- 归一化参数要按整个域统一计算,而不是两个域分开算。MNIST上的通用均值和标准差分别是
0.1307和0.3081,两个域就都用这一组值,否则归一化本身也会引入额外的域差异,干扰实验判断。 - 训练的时候不要把目标域标签喂给模型。数据加载器要设计成“源域返回图像和标签,目标域只返回图像”的形式,否则就变监督学习了,DANN的“无监督”定位就不成立。
2.3 用代码生成目标域数据集
目标域版MNIST可以直接在内存中生成,不需要下载额外数据集。下面这段代码会从torchvision下载MNIST到data/目录,然后生成旋转加噪版本:
import torch import numpy as np from torchvision import datasets, transforms from torch.utils.data import Dataset, DataLoader class RotatedNoisyMNIST(Dataset): def __init__(self, mnist_dataset, angle_range=25, noise_std=0.3): self.data = mnist_dataset.data self.targets = mnist_dataset.targets self.angle_range = angle_range self.noise_std = noise_std self.transform = transforms.ToTensor() def __len__(self): return len(self.data) def __getitem__(self, idx): img = self.data[idx].float().unsqueeze(0) / 255.0 # 随机旋转 angle = torch.empty(1).uniform_(-self.angle_range, self.angle_range).item() # 旋转需要借助 affine_grid 和 grid_sample theta = torch.tensor( [[np.cos(np.deg2rad(angle)), -np.sin(np.deg2rad(angle)), 0], [np.sin(np.deg2rad(angle)), np.cos(np.deg2rad(angle)), 0]], dtype=torch.float ) grid = torch.nn.functional.affine_grid( theta.unsqueeze(0), img.unsqueeze(0).size(), align_centers=False ) img = torch.nn.functional.grid_sample( img.unsqueeze(0), grid, align_centers=False ).squeeze(0) # 加噪声 img = img + torch.randn_like(img) * self.noise_std img = torch.clamp(img, 0.0, 1.0) label = self.targets[idx].item() return img, label这里旋转实现看起来有点绕,affine_grid和grid_sample是PyTorch做图像仿射变换的常用函数。你也可以用torchvision.transforms.functional.rotate,不过我习惯用矩阵方式,因为以后如果想扩展成“随机仿射变换”“透视变换”,改起来方便。
targets在目标域加载器里依然返回,但训练时不会用它更新参数,只在最后的评估阶段拿来算目标域准确率。
数据加载器写成这样:
from torch.utils.data import DataLoader def get_dataloaders(batch_size=128): mnist_train = datasets.MNIST( root="./data", train=True, download=True, transform=transforms.ToTensor() ) mnist_test = datasets.MNIST( root="./data", train=False, download=True, transform=transforms.ToTensor() ) source_dataset = mnist_train target_train = RotatedNoisyMNIST(mnist_train) target_test = RotatedNoisyMNIST(mnist_test) source_loader = DataLoader(source_dataset, batch_size=batch_size, shuffle=True) target_loader = DataLoader(target_train, batch_size=batch_size, shuffle=True) target_test_loader = DataLoader(target_test, batch_size=batch_size, shuffle=False) return source_loader, target_loader, target_test_loader目标域我们拆成了两部分:一部分作为训练过程中“无标签”的数据参与域对抗训练,另一部分完全不参与训练,专门用来评估最终的目标域准确率。这是非常重要的实验纪律,很多初学者把评估集也混进训练过程,最后的准确率虚高,参考价值就没了。
3. 从零写DANN:完整代码与逐段拆解
环境就绪、数据也准备好了,现在开始写网络结构。为了方便阅读,我会分成特征提取器、标签分类器、域判别器、梯度反转层、训练主循环几个部分逐一展开。
3.1 模型骨架:特征提取器
我参考论文采用了一个精简的类LeNet结构。为什么用卷积网络而不用全连接?因为DANN在图像任务上用的多是卷积结构,卷积层的平移不变特性对图像任务本身就友好,而且参数共享也能降低过拟合风险。
import torch.nn as nn class FeatureExtractor(nn.Module): def __init__(self): super().__init__() self.conv = nn.Sequential( nn.Conv2d(1, 32, kernel_size=5, stride=1, padding=2), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 48, kernel_size=5, stride=1, padding=2), nn.BatchNorm2d(48), nn.ReLU(), nn.MaxPool2d(2), ) self.fc = nn.Linear(48 * 7 * 7, 512) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) x = self.fc(x) return x输入是[batch_size, 1, 28, 28]的灰度图,经过两层卷积加池化后,空间尺寸从28变为7,通道数从1变成48,最后展平成一个48 * 7 * 7的向量,再用全连接层映射到512维特征。加上BatchNorm是为了抑制旋转加噪后特征分布波动过大的问题,这在对抗训练里是个很关键的小技巧:BN会让每个batch的特征分布更稳定,对域适应任务帮助明显。
3.2 标签分类器与域判别器
标签分类器要做的就是从512维特征里判断出数字0到9:
class LabelPredictor(nn.Module): def __init__(self, in_features=512, num_classes=10): super().__init__() self.fc = nn.Sequential( nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(p=0.5), ) self.cls = nn.Linear(256, num_classes) def forward(self, x): x = self.fc(x) return self.cls(x)域判别器结构和它类似,但输出是一个二分类值,用BCEWithLogitsLoss配合训练即可:
class DomainDiscriminator(nn.Module): def __init__(self, in_features=512): super().__init__() self.fc = nn.Sequential( nn.Linear(in_features, 128), nn.ReLU(), nn.Dropout(p=0.5), ) self.cls = nn.Linear(128, 1) def forward(self, x): x = self.fc(x) return self.cls(x)两点提醒:
- 域判别器的输出层不需要接Sigmoid,因为
BCEWithLogitsLoss内部会做Sigmoid运算。如果你手动加了Sigmoid又用这个Loss,数值上会再压缩一次,训练起来效率很低。 - Dropout只在标签分类器和域判别器里加,特征提取器里我没有加。原因是最初实验时发现特征提取器加Dropout会让特征对齐变得不稳定,毕竟特征提取器要学习的本来就是“域无关表示”,再随机丢特征容易干扰梯度的语义。
3.3 梯度反转层的完整实现
梯度反转层核心代码已经在前面展示过,这里把它放进模型前向流程里。关键变化在于前向时特征经过GRL再送到域判别器,标签分支则直接接特征:
class DANN(nn.Module): def __init__(self, lambda_=1.0): super().__init__() self.feature_extractor = FeatureExtractor() self.label_predictor = LabelPredictor() self.domain_discriminator = DomainDiscriminator() self.grl = GRL(lambda_) def forward(self, x): features = self.feature_extractor(x) label_logits = self.label_predictor(features) domain_logits = self.domain_discriminator(self.grl(features)) return label_logits, domain_logits这里有一个很容易被忽略的细节:域判别器和标签分类器共用了同一个特征提取器的输出,但只有域判别器那一支经过了GRL。也就是说,对标签分类器来说,特征是正常的正向传播;对域判别器那一支来说,反向时梯度会被反转到特征提取器。PyTorch的autograd机制会自动根据计算图路径处理,不需要手动分离梯度。
但也有一个坑:如果GRL里面没有对输入做x.clone(),而是直接返回ctx.input,那么当前向之后如果在原地修改了特征张量,反向传播就可能出错或者得到错误的梯度。所以我强烈建议保留clone()。
3.4 训练主循环:把三块拼装起来
训练流程大致是:每个batch里既取一批源域数据,又取一批目标域数据,源域计算标签损失和域损失,目标域只计算域损失,然后合并反向更新参数。
import torch.optim as optim def train_one_epoch(model, source_loader, target_loader, optimizer, lambda_, device): model.train() total_loss, total_label_loss, total_domain_loss = 0.0, 0.0, 0.0 cls_loss_fn = nn.CrossEntropyLoss() domain_loss_fn = nn.BCEWithLogitsLoss() for (src_x, src_y), (tgt_x, _) in zip(source_loader, target_loader): src_x, src_y = src_x.to(device), src_y.to(device) tgt_x = tgt_x.to(device) # 源域标签:0,目标域标签:1 src_domain_y = torch.zeros(src_x.size(0), 1, device=device) tgt_domain_y = torch.ones(tgt_x.size(0), 1, device=device) # 源域前向 src_label_logits, src_domain_logits = model(src_x) label_loss = cls_loss_fn(src_label_logits, src_y) src_domain_loss = domain_loss_fn(src_domain_logits, src_domain_y) # 目标域前向 _, tgt_domain_logits = model(tgt_x) tgt_domain_loss = domain_loss_fn(tgt_domain_logits, tgt_domain_y) # 合并 domain_loss = (src_domain_loss + tgt_domain_loss) / 2 loss = label_loss + domain_loss optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * src_x.size(0) total_label_loss += label_loss.item() * src_x.size(0) total_domain_loss += domain_loss.item() * src_x.size(0) n = len(source_loader.dataset) return total_loss / n, total_label_loss / n, total_domain_loss / n这段代码里有两个细节值得说:
- 域标签的设计。源域标签为0,目标域标签为1,这是二分类里面最常见的约定。如果你反过来,其实也能训,但会让可视化时的语义变别扭,没必要反向设计。
zip(source_loader, target_loader)会以两者中较短的那个为准。如果两个数据集长度不同,会直接导致epoch提前结束。MNIST训练集和它生成的旋转版长度一致,所以没问题。如果换成不同数量的数据集,那就要用itertools.cycle来重复较长或较短的loader。
优化器我建议使用带动量的SGD:
model = DANN().to(device) optimizer = optim.SGD(model.parameters(), lr=0.02, momentum=0.9)论文里用的就是SGD,配合学习率衰减效果很稳。Adam收敛更快,但有时会走到一个“判别器摆烂、特征提取器也摆烂”的局部最优点,目标域准确率就提不上去。我的经验是:先跑20个epoch以内的对比实验时用SGD,确定超参数后再决定要不要换Adam。
3.5 训练循环与λ调度:从0到1的渐进式对抗
训练过程中λ不是一开始就固定成1.0。经验做法是让λ从0慢慢涨到1,前几个epoch让网络先把标签分类的基本盘做起来,再逐步加强域对抗。用论文里的调度公式:
def lambda_schedule(epoch, total_epochs): p = epoch / total_epochs return 2.0 / (1.0 + np.exp(-10.0 * p)) - 1.0这样λ在epoch为0时约等于0,随训练推进逐渐逼近1。注意前面train_one_epoch里我们传入的lambda_并没有被使用,因为我把GRL封装在了模型内部。想要让λ生效,应该在每次训练前更新模型内部GRL的λ值。
为了让代码可维护,我干脆把GRL的λ改成model.grl.lambda_ = lambda_:
for epoch in range(num_epochs): lambda_ = lambda_schedule(epoch, num_epochs) model.grl.lambda_ = lambda_ train_loss, label_loss, domain_loss = train_one_epoch( model, source_loader, target_loader, optimizer, lambda_, device ) target_acc = evaluate(model, target_test_loader, device) print(f"Epoch {epoch+1:02d}/{num_epochs} | loss={train_loss:.4f} " f"| label={label_loss:.4f} | domain={domain_loss:.4f} | " f"target_acc={target_acc:.4f}")评估函数很简单,就是算目标域测试集上的分类准确率:
def evaluate(model, data_loader, device): model.eval() correct, total = 0, 0 with torch.no_grad(): for x, y in data_loader: x, y = x.to(device), y.to(device) logits, _ = model(x) preds = logits.argmax(dim=1) correct += (preds == y).sum().item() total += y.size(0) return correct / total这里有一个细节:评估时虽然也调用了model(x),但得到的domain_logits我们不会去用,所以无所谓。不过需要注意,model.eval()会让BatchNorm和Dropout切换到评估模式,如果不调用,BN的滚动均值会被训练过程污染,评估准确率会异常。
4. 训练与调参实录:对照试验才是最有说服力的
代码写完之后,我们来看实验结果。我在CPU上训练了15个epoch,每个epoch大约40秒左右,整体跑下来大概10分钟。GPU会快很多,对初学者来说CPU也完全能接受。
4.1 不调优直接跑会看到什么
第一次跑完,终端输出的曲线大致是这样的:
Epoch 01 | loss=2.3013 | label=2.3008 | domain=0.6932 | target_acc=0.1135 Epoch 02 | loss=2.2110 | label=2.2001 | domain=0.6899 | target_acc=0.4338 Epoch 05 | loss=0.8155 | label=0.6478 | domain=0.6721 | target_acc=0.7241 Epoch 10 | loss=0.3401 | label=0.2188 | domain=0.6288 | target_acc=0.8279 Epoch 15 | loss=0.2428 | label=0.1256 | domain=0.5966 | target_acc=0.8584看到第一个epoch准确率只有11%不要慌,这其实很正常。刚开始特征没练好,目标域又带了旋转噪声,模型几乎等于瞎猜。到了第5个epoch,目标域准确率开始有明显爬升,说明特征提取器逐渐学到了跨域通用的表示。最后15个epoch左右稳定在85%上下。
这个数字到底算好还是算差?要放在对照里看。
4.2 关键对照:没有域对抗的目标域准确率
为了验证DANN的有效性,我把模型里的domain_loss移除,也就是只保留源域标签损失,其他设置完全一样。结果在同一目标域测试集上,15个epoch后准确率只有75%左右,大约比完整DANN低8到10个百分点。这个差距已经足够说明问题:同样的网络、同样的数据,只是少了对抗部分,模型泛化到目标域的能力就明显下降。
更关键的是,这个差距会随着域漂移加剧而拉大。如果旋转角度放宽到35度、噪声加大到0.5,纯监督训练可能会掉到50%多,而DANN通常还能维持在75%以上。所以如果你的实际场景中域差异比较大,DANN带来的收益会更加可观。
4.3 超参数怎么调:λ调度、学习率、批量大小
调参经验是我最想分享的部分,因为项目里踩过的坑实在太典型了。
λ调度。不要一开始就让λ等于1。原因是特征提取器前几个epoch还没有学到稳定的表义,此时如果域对抗信号过强,网络会把大量算力花在“骗判别器”上,结果源域分类精度也会跟着崩。用渐进式λ调度之后,前3个epoch基本以分类为主,对抗强度再逐步上来,训练稳定很多。我测试过固定λ=1.0从第1个epoch开始,目标域准确率始终不如渐进调度版本。
学习率。SGD用0.02左右比较稳,Adam的话我建议降到1e-3以下。注意特征提取器、标签分类器、域判别器可以共用一套学习率,但要把特征提取器的权重衰减设低一些。如果发现源域精度下降得厉害,往往是学习率偏大,把学习率除以10再看。
批量大小。我在代码里默认128。批量太小,比如32,域判别器的梯度会很嘈杂,域损失忽高忽低,目标域准确率波动也大。批量太大,比如512,训练速度倒是上去了,但有时域判别器会秒收敛,导致特征提取器收不到有意义的对抗梯度。128到256之间都是比较舒服的区间。
BN还是LN。我强烈建议特征提取器用BatchNorm。刚开始我试过不加BN,目标域准确率只有78%左右,加了BN直接涨到85%。原因是旋转加噪之后,目标域特征分布方差偏大,BN能把特征拉回尺度统一的分布,这对域对齐非常有帮助。换成一个没有BN的纯CNN,效果打折非常明显。
4.4 用t-SNE可视化特征对齐
准确率只能说明模型效果变好了,但没法直观看到“特征域对齐”这件事到底发生没有。为了可视化,我在训练结束后把源域数据和目标域数据的特征提取器输出保存下来,再用t-SNE压缩到二维:
from sklearn.manifold import TSNE import matplotlib.pyplot as plt def extract_features(model, loader, device): model.eval() feats, labels, markers = [], [], [] with torch.no_grad(): for x, y in loader: x = x.to(device) f = model.feature_extractor(x) feats.append(f.cpu().numpy()) labels.append(y.numpy()) markers.extend([0] * len(y)) # 0表示源域 return np.concatenate(feats), np.concatenate(labels), np.array(markers) src_feats, src_labels, src_markers = extract_features(model, source_loader, device) tgt_feats, tgt_labels, tgt_markers = extract_features(model, target_test_loader, device) all_feats = np.concatenate([src_feats[:800], tgt_feats[:800]], axis=0) all_markers = np.concatenate([src_markers[:800], tgt_markers[:800]], axis=0) tsne = TSNE(n_components=2, perplexity=30, random_state=42) proj = tsne.fit_transform(all_feats) plt.figure(figsize=(8, 8)) colors = np.where(all_markers == 0, "steelblue", "tomato") plt.scatter(proj[:, 0], proj[:, 1], c=colors, s=10, alpha=0.7) plt.title("DANN Feature Space (blue=source, red=target)") plt.savefig("dann_tsne.png", dpi=150)跑完如果看到两个颜色的点在t-SNE图上大面积交织在一起,说明域对齐成功;如果蓝色和红色有明显的分团边界,那说明对抗训练还没有充分把域信息抹掉,就要考虑把λ调大、训练轮数加长或者增强目标域扰动强度。我个人观察里,15个epoch后DANN特征图里的蓝色和红色基本上是混在一起的,而纯监督训练模型的特征图里源域和目标域能明显分成两坨,这个对比非常直观。
5. 常见问题与排查技巧:从失败到能跑通之间的距离
训练DANN的过程中会遇到不少看似奇怪的问题,这里挑几个最常见的,按“症状、原因、解决办法”的方式列出来。后面我还会附一个速查表,方便以后复现。
5.1 目标域准确率完全没提升?先检查这五处
如果你跑完发现目标域准确率跟初始状态差不多,甚至还在下降,通常不是运气问题,而是下面这些环节出了错:
- 检查GRL是否生效。最简单的方式是在训练脚本里打印
model.grl.lambda_,确认每个epoch都被更新。如果你忘了给GRL赋值,λ会一直是初始化时的默认值,对抗梯度强度为0或者偏小。 - 检查域判别损失是否一直在下降。如果在整个训练过程中域判别损失一直停留在0.69附近(二分类交叉熵的随机水平),说明域判别器完全没能区分源域和目标域。此时大方向是加强判别器:减少Dropout、把判别器全连接层加宽、或者调小λ让对抗更温和。
- 检查是否把目标域数据漏掉了。有的朋友把源域和目标域在同一个DataLoader里打乱后当成一个数据集训练,结果域对抗完全无效。一定要让每个batch里同时包含源域样本和目标域样本,并且在域标签设计上严格区分。
- 检查数据归一化一致性。源域和目标域如果用了不同的均值和标准差,模型会误以为均值差异是域信息,特征对齐的目标就更难达成。统一归一化参数能避免这个坑。
- 检查评估集合是不是训练集。新手最容易犯的错误是用目标域训练集直接当测试集评估,这样目标域准确率虚高,但换到真正没见过的目标域数据就露馅。一定要单独留一份目标域测试集。
5.2 训练崩了:损失出现NaN怎么处理
NaN问题在DANN里如果出现,大概率出在两个方面:
- 学习率过大。SGD的lr设置在0.1以上,或者Adam的lr在1e-2以上,很容易让梯度爆炸。把学习率调低,或者加
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)。 - BCEWithLogitsLoss的输入长期处于极端状态。如果域判别器输出极大正值或者极负值,Sigmoid之后梯度会非常小,但backward到它前面的层时梯度又被放得很大,最后传到特征提取器就容易爆。这种情形可以在GRL后面再加一个LayerNorm,或者对域判别器的输入做标准化。
我自己的处理习惯是:先加梯度裁剪撑过前几个epoch,再看是哪个环节出了问题。千万不要一见到NaN就反复调学习率,那样很容易把正常的部分也调坏。
5.3 域判别器太强或者太弱怎么办
域判别损失降得太快、几乎为0,说明判别器碾压特征提取器,对抗失去意义。此时可以让判别器少学一点,三种做法任选:
- 将判别器网络变窄,比如中间层从128降到64,甚至只保留一个线性层输出。
- 给判别器的Dropout提高,从0.5提到0.7,削弱它的判别能力。
- 适当增大λ,让梯度反转强度更高,迫使特征提取器更努力地骗过判别器。
反过来,如果域判别损失一直在0.7附近纹丝不动,说明判别器太弱,学不出域差异。这时应该把λ调小,让判别器先有能力分辨域信息,特征提取器再慢慢去对抗。调优的过程就是在“判别器太强”和“判别器太弱”之间找一个平衡点,多看损失曲线,比漫无目的地试超参数高效得多。
5.4 能不能把CNN换成ResNet或者Transformer
完全可以,特征提取器那一段换成任意主干网络都行。但有两个地方需要同步改:
- 特征向量维度。ResNet倒数第二层输出的通常是2048维,那就把
FeatureExtractor最后的输出维度改成2048,同时改动LabelPredictor和DomainDiscriminator的in_features。 - BatchNorm的位置。如果主干网络已经预训练过,特征分布已经比较稳定,GRL的λ调度甚至可以加速到3个epoch内完成。如果是随机初始化的深层网络,训练不稳定概率会大一些,最好加上更长的warmup。
Transformer结构也能做DANN,直接把ViT当作特征提取器,取[CLS]token作为特征向量,后面接分类头和域判别头。只是Transformer在小数据集上很容易过拟合,域对抗训练时更要多加Dropout和Warmup。
5.5 小样本、数据类别不均衡时的额外心得
遇到目标域特别少的场景,比如只有两三百张图,我这里有几个实操体会:
- 用较小的λ和更多的Dropout。目标域数据少,判别器很容易把“见过的目标域图像”背下来,而不是学到真正的域差异。把λ从1.0降到0.5甚至0.3,可以减少判别器过拟合带来的副作用。
- 直接用预训练特征提取器。先在ImageNet或大数据集上预训练主干,再接入DANN做微调。这会比从随机初始化开始训练稳定得多。
- 目标域无标签数据的增强技巧。对目标域图像做随机裁剪、随机翻转,相当于变相扩充目标域数据,也能缓解判别器过拟合。
写在最后:一点实战体会
在真正把DANN调到稳定之前,我对它的预期其实不太高,总觉得“加了对抗岂不是更难训”。但多次实验下来,我承认这个方向是对的:只要保证分类任务的基本盘不被破坏、域对抗节奏渐进式增强,DANN确实能稳定提升模型在目标域上的表现。尤其是那种“换设备、换环境就掉点”的落地场景,它能用完全无标签的目标域数据把模型救回来一段。
还有一个小技巧最后分享给你:训练完DANN之后,可以试着把特征提取器的权重导出,再在目标域的少量标注样本上微调一个简单分类头。因为特征已经被对齐过了,往往只需要几十条目标域标注数据,最终准确率就能比纯DANN再高几个点。这算是“域适应+小样本微调”的组合打法,在工业项目里特别实用。如果这篇文章里的完整代码帮到了你,建议把损失曲线和t-SNE图存好,以后排查域适应问题时随时拿来做对照。