AI领域自适应:这次怎么落地的
上周在做项目迁移时遇到一个尴尬问题:我有一个训练得很好的图像分类模型,在 ImageNet 上准确率 90%+,但部署到生产环境时(产品图片,不是原始 ImageNet 图片)准确率直接掉到 60%。
我花了两周时间踩坑、查资料、实验,最后把准确率拉回了 85%+。
为什么写这篇
上周在做项目迁移时遇到一个尴尬问题:我有一个训练得很好的图像分类模型,在 ImageNet 上准确率 90%+,但部署到生产环境时(产品图片,不是原始 ImageNet 图片)准确率直接掉到 60%。这不是模型的问题,是训练数据和实际应用场景的分布差异导致的。
这个问题就是典型的"领域自适应"问题。我花了两周时间踩坑、查资料、实验,最后把准确率拉回了 85%+。把整个折腾过程记录下来,避免重复造轮子。
背景与需求
问题定义
我有这么一个场景:
- 源域:ImageNet 数据集,模型在上面训练得很好
- 目标域:产品商品图片(电商平台采集)
- 挑战:两类数据分布差异很大(光照、背景、拍摄角度、图像质量都不同)
传统方法的局限
尝试了几个传统方法,效果都不理想:
- 直接微调:在少量目标域数据上 fine-tune,容易过拟合
- 数据增强:通过旋转、缩放、颜色变换来增加数据多样性,解决不了根本的分布差异
- 从头训练:目标域数据不够,训练不出好模型
这些方法要么没效果,要么成本太高。需要的是专门解决域迁移问题的方法。
实现方案
方案选择
领域自适应的方法很多,我最终选择基于对抗性训练的 DANN(Domain-Adversarial Neural Networks),原因:
- 不需要目标域标注数据(只需要无标签数据)
- 理论基础扎实,有大量论文支持
- PyTorch 实现相对简单
- 效果在实践中验证过
整体架构
DANN 的核心思想是让模型"分不清"数据来自源域还是目标域:
- 特征提取器:提取通用特征
- 领域分类器:判断数据来源(源域/目标域),训练时让其反向更新
- 任务分类器:完成原始分类任务
代码实现
import torch
import torch.nn as nn
import torch.optim as optim
from torch.autograd import Function
# 梯度反转层
class GradientReversalFunction(Function):
@staticmethod
def forward(ctx, x, alpha):
ctx.alpha = alpha
return x.view_as(x)
@staticmethod
def backward(ctx, grad_output):
output = grad_output.neg() * ctx.alpha
return output, None
class GradientReversalLayer(nn.Module):
def __init__(self, alpha=1.0):
super().__init__()
self.alpha = alpha
def forward(self, x):
return GradientReversalFunction.apply(x, self.alpha)
# 特征提取器
class FeatureExtractor(nn.Module):
def __init__(self):
super().__init__()
# 使用 ResNet50 的特征提取部分
resnet = models.resnet50(pretrained=True)
self.features = nn.Sequential(*list(resnet.children())[:-1])
def forward(self, x):
return self.features(x).view(x.size(0), -1)
# 任务分类器
class TaskClassifier(nn.Module):
def __init__(self, num_classes=1000):
super().__init__()
self.classifier = nn.Sequential(
nn.Linear(2048, 512),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(512, num_classes)
)
def forward(self, x):
return self.classifier(x)
# 领域分类器
class DomainClassifier(nn.Module):
def __init__(self):
super().__init__()
self.domain_classifier = nn.Sequential(
GradientReversalLayer(alpha=1.0),
nn.Linear(2048, 512),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(512, 2) # 2个域: 源域和目标域
)
def forward(self, x):
return self.domain_classifier(x)
# 完整的 DANN 模型
class DANN(nn.Module):
def __init__(self, num_classes=1000):
super().__init__()
self.feature_extractor = FeatureExtractor()
self.task_classifier = TaskClassifier(num_classes)
self.domain_classifier = DomainClassifier()
def forward(self, x):
features = self.feature_extractor(x)
task_output = self.task_classifier(features)
domain_output = self.domain_classifier(features)
return task_output, domain_output
训练流程
# 训练参数
batch_size = 32
learning_rate = 0.001
num_epochs = 50
# 初始化模型
model = DANN(num_classes=1000)
criterion_task = nn.CrossEntropyLoss()
criterion_domain = nn.CrossEntropyLoss()
# 优化器
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
# 训练循环
for epoch in range(num_epochs):
model.train()
total_loss = 0.0
task_loss_sum = 0.0
domain_loss_sum = 0.0
# 逐批训练
for (source_data, source_labels), (target_data, _) in zip(source_loader, target_loader):
# 将数据移到 GPU
source_data = source_data.to(device)
source_labels = source_labels.to(device)
target_data = target_data.to(device)
# 合并数据
batch_size_s = source_data.size(0)
batch_size_t = target_data.size(0)
domain_labels_s = torch.zeros(batch_size_s, dtype=torch.long).to(device) # 源域标签为 0
domain_labels_t = torch.ones(batch_size_t, dtype=torch.long).to(device) # 目标域标签为 1
# 前向传播
task_output_s, domain_output_s = model(source_data)
_, domain_output_t = model(target_data)
# 计算损失
task_loss = criterion_task(task_output_s, source_labels)
domain_loss = criterion_domain(
torch.cat([domain_output_s, domain_output_t], dim=0),
torch.cat([domain_labels_s, domain_labels_t], dim=0)
)
# 总损失
total_loss_batch = task_loss + domain_loss
# 反向传播
optimizer.zero_grad()
total_loss_batch.backward()
optimizer.step()
total_loss += total_loss_batch.item()
task_loss_sum += task_loss.item()
domain_loss_sum += domain_loss.item()
print(f'Epoch {epoch+1}/{num_epochs}')
print(f'Total Loss: {total_loss/len(source_loader):.4f}')
print(f'Task Loss: {task_loss_sum/len(source_loader):.4f}')
print(f'Domain Loss: {domain_loss_sum/len(source_loader):.4f}')
踩坑记录
坑 1:alpha 参数动态调整
刚开始直接用固定的 alpha=1.0,发现训练不稳定。看了论文才知道 alpha 需要动态调整:
def get_alpha(epoch, num_epochs):
p = epoch / num_epochs
return 2. / (1. + math.exp(-10 * p)) - 1.
这个调整很重要,前期专注特征提取,后期加强域自适应。
坑 2:批次大小不匹配
源域和目标域数据量可能不同,导致迭代器长度不一致。解决方案:
# 确保迭代器长度一致
min_len = min(len(source_loader), len(target_loader))
source_loader = iter(source_loader)
target_loader = iter(target_loader)
for i in range(min_len):
source_data = next(source_loader)
target_data = next(target_loader)
# ... 训练代码
坑 3:域分类器过拟合
一开始 domain_loss 降到 0,但任务表现没提升。说明域分类器过拟合了,需要:
- 增加 Dropout
- 减少域分类器的复杂度
- 使用梯度惩罚
坑 4:目标域数据质量
尝试过用网络爬取的商品图片,结果效果很差。问题是:
- 图片分辨率差异大(有些只有 100x100)
- 有些图片经过压缩,质量很差
- 包含大量水印和边框
最终解决方案:
# 数据预处理
def preprocess_image(image):
# 统一尺寸
image = transforms.Resize((224, 224))(image)
# 质量过滤
if image.size[0] < 200 or image.size[1] < 200:
return None
# 检测并去除水印(简单方法)
if has_watermark(image):
return None
return image
坑 5:GPU 内存不足
DANN 的训练需要同时加载源域和目标域数据,容易爆显存。解决方案:
# 降低批次大小
batch_size = 16 # 从 32 降到 16
# 使用混合精度训练
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
with autocast():
task_output_s, domain_output_s = model(source_data)
_, domain_output_t = model(target_data)
task_loss = criterion_task(task_output_s, source_labels)
domain_loss = criterion_domain(...)
scaler.scale(total_loss_batch).backward()
scaler.step(optimizer)
scaler.update()
结果评估
性能对比
| 方法 | 源域准确率 | 目标域准确率 | 训练时间 |
|---|---|---|---|
| 原始模型 | 92.3% | 61.5% | - |
| 直接微调 | 88.7% | 72.3% | 4 小时 |
| 数据增强 | 91.2% | 68.9% | 6 小时 |
| DANN | 89.5% | 85.7% | 8 小时 |
可以看到 DANN 在目标域上的提升最明显,虽然源域性能略有下降,但整体效果最好。
下图把上表中的源域与目标域准确率并列展示,DANN 在目标域的跃升最为突出。

DANN 以约 1.8 个百分点的源域代价,换取目标域准确率从 61.5% 提升到 85.7%,是四种方案里迁移收益最大的。
可视化分析
用 t-SNE 可视化特征空间分布:

左边是 DANN 训练前,源域和目标域特征明显分离;右边是 DANN 训练后,两类特征重叠度大幅增加,说明模型学到了域不变特征。
实际应用建议
何时使用领域自适应
适合使用 DANN 这类方法的情况:
- 有源域标注数据:有一个大量标注的源域数据集
- 有目标域无标签数据:有足够的目标域数据但未标注
- 分布差异明显:两个域的数据分布有系统性差异
不适合的情况:
- 目标域有大量标注数据(直接训练效果更好)
- 两个域分布相似(直接迁移就够用)
- 需要极致性能(从头训练或精心微调可能更好)
实用技巧
- 渐进式训练:先用小 batch size 训练,再逐步增大
- 监控域分类器准确率:保持在 50% 左右,太低说明特征提取不够,太高说明过拟合
- 数据预处理很重要:统一图像尺寸、格式、质量
- early stopping:防止过拟合
扩展方向
如果 DANN 效果还不够,可以尝试:
- CORAL:基于协方差对齐的方法
- MMD:最大均值差异
- 领域泛化:同时学习多个源域,泛化到未知域
- 自监督预训练:先在目标域上做自监督学习,再微调
小结
领域自适应解决的是一个很实际的问题:模型在训练环境表现好,部署到生产环境就掉链子。
DANN 通过对抗性训练让模型学习域不变特征,在商品图片迁移任务上把准确率从 60% 提升到了 85%+。虽然训练时间较长,但效果稳定,适合生产环境使用。
关键是要理解问题本质:不是模型不好,是数据分布变了。针对性的解决方案才能有效。如果你的项目也遇到类似的域迁移问题,可以考虑试试领域自适应方法。
代码示例和实验结果基于 PyTorch 1.12.1,实际应用时请根据你的数据和环境调整参数。
版权声明: 本文首发于 指尖魔法屋-AI领域自适应:这次怎么落地的(https://blog.thinkmoon.cn/post/308-ai-domain-adaptation-source-target-domain-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。