简介:这份 PDF 讲义面向迁移学习入门者、课程复习者与相关方向研究者,围绕跨领域分布不一致下的模式识别问题,系统梳理迁移学习的理论基础与代表性算法。压缩包内仅含 1 个 PDF 文件,大小约 13.68MB,内容以课堂讲义形式组织,便于按章节连续阅读和检索。讲义从 H∆H-divergence 出发,串联 DAN、DANN、MCD、MDD 等经典方法,并延伸到 DEV 迁移模型选择以及 Accuracy、Precision、Recall、F1-score 等评估实现,较完整地呈现“理论度量—算法设计—模型选择—评估落地”的知识链路。目前已有 271 人学习,适合希望快速建立迁移学习知识框架、理解域间差异与对抗/不变表示思路的读者用作课程配套或科研入门参考。
1. 从一次掉点说起:迁移学习理论与算法到底解决什么
迁移学习理论与算法这组词,多数人是被一次掉点逼着去查的:源域上验证集涨到 0.92 的模型,换到目标域的真实数据上直接掉到 0.7 出头,重新标注的成本又扛不住。这套理论回答的其实是个很朴素的问题——当目标域没有标注、分布又和训练集不一样时,模型里哪些知识可以搬,搬过去之后凭什么判断它不会更差。它适合三类人:手里有成熟源域模型但缺目标域标注的算法工程师、要做跨设备跨场景部署的团队、以及想把「能不能迁」从拍脑袋变成可测量判断的技术负责人。后面按度量、训练回路、微调、排查的顺序,把它拆成能跑起来的东西。
2. 分布差异怎么度量:MMD、CORAL 与对抗对齐的选型账
选型这件事最容易犯的错,是先挑方法再看数据。正确顺序反过来:先确认两个域的偏移属于哪一类,再决定用哪种度量。偏移大致分三种——协变量偏移(输入分布变、条件分布不变)、标签偏移(类别比例变)、概念偏移(同样输入对应不同输出)。迁移学习理论与算法里有明确结论的部分,基本都建立在协变量偏移这个假设上,所以动手之前先花半天确认这个假设,比后面调一周超参都值。
2.1 把「两个域不像」写成一个能反传的数
记源域有标注样本 $D_s={(x_i,y_i)}{i=1}^{n_s}$,目标域只有输入 $D_t={x_j}{j=1}^{n_t}$,这就是最常见的无监督域自适应设定。模型拆成两部分:特征提取器 $g$ 和分类器 $c$,整体 $f=c\circ g$。对齐的本质是让 $g$ 把两个域的样本映射到同一片特征空间后,边缘分布接近,即 $g_#P_s \approx g_#P_t$,同时 $c$ 在源域上仍然准。
经典泛化界给出了三项结构:目标域风险不超过源域风险、两个域在假设类上的散度、以及理想联合假设的误差 $\lambda^$。三项里只有第二项是能优化掉的。这点非常关键——很多负迁移的根因不在对齐没做好,而在 $\lambda^$ 本身就大:两个域的类别语义根本对不上(比如源域把「卡车」当正类,目标域同一位置是「面包车」),这时候把散度压到接近零,等于逼着模型把不同的东西映射到一起,精度只会更差。所以看到「对抗损失降得很漂亮但目标域精度不涨」的现象,第一反应应该是回头检查 $\lambda^*$,而不是继续加对齐强度。
按可优化的对象划分,主流手段有四类:矩匹配(MMD、CORAL)、对抗式(DANN、CDAN)、最优传输(Wasserstein 型)、子空间对齐(把两个域投影到同一低维子空间)。前两类工程成本最低、代码最短,也是绝大多数落地场景的第一选择;后两类在域间差异极大、样本量又够的时候才有明显收益。
2.2 三个能直接塞进训练回路的差异度量
下面三段代码是我常用的基线实现,可以直接嵌进训练循环。它们都只依赖当前 batch,不需要额外的全局统计,显存占用可控。
import torch def mmd_multi_kernel(xs, xt, sigmas=(1.0, 2.0, 4.0, 8.0)): """多带宽高斯核 MMD^2;xs, xt: [B, D];返回标量""" def pairwise_sq(a, b): # 用 (a-b)^2 = a^2 + b^2 - 2ab 展开,避免双重循环 return (a * a).sum(1, keepdim=True) + (b * b).sum(1) - 2.0 * a @ b.t() d_ss, d_tt, d_st = pairwise_sq(xs, xs), pairwise_sq(xt, xt), pairwise_sq(xs, xt) loss = 0.0 for s in sigmas: k = lambda d: torch.exp(-d / (2.0 * s * s)) # 每个带宽一个 RBF 核 loss = loss + k(d_ss).mean() + k(d_tt).mean() - 2.0 * k(d_st).mean() return loss / len(sigmas) # 多核取平均,降低对单一带宽的依赖sigmas是唯一需要按特征尺度调的参数。判断方法很简单:打印 $d_{ss}$ 的中位数,让几个带宽落在这个中位数的 0.5~4 倍之间。带宽全部设成 1.0 是最常见的翻车点——特征范数在 20~50 量级时,所有核值都趋近于 0,MMD 恒等于 0,损失曲线一条直线,看着「收敛得很好」,其实什么都没对齐。多核版本对带宽不敏感,代价是每个 batch 要多算几次核矩阵,$O(B^2D)$ 的开销在 $B\le 128$ 时基本可以忽略。
def coral_loss(xs, xt, eps=1e-5): """二阶统计量对齐;只对协方差差做惩罚,不要求均值一致""" d = xs.size(1) xs_c = xs - xs.mean(0, keepdim=True) xt_c = xt - xt.mean(0, keepdim=True) cs = xs_c.t() @ xs_c / (xs.size(0) - 1) ct = xt_c.t() @ xt_c / (xt.size(0) - 1) return ((cs - ct) ** 2).sum() / (4.0 * d * d) # 4d^2 归一化,量级落在 [0,1]CORAL 的 4d² 归一化不能省。不除的话,特征维度 256 时损失能到几百,和交叉熵不在一个量级,学习率会完全被它主导。它比 MMD 便宜、比对抗式更稳,缺点是只匹配二阶统计量,对多模态结构的偏移无能为力——目标域是源域若干模态的混合时,CORAL 会把特征压成一个「平均形状」,分类边界反而变糊。
class _GradReverse(torch.autograd.Function): @staticmethod def forward(ctx, x, lam): ctx.lam = lam return x.view_as(x) @staticmethod def backward(ctx, g): return -ctx.lam * g, None # 前向恒等,反向乘 -lam def grad_reverse(x, lam=1.0): return _GradReverse.apply(x, lam)梯度反转层是对抗式方法的全部玄机:判别器正常训练、正常降低域分类损失,而特征提取器收到的是取反后的梯度,于是被推着去「骗过」判别器。lam控制这部分梯度相对分类损失的比例,它的调度策略比对决网络结构重要得多,第三章细说。这里只要记住一条血泪经验:lam一开始就设成 1.0,前几百步特征会被域损失拽走,源域精度直接崩,而且很难救回来。
2.3 五类对齐方法的边界对照
| 方法 | 依赖假设 | 关键超参 | 训练开销 | 典型失效场景 |
|---|---|---|---|---|
| MMD(多核) | 边缘分布可对齐 | 核带宽、权重 | 中,$O(B^2)$ | 特征尺度未归一,核值退化 |
| CORAL | 二阶统计量足够描述偏移 | 归一化系数 | 低 | 目标域是多模态混合 |
| DANN(对抗) | 存在域不变特征 | lam 调度、判别器容量 | 中 | 判别器过强,特征坍缩 |
| 类条件对齐 | 伪标签足够准 | 伪标签阈值、类别权重 | 高 | 早期伪标签错,误差自我强化 |
| 最优传输 | 有可用的代价矩阵 | 正则项、迭代步数 | 高 | 样本量大时求解慢,代价矩阵选错 |
我的默认顺序是:先跑一个不做任何对齐的源域模型当基线,再上多核 MMD(便宜、易调、结果可解释),如果 MMD 带来的提升不足两个点,再换对抗式。直接上对抗式的团队,八成的调试时间花在判别器和 lam 上,而不是花在数据上,这个投入产出比通常不划算。
3. 搭一个能跑通的最小领域自适应回路:DANN 骨架与四个必调参数
理论和能跑是两回事。这一章给一个完整的、百来行能跑起来的骨架,重点不在代码本身,而在参数怎么起、失败时看哪个数。
3.1 域标签怎么进 Dataset:双流采样与类别平衡
别把两个域拼成一个大 Dataset 然后随机采样。随机采样会导致每个 batch 里源域目标域比例抖动,域判别器的梯度噪声极大,训练曲线像心电图一样难以判读。做法是每个 batch 固定返回 $n_s$ 个源域样本和 $n_t$ 个目标域样本。
class PairBatchSampler: """每个 batch 固定 bs 个源域 + bt 个目标域,各自独立轮转""" def __init__(self, n_src, n_tgt, bs=32, bt=32): self.n_src, self.n_tgt, self.bs, self.bt = n_src, n_tgt, bs, bt self.steps = max(n_src // bs, n_tgt // bt) def __iter__(self): ps, pt = torch.randperm(self.n_src), torch.randperm(self.n_tgt) for i in range(self.steps): s = ps[(i * self.bs) % self.n_src: (i * self.bs) % self.n_src + self.bs] t = pt[(i * self.bt) % self.n_tgt: (i * self.bt) % self.n_tgt + self.bt] if len(s) < self.bs or len(t) < self.bt: continue # 尾部不足一个 batch 就丢弃 yield s, tbs和bt的比例是有讲究的。源域样本多于目标域时(比如 2:1),分类损失占主导,对齐更保守,负迁移风险低但收益也小;反过来目标域占多数时对齐更激进,收益和风险同时放大。经验起点是 1:1,域差距特别大时再往 1:2 试。另外如果目标域类别明显不均衡,采样器要按类别分层,否则边缘分布对齐会退化成「把多数类对齐到多数类」,少数类全错。
3.2 训练回路:三个模块加一次反向
import torch.nn as nn, torch.nn.functional as F class DANN(nn.Module): def __init__(self, backbone, n_cls, feat_dim, disc_hidden=256): super().__init__() self.encoder = backbone # 输出 feat_dim 维特征 self.cls = nn.Linear(feat_dim, n_cls) self.disc = nn.Sequential( # 判别器别做太深,两层足够 nn.Linear(feat_dim, disc_hidden), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(disc_hidden, 1)) def train_step(batch, model, opt, step, total_steps, lam_max=0.5, ramp_ratio=0.3): xs, ys, xt = batch # 1) 源域监督:label_smoothing 防止过拟合源域 fs = model.encoder(xs) loss_cls = F.cross_entropy(model.cls(fs), ys, label_smoothing=0.1) # 2) 域判别:源域标 0,目标域标 1;lam 在前 ramp_ratio 比例步数内线性爬坡 ft = model.encoder(xt) lam = lam_max * min(1.0, step / max(1, int(ramp_ratio * total_steps))) ds = model.disc(grad_reverse(fs, lam)).squeeze(1) dt = model.disc(grad_reverse(ft, lam)).squeeze(1) loss_dom = 0.5 * (F.binary_cross_entropy_with_logits(ds, torch.zeros_like(ds)) + F.binary_cross_entropy_with_logits(dt, torch.ones_like(dt))) loss = loss_cls + loss_dom opt.zero_grad(); loss.backward(); opt.step() return loss_cls.item(), loss_dom.item(), lam这段代码有三处容易被改坏。第一,判别器的参数梯度没有被反转,只有回传到encoder的那部分被乘了-lam,所以用一个 optimizer 同时更新三个模块是安全的;如果你想给判别器单独设学习率,拆成两个 optimizer,但记住判别器的step()要在同一个 batch 内调用。第二,label_smoothing=0.1不是可有可无,源域只有几万张图时模型三天就能把它背下来,之后域判别器拿到的特征全是记忆产物,对齐毫无意义。第三,lam的爬坡必须存在——它是整个方法里最重要的一行,理由在 3.3 展开。
3.3 四个必调参数与它们的默认值
| 参数 | 默认值 | 作用 | 调坏的征兆 |
|---|---|---|---|
lam_max | 0.3~0.5 | 对齐强度上限 | 太大:源域精度先涨后崩;太小:目标域不动 |
ramp_ratio | 0.3 | 爬坡步数占比 | 太小等于没爬坡,特征早期被拽走 |
| 判别器宽度 | 特征维度的一半 | 判别能力 | 太宽:域准确率秒到 1.0,特征坍缩 |
| 对齐层位置 | 最后一层特征 | 对齐粒度 | 只对齐浅层,深层偏移没被处理 |
lam_max的调法建议二分:先固定ramp_ratio=0.3,在 {0.1, 0.3, 0.5, 1.0} 里扫。判据不是目标域精度(早期你拿不到),而是源域验证精度和域判别准确率这两条曲线。健康的形态是:源域精度小幅下滑 1~2 个点后走平,域判别准确率从接近 1.0 缓慢降到 0.6~0.7 之间震荡。如果域判别准确率一直贴着 1.0,说明判别器碾压特征提取器,要么降lam_max,要么把判别器调窄,要么给判别器加 dropout。如果它掉到 0.5 以下并且源域精度同步跳水,说明对齐过头了。
对齐层位置这件事,实践中分歧最大。只对齐最后一层是标准做法,但很多骨干网络的深层特征已经高度语义化,最后一层的偏移其实被前面的层放大了。我的做法是在最后两到三个 stage 的输出上各接一个轻量判别器(共用一套判别器也行),损失取平均再乘lam。代价是显存涨一点,收益在域差距大的任务上大约能多一到两个点。
4. 微调策略与参数高效迁移:冻结哪几层、学习率怎么分层
不是所有迁移都需要域自适应。当目标域有几百到几千条标注时,微调(fine-tune)通常比任何对齐方法都直接,但微调的参数配置错了,效果可能还不如直接拿源域模型推理。
4.1 冻结策略取决于域距离,不取决于模型大小
判断依据是特征层面的域距离,而不是「数据像不像」。做法很土但有效:用源域模型抽目标域的特征,算目标域同类样本的类内距离和跨类距离之比,再和源域同一个指标比。比值接近,说明底层特征通用,冻结浅层、只训深层和分类头;比值明显变大,说明连底层统计量都变了(比如成像设备的色调、传感器的量纲完全不同),这时候要放开更多层,或者干脆上第三章的对齐回路。
一个常被忽略的基线是线性探针(linear probe):冻结整个骨干,只训一个线性分类头。它是微调的下界,也几乎是零成本的可行性验证。如果线性探针在目标域上只能到 0.6,而全量微调能到 0.85,说明差距来自特征不适配而不是分类边界不适配;如果线性探针就能到 0.83,那全量微调的收益很有限,直接上参数高效方案更划算。
4.2 分层学习率与 BN 的两个开关
def build_param_groups(model, base_lr=1e-3, decay=0.75, freeze_until=0, head_mult=10): """按 stage 从浅到深分配学习率;freeze_until 之前的 stage 全部冻结""" groups, blocks = [], list(model.encoder.children()) n = len(blocks) for i, blk in enumerate(blocks): keep = i >= freeze_until for p in blk.parameters(): p.requires_grad = keep if keep: groups.append({"params": blk.parameters(), "lr": base_lr * (decay ** (n - 1 - i))}) groups.append({"params": model.cls.parameters(), "lr": base_lr * head_mult}) return groups def set_bn_mode(model, mode="source"): for m in model.modules(): if isinstance(m, (nn.BatchNorm2d, nn.BatchNorm1d)): if mode == "source": m.eval() # 冻结源域 running stats,不更新 elif mode == "target": m.train() # 用目标域数据重估统计量(AdaBN 思路)decay=0.75意味着最浅的可训层学习率只有最深层的0.75^(n-1)。层数多的时候这个衰减太狠,浅层几乎不更新,可以把decay提到 0.85~0.9。head_mult=10是新分类头的放大倍数——分类头是随机初始化的,和预训练权重量级差得远,用同一个学习率训,头收敛太慢,训练前期全是噪声。
BN 这两个开关差异巨大,值得单独跑一组对照。目标域和源域图像统计量接近时,mode="source"通常更好,因为它保留了源域上学到的归一化尺度;目标域整体偏暗、偏色、分辨率不同时,mode="target"往往能白捡一到两个点。但有前提:目标域 batch 要够大($\ge 32$),batch 太小时 running stats 估计方差极大,反而恶化。
4.3 参数极省场景:只训一小块新参数
标注极少(目标域每类不到十条)时,全量微调几乎必然过拟合。这时更合适的做法是冻结整个骨干,只训练一个插入式的小模块(降维投影、轻量 adapter,或者只在注意力层加低秩旁路),参数量控制在总参数的 1%~5%。好处有两层:一是过拟合风险低,二是多个目标域可以各自挂一套小模块、共用同一个骨干,部署时省显存。
要注意的是,这类方法在域差距大时的上限明显低于全量微调。我的判断线是:线性探针和全量微调的差距在 5 个点以内,用小模块;超过 10 个点,说明特征本身不适配,得回到第三章的对齐路线,小模块救不了。
5. 避坑与排查:负迁移、判别器过强和隐性泄漏的三类翻车
这一章的四条都是我或同事真实踩过的,按现象、原因、解决写,方便对照自己的训练日志。
5.1 现象:目标域精度比不做任何迁移的源域模型还低
原因基本只有一个——负迁移。细分又分两种:一是类别空间不一致,源域有「货架」这个类,目标域根本没有,强行对齐会把这个类的特征挤到目标域的某个真实类上;二是边缘分布可对齐但条件分布不一致,MMD 掉得很漂亮,分类边界却错位。
解决分三步。第一步,先算源域和目标域在特征空间的类别原型距离,如果某些源域类在目标域找不到近邻原型,直接把这类样本从训练里剔掉,或者降权。第二步,把边缘对齐换成类条件对齐:用高置信度伪标签把目标域样本分组,做类内 MMD。第三步,如果前两步都做了还掉点,接受现实——加一个可学习的域权重,让模型自己决定信源域多少,这比强行对齐诚实得多。
5.2 现象:域判别准确率三步就到 1.0 并且一直不动
原因是对抗失衡。判别器太强或者lam太大,特征提取器完全跟不上,梯度要么爆炸要么把特征压成一个常数向量(特征坍缩,表现为所有样本的余弦相似度接近 1)。
解决:先把lam_max砍一半看曲线是否变化;再给判别器降容量、加 dropout、把它的学习率设成主干的三分之一;同时打开label_smoothing。还有个更隐蔽的原因——特征做了 L2 归一化但判别器第一层没做相应缩放,判别器看到的输入全落在一个球面上,分起来太容易。这种时候在判别器前加一层可学习的缩放,问题立刻消失。
5.3 现象:离线评估很好,上线一周就退化
原因是隐性泄漏。训练时用目标域标签挑了最好的 checkpoint,或者用目标域验证集调了lam_max,这个分数就再也不可信了。目标域标注在真实场景里是稀缺资源,你把它花在调参上,就等于没有测试集。
解决:目标域标注一律锁起来,只在最后评一次,中间所有决策靠无监督指标(下一章展开)。如果必须调,把目标域标注切成互斥的两份,调参用一份、评估用一份,并且记录每次用过哪份,别自己骗自己。
5.4 现象:训练 loss 正常,推理时目标域 batch 大小一变精度就抖
原因是 BN 统计量。训练时目标域 batch 是 32,推理时逐条进来,running stats 和实际分布对不上,尤其在你用了mode="target"重估统计量之后更明显。
解决:训练结束前用目标域的全量无标注数据跑一遍前向,重估并固定 BN 统计量,把结果写进权重;推理阶段一律eval()。如果目标域数据是流式的、分布还在缓慢漂移,那就别用 BN 的 running stats,换 GroupNorm 或 LayerNorm,代价是收敛稍慢,但省掉了这个黑匣子。
6. 用目标域无标签数据做模型选择:重要性加权验证集的搭法
训练几十个 checkpoint 之后,真正难的不是训,是挑。没有目标域标签的情况下,我一般用「源域验证集 + 密度比加权」这条路,成本最低且可解释。
思路很直接:源域验证集上的损失是无偏的,但它是针对源域分布求的期望,要估目标域上的期望,就得给每个源域验证样本乘一个权重 $w(x)=p_t(x)/p_s(x)$。这个比值不用另外训模型,域判别器现成就有:判别器输出经过 sigmoid 得到 $d(x)$,在源域标 0、目标域标 1 的设定下,$w(x)=d(x)/(1-d(x))$ 就是密度比的估计。
@torch.no_grad() def iw_score(model, src_val_loader, disc): """返回目标域风险的加权估计,越小越好""" total, wsum = 0.0, 0.0 for x, y in src_val_loader: f = model.encoder(x) loss = F.cross_entropy(model.cls(f), y, reduction="none") d = torch.sigmoid(disc(f).squeeze(1)).clamp(1e-3, 1 - 1e-3) w = (d / (1 - d)).clamp(max=50.0) # 截断,防个别样本权重爆炸 total += (w * loss).sum().item(); wsum += w.sum().item() return total / max(wsum, 1e-6)clamp(max=50)那一步不能省。密度比在判别器饱和时会趋向无穷,一两个样本就能把整个分数带偏,这是我最常提醒别人改的一行。
几种无监督选择指标的适用边界,我按经验排了个序:
| 指标 | 依赖 | 与目标域真实精度的排序一致性 | 适用前提 |
|---|---|---|---|
| 重要性加权源域验证损失 | 域判别器 | 较高 | 判别器未饱和、域差异中等 |
| 目标域预测熵 | 无 | 中 | 类别均衡、模型不过自信 |
| 置信度均值 | 无 | 低 | 只适合做粗筛 |
| 目标域伪标签一致率 | 伪标签阈值 | 中 | 需配合多个阈值取平均 |
熵这类指标有个通病:它奖励「敢下判断」的模型,而不是「判断正确」的模型,训练早期特别容易选出过度自信的坏 checkpoint。所以我的习惯是两个指标交叉——重要性加权分数做主排序,熵做一致性校验,两者排序冲突的 checkpoint 直接不要。
最后说个我自己的教训。我做过一个跨设备的图像分类任务,前两周一直在追对齐损失,MMD 从 0.42 压到 0.03,目标域精度一动不动。后来把源域里两个语义错位的类删掉,什么都没改,精度直接涨了六个点。从那以后我养成了一个习惯:动手调任何对齐超参之前,先花半天看数据,把源域和目标域的类别原型画出来看一眼。迁移学习理论与算法给的是判断框架,不是万能药,负迁移这道坎,八成得靠数据解决,而不是靠更花哨的损失函数。希望帮到你。
本文还有配套的精品资源,点击获取