AI领域自适应:这次怎么落地的

上周在做项目迁移时遇到一个尴尬问题:我有一个训练得很好的图像分类模型,在 ImageNet 上准确率 90%+,但部署到生产环境时(产品图片,不是原始 ImageNet 图片)准确率直接掉到 60%。

我花了两周时间踩坑、查资料、实验,最后把准确率拉回了 85%+。

为什么写这篇

上周在做项目迁移时遇到一个尴尬问题:我有一个训练得很好的图像分类模型,在 ImageNet 上准确率 90%+,但部署到生产环境时(产品图片,不是原始 ImageNet 图片)准确率直接掉到 60%。这不是模型的问题,是训练数据和实际应用场景的分布差异导致的。

这个问题就是典型的"领域自适应"问题。我花了两周时间踩坑、查资料、实验,最后把准确率拉回了 85%+。把整个折腾过程记录下来,避免重复造轮子。

背景与需求

问题定义

我有这么一个场景:

  • 源域:ImageNet 数据集,模型在上面训练得很好
  • 目标域:产品商品图片(电商平台采集)
  • 挑战:两类数据分布差异很大(光照、背景、拍摄角度、图像质量都不同)
graph LR A[源域: ImageNet] -->|训练| B[源域模型] C[目标域: 商品图片] -->|部署| B B -->|性能下降| D[准确率 90% → 60%] D -->|需要解决| E[领域自适应]

传统方法的局限

尝试了几个传统方法,效果都不理想:

  1. 直接微调:在少量目标域数据上 fine-tune,容易过拟合
  2. 数据增强:通过旋转、缩放、颜色变换来增加数据多样性,解决不了根本的分布差异
  3. 从头训练:目标域数据不够,训练不出好模型

这些方法要么没效果,要么成本太高。需要的是专门解决域迁移问题的方法。

实现方案

方案选择

领域自适应的方法很多,我最终选择基于对抗性训练的 DANN(Domain-Adversarial Neural Networks),原因:

  • 不需要目标域标注数据(只需要无标签数据)
  • 理论基础扎实,有大量论文支持
  • PyTorch 实现相对简单
  • 效果在实践中验证过

整体架构

DANN 的核心思想是让模型"分不清"数据来自源域还是目标域:

graph TD A[输入图片] --> B[特征提取器] B --> C{领域分类器} B --> D[任务分类器] C -->|反向传播| B D -->|反向传播| B style C fill:#ffcccc style D fill:#ccffcc
  • 特征提取器:提取通用特征
  • 领域分类器:判断数据来源(源域/目标域),训练时让其反向更新
  • 任务分类器:完成原始分类任务

代码实现

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,但任务表现没提升。说明域分类器过拟合了,需要:

  1. 增加 Dropout
  2. 减少域分类器的复杂度
  3. 使用梯度惩罚

坑 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 小时
DANN89.5%85.7%8 小时

可以看到 DANN 在目标域上的提升最明显,虽然源域性能略有下降,但整体效果最好。

下图把上表中的源域与目标域准确率并列展示,DANN 在目标域的跃升最为突出。

ImageNet→商品图片迁移:原始模型、微调、数据增强与 DANN 的源域/目标域准确率对比

DANN 以约 1.8 个百分点的源域代价,换取目标域准确率从 61.5% 提升到 85.7%,是四种方案里迁移收益最大的。

可视化分析

用 t-SNE 可视化特征空间分布:

DANN训练前后的特征分布对比

左边是 DANN 训练前,源域和目标域特征明显分离;右边是 DANN 训练后,两类特征重叠度大幅增加,说明模型学到了域不变特征。

实际应用建议

何时使用领域自适应

适合使用 DANN 这类方法的情况:

  1. 有源域标注数据:有一个大量标注的源域数据集
  2. 有目标域无标签数据:有足够的目标域数据但未标注
  3. 分布差异明显:两个域的数据分布有系统性差异

不适合的情况:

  1. 目标域有大量标注数据(直接训练效果更好)
  2. 两个域分布相似(直接迁移就够用)
  3. 需要极致性能(从头训练或精心微调可能更好)

实用技巧

  1. 渐进式训练:先用小 batch size 训练,再逐步增大
  2. 监控域分类器准确率:保持在 50% 左右,太低说明特征提取不够,太高说明过拟合
  3. 数据预处理很重要:统一图像尺寸、格式、质量
  4. 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!