简介:这份资源是清华大学龙明盛老师《迁移学习理论与算法》的PDF讲义,面向机器学习方向的研究生、算法工程师及希望系统理解域适应理论的读者,用于解决源域与目标域分布不一致时的模型泛化问题。压缩包内共1个PDF文件,大小约13.68MB,内容以理论推导与算法框架为主线,涵盖H∆H-Divergence、DAN、DANN、MCD、MDD等代表性方法,并延伸至DEV深度嵌入式模型选择以及Accuracy、Precision、Recall、F1-score等评估实现环节。讲义从监督学习的误差界出发,逐步引出域间差异度量、偏差-方差-漂移权衡,再到算法与性能的桥接,结构紧凑、公式与图示并重,适合作为迁移学习课程复习或科研入门的参考材料。目前已有268人学习,便于读者快速建立从理论到算法的完整认知脉络。
1. 迁移学习到底在解决什么问题:从龙明盛的理论框架说起
你在做一个图像分类任务,手头只有几千张标注样本,模型训到 70% 准确率就上不去了。但隔壁团队在 ImageNet 上训好的 ResNet 拿来一测,直接 85% 起步。这中间的差距,就是迁移学习要填的坑。龙明盛教授的《迁移学习理论与算法》系统梳理了这件事的数学本质:当源域和目标域的数据分布不一致时,如何把源域学到的知识有效地搬到目标域。这不是简单的 fine-tune 就完事,背后涉及分布度量、领域自适应、样本权重调整等一整套理论工具。直推式迁移学习、数值最优化算法与理论这些热词之所以反复出现,是因为工业界真正卡住的不是“能不能用预训练模型”,而是“什么时候该迁移、迁移多少、怎么判断迁移有没有用”。这篇内容面向需要把迁移学习落地到实际任务的算法工程师和研究生,从理论框架讲到可复现的实现路径,再到参数怎么设、坑在哪。
2. 迁移学习的数学基础:分布差异怎么度量与优化
2.1 从经验风险最小化到领域自适应
标准监督学习假设训练数据和测试数据独立同分布,经验风险最小化直接优化:
$$\min_f \frac{1}{n}\sum_{i=1}^{n} L(f(x_i), y_i)$$
迁移学习打破了这个假设。源域 $D_S = {(x_i^S, y_i^S)}{i=1}^{n_S}$ 和目标域 $D_T = {(x_j^T, y_j^T)}{j=1}^{n_T}$ 的联合分布不同:$P_S(x,y) \neq P_T(x,y)$。龙明盛在书中把迁移学习按“迁移什么”分为四类:基于样本的迁移、基于特征的迁移、基于模型的迁移、基于关系的迁移。工业界最常用的是基于特征和基于模型的两类。
核心思路是:找到一个变换 $\phi$,使得变换后的源域和目标域分布尽可能接近,同时保留对任务有用的判别信息。这就引出了两个关键问题——用什么度量分布差异,以及怎么把这个度量嵌入优化目标。
2.2 最大均值差异(MMD)及其核方法实现
MMD 是迁移学习里最常用的分布度量工具。给定源域样本 ${x_i^S}$ 和目标域样本 ${x_j^T}$,MMD 的平方经验估计为:
$$\text{MMD}^2 = \left| \frac{1}{n_S}\sum_{i=1}^{n_S} \phi(x_i^S) - \frac{1}{n_T}\sum_{j=1}^{n_T} \phi(x_j^T) \right|_{\mathcal{H}}^2$$
其中 $\phi$ 是映射到再生核希尔伯特空间(RKHS)的特征映射。用核技巧展开后可以直接用核矩阵计算,不需要显式定义 $\phi$。
下面是一个用 Python 计算线性核 MMD 的最小实现:
import numpy as np def mmd_linear(X_source, X_target): """ 计算源域和目标域之间的线性 MMD 平方值 X_source: (n_s, d) 源域特征矩阵 X_target: (n_t, d) 目标域特征矩阵 返回: MMD^2 标量 """ n_s = X_source.shape[0] n_t = X_target.shape[0] # 源域均值与目标域均值 mean_s = X_source.mean(axis=0) # (d,) mean_t = X_target.mean(axis=0) # (d,) # 线性核下 MMD^2 = ||mean_s - mean_t||^2 diff = mean_s - mean_t mmd2 = np.dot(diff, diff) return mmd2 # 使用示例 np.random.seed(42) X_s = np.random.randn(500, 128) # 源域:500个样本,128维 X_t = np.random.randn(300, 128) + 0.5 # 目标域:均值偏移0.5 print(f"MMD^2 = {mmd_linear(X_s, X_t):.4f}")这段代码的逻辑很直接:线性核下 MMD 退化为两个分布均值之差的欧氏距离平方。参数说明——X_source和X_target的维度必须一致(同一个特征空间),样本数可以不同。实际使用中如果分布差异是非线性的,需要换成高斯核:
def mmd_rbf(X_source, X_target, gamma=1.0): """ 高斯核(RBF)MMD 平方的无偏估计 gamma: 核带宽参数,控制对分布差异的敏感度 """ n_s = X_source.shape[0] n_t = X_target.shape[0] # 计算核矩阵 K_ss = np.exp(-gamma * np.sum((X_source[:, None] - X_source[None, :])**2, axis=-1)) K_tt = np.exp(-gamma * np.sum((X_target[:, None] - X_target[None, :])**2, axis=-1)) K_st = np.exp(-gamma * np.sum((X_source[:, None] - X_target[None, :])**2, axis=-1)) # 无偏估计:去掉对角项 mmd2 = (K_ss.sum() - np.trace(K_ss)) / (n_s * (n_s - 1)) \ + (K_tt.sum() - np.trace(K_tt)) / (n_t * (n_t - 1)) \ - 2 * K_st.mean() return mmd2gamma是最关键的参数。太小则所有样本对核值都接近1,区分度消失;太大则只有极近邻有非零核值,估计方差爆炸。经验做法是在中位数距离的倒数附近搜索,比如gamma = 1 / median_pairwise_distance。
2.3 把 MMD 嵌入网络训练:DAN 的损失设计
知道了怎么度量分布差异,下一步就是把它作为正则项加入训练目标。龙明盛团队提出的 DAN(Deep Adaptation Network)是经典做法:在网络的多个全连接层后计算源域和目标域的 MMD,加到分类损失上。
总损失函数:
$$\mathcal{L} = \mathcal{L}{cls} + \lambda \sum{l \in \mathcal{L}_{adapt}} \text{MMD}^2(\phi_l(X_S), \phi_l(X_T))$$
其中 $\lambda$ 控制迁移正则的强度,$\mathcal{L}_{adapt}$ 是需要对齐的层集合。下面是一个 PyTorch 实现的核心片段:
import torch import torch.nn as nn class DANLoss(nn.Module): def __init__(self, lambda_mmd=0.5, gamma=1.0): super().__init__() self.lambda_mmd = lambda_mmd # 迁移损失权重 self.gamma = gamma # RBF核带宽 def forward(self, source_features, target_features, source_logits, source_labels): # 分类损失 cls_loss = nn.CrossEntropyLoss()(source_logits, source_labels) # 多层 MMD 损失 mmd_loss = 0.0 for s_feat, t_feat in zip(source_features, target_features): mmd_loss += self._rbf_mmd(s_feat, t_feat) total = cls_loss + self.lambda_mmd * mmd_loss return total, cls_loss.item(), mmd_loss.item() def _rbf_mmd(self, X_s, X_t): n_s, n_t = X_s.size(0), X_t.size(0) # 用pairwise距离计算RBF核 diff_ss = torch.cdist(X_s, X_s, p=2) ** 2 diff_tt = torch.cdist(X_t, X_t, p=2) ** 2 diff_st = torch.cdist(X_s, X_t, p=2) ** 2 K_ss = torch.exp(-self.gamma * diff_ss) K_tt = torch.exp(-self.gamma * diff_tt) K_st = torch.exp(-self.gamma * diff_st) mmd = (K_ss.sum() - K_ss.diag().sum()) / (n_s * (n_s - 1)) \ + (K_tt.sum() - K_tt.diag().sum()) / (n_t * (n_t - 1)) \ - 2 * K_st.mean() return mmd参数说明:lambda_mmd通常从 0.1 到 1.0 之间调,太大模型会过度对齐导致源域判别能力下降,太小则迁移效果不明显。gamma建议根据特征维度和数值范围做归一化后取 1.0 左右。训练时源域和目标域数据要同一个 batch 里交替送入,不能分开训。
3. 从理论到代码:搭建一个可复现的迁移学习实验
3.1 数据准备与域划分策略
理论讲完了,现在动手搭一个完整的实验。以 Office-31 数据集为例,它包含三个域:Amazon(A)、Webcam(W)、DSLR(D),每个域 31 个类别。常见的迁移任务有 A→W、D→A 等六组。
数据加载的核心是保证源域有标签、目标域在训练时只有无标签数据(直推式迁移学习设定),测试时用目标域的标签评估。
import torch from torch.utils.data import DataLoader, Dataset from torchvision import transforms, datasets class DomainDataset(Dataset): """包装单个域的数据集,支持返回索引用于对齐""" def __init__(self, root, domain, transform=None, return_label=True): self.data = datasets.ImageFolder( root=f"{root}/{domain}/images", transform=transform ) self.return_label = return_label def __len__(self): return len(self.data) def __getitem__(self, idx): img, label = self.data[idx] if self.return_label: return img, label return img, -1 # 目标域训练时不提供真实标签 # 图像预处理:源域用强增强,目标域用弱增强 source_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) target_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 构建 A→W 任务的数据加载器 source_dataset = DomainDataset("./Office31", "amazon", source_transform) target_dataset_train = DomainDataset("./Office31", "webcam", target_transform, return_label=False) target_dataset_test = DomainDataset("./Office31", "webcam", target_transform, return_label=True) source_loader = DataLoader(source_dataset, batch_size=32, shuffle=True, drop_last=True) target_loader = DataLoader(target_dataset_train, batch_size=32, shuffle=True, drop_last=True) test_loader = DataLoader(target_dataset_test, batch_size=64, shuffle=False)关键点:drop_last=True必须加,因为 MMD 计算需要源域和目标域 batch 大小一致。目标域训练集不返回标签,这是直推式迁移学习的基本设定——训练时看不到目标域标签,但可以看到目标域数据本身。
3.2 网络结构与迁移层选择
用 ResNet-50 做 backbone,替换最后的全连接层为任务分类头。迁移层选在layer4的输出和分类头之前。
import torch.nn as nn from torchvision.models import resnet50, ResNet50_Weights class TransferNet(nn.Module): def __init__(self, num_classes=31, bottleneck_dim=256): super().__init__() backbone = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) # 去掉原始fc层 self.features = nn.Sequential(*list(backbone.children())[:-1]) self.fc1 = nn.Linear(2048, bottleneck_dim) self.bn = nn.BatchNorm1d(bottleneck_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(bottleneck_dim, num_classes) def forward(self, x): feat = self.features(x).flatten(1) # (B, 2048) bottleneck = self.relu(self.bn(self.fc1(feat))) # (B, 256) logits = self.fc2(bottleneck) return bottleneck, logits迁移层选在bottleneck输出(256维)和fc2之前。为什么不在更浅的层做对齐?浅层特征更通用,域差异小,对齐收益低;深层特征域差异大,对齐收益高但难度也大。实践中在 bottleneck 层做 MMD 对齐是性价比最高的选择。
3.3 训练循环与参数配置
def train_dan(model, source_loader, target_loader, test_loader, epochs=50): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) # 优化器:backbone用小学习率,新加层用大学习率 params = [ {"params": model.features.parameters(), "lr": 1e-4}, {"params": model.fc1.parameters(), "lr": 1e-3}, {"params": model.bn.parameters(), "lr": 1e-3}, {"params": model.fc2.parameters(), "lr": 1e-3}, ] optimizer = torch.optim.SGD(params, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) criterion = DANLoss(lambda_mmd=0.5, gamma=1.0) for epoch in range(epochs): model.train() target_iter = iter(target_loader) for src_imgs, src_labels in source_loader: # 取目标域一个batch try: tgt_imgs, _ = next(target_iter) except StopIteration: target_iter = iter(target_loader) tgt_imgs, _ = next(target_iter) src_imgs, src_labels = src_imgs.to(device), src_labels.to(device) tgt_imgs = tgt_imgs.to(device) # 前向 src_bottleneck, src_logits = model(src_imgs) tgt_bottleneck, _ = model(tgt_imgs) # 损失 loss, cls_l, mmd_l = criterion( [src_bottleneck], [tgt_bottleneck], src_logits, src_labels ) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 每5个epoch评估一次 if (epoch + 1) % 5 == 0: acc = evaluate(model, test_loader, device) print(f"Epoch {epoch+1}: cls_loss={cls_l:.4f}, " f"mmd_loss={mmd_l:.4f}, target_acc={acc:.2f}%") def evaluate(model, loader, device): model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) _, logits = model(imgs) preds = logits.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return 100.0 * correct / total参数配置的要点:backbone 学习率设小(1e-4)是因为预训练权重已经很好,大学习率会破坏;新加的 fc1/fc2 学习率设大(1e-3)是因为要快速适应新任务。lambda_mmd=0.5是 Office-31 上的经验值,如果目标域和源域差异更大(比如 A→D),可以调到 1.0;差异小(W→D)可以降到 0.1。
4. 迁移学习落地避坑:那些论文里不会写的问题
4.1 负迁移:迁移之后效果反而变差了
现象:源域模型在目标域上的准确率比直接在目标域上训一个从零开始的模型还低。
原因:源域和目标域的分布差异太大,强行对齐反而把源域的噪声和无关特征也迁移过来了。比如用 ImageNet 预训练模型去迁移到医学影像,纹理和语义差异巨大。
解决:先做分布差异的定量评估。用 MMD 或 A-distance 算一下源域和目标域的距离,如果 MMD 值比源域内部随机划分的 MMD 大一个数量级以上,就要谨慎。应对策略包括:只迁移浅层特征(冻结深层)、减小lambda_mmd、或者改用基于样本权重的方法(给源域中与目标域更近的样本更高权重)。
4.2 MMD 损失不收敛或震荡
现象:训练日志里mmd_loss忽大忽小,目标域准确率不升反降。
原因:gamma参数设置不当。如果特征维度是 256,特征值范围在 [-3, 3] 之间,gamma=1.0时 RBF 核的带宽太窄,只有非常近的样本对才有非零核值,导致 MMD 估计的方差极大。
解决:对特征做 L2 归一化后再算 MMD,或者用中位数启发式设置gamma:
def median_heuristic(X_s, X_t): """根据中位数距离设置gamma""" X = torch.cat([X_s, X_t], dim=0) dists = torch.cdist(X, X, p=2) median_dist = dists.median() gamma = 1.0 / (median_dist ** 2 + 1e-8) return gamma.item()在训练初期用这个值,后期可以适当增大gamma让对齐更精细。
4.3 Batch 内类别不平衡导致对齐偏差
现象:目标域上某些类别的准确率特别低,其他类别正常。
原因:源域和目标域的 batch 是随机采样的,如果某个 batch 里源域只有 2 个“键盘”类样本,而目标域有 10 个“鼠标”类样本,MMD 会强行把这两个不同类别的分布拉近,破坏判别结构。
解决:用类别平衡采样。源域按类别分层采样,目标域虽然无标签但可以用聚类伪标签做粗分层。更简单的做法是增大 batch size(至少 64),让每个 batch 里类别分布更均匀。
4.4 目标域测试集泄露到训练中
现象:实验报告的目标域准确率很高,但换一批新数据就崩了。
原因:在调参过程中反复用目标域测试集评估,实际上已经过拟合了测试集。这在直推式迁移学习里尤其容易发生,因为目标域数据在训练时就是可见的。
解决:从目标域数据里划出一部分作为验证集,调参只看验证集,测试集只在最终报告时用一次。如果目标域数据量本来就少,用交叉验证。
4.5 预训练权重与任务不匹配
现象:用 ImageNet 预训练的 ResNet 迁移到遥感图像分类,效果不如预期。
原因:ImageNet 是自然图像,遥感图像是俯视图,低层纹理差异就很大。直接迁移所有层反而引入噪声。
解决:只迁移浅层(layer1、layer2),深层从零训练。或者用领域相关的预训练权重(比如遥感领域有专门的预训练模型)。判断标准是:如果源域和目标域的低层特征可视化后差异明显,就不要迁移深层。
5. 进阶技巧:用数值最优化视角调迁移学习
5.1 把 MMD 对齐看作一个正则化优化问题
从数值最优化算法与理论的角度看,DAN 的训练目标可以写成:
$$\min_\theta \mathcal{L}{cls}(\theta) + \lambda \cdot \mathcal{R}{mmd}(\theta)$$
其中 $\mathcal{R}{mmd}$ 是 MMD 正则项。这个问题的难点在于:$\mathcal{L}{cls}$ 只在源域上有定义,$\mathcal{R}_{mmd}$ 在源域和目标域上都有定义,两者的梯度尺度可能差几个数量级。如果直接相加,优化过程会被其中一个主导。
一个实用的技巧是梯度归一化:在每个 batch 里分别计算两个损失的梯度范数,然后按比例缩放。
def gradient_normalized_step(model, cls_loss, mmd_loss, optimizer, alpha=0.5): """ 对分类损失和MMD损失做梯度归一化后再更新 alpha: MMD损失的相对权重 """ # 计算分类损失梯度 optimizer.zero_grad() cls_loss.backward(retain_graph=True) cls_grad_norm = sum(p.grad.norm() ** 2 for p in model.parameters() if p.grad is not None) ** 0.5 # 计算MMD损失梯度 optimizer.zero_grad() mmd_loss.backward(retain_graph=True) mmd_grad_norm = sum(p.grad.norm() ** 2 for p in model.parameters() if p.grad is not None) ** 0.5 # 按比例缩放后合并 optimizer.zero_grad() (cls_loss / (cls_grad_norm + 1e-8) + alpha * mmd_loss / (mmd_grad_norm + 1e-8)).backward() optimizer.step()这个做法让两个损失的梯度范数在同一量级,alpha就变成了一个更直观的“相对权重”,不用再反复调lambda_mmd。
5.2 用验证集上的 MMD 值做早停
训练过程中监控目标域验证集上的 MMD 值,如果连续几个 epoch 不再下降,说明对齐已经饱和,继续训只会过拟合源域。
def compute_val_mmd(model, source_loader, target_val_loader, device, max_batches=10): """在验证集上计算MMD,用于早停判断""" model.eval() src_feats, tgt_feats = [], [] with torch.no_grad(): for i, (imgs, _) in enumerate(source_loader): if i >= max_batches: break feat, _ = model(imgs.to(device)) src_feats.append(feat.cpu()) for i, (imgs, _) in enumerate(target_val_loader): if i >= max_batches: break feat, _ = model(imgs.to(device)) tgt_feats.append(feat.cpu()) X_s = torch.cat(src_feats, dim=0) X_t = torch.cat(tgt_feats, dim=0) return mmd_rbf(X_s.numpy(), X_t.numpy(), gamma=1.0)早停策略:如果验证 MMD 在 5 个 epoch 内下降幅度小于 1%,就停止训练。这个习惯帮我省了很多无效训练时间,也避免了过拟合。
5.3 一个具体的调参顺序
血泪经验总结出来的调参顺序,按优先级从高到低:
| 优先级 | 参数 | 推荐范围 | 影响 |
|---|---|---|---|
| 1 | backbone 学习率 | 1e-5 ~ 1e-4 | 太大破坏预训练权重,太小迁移不动 |
| 2 | lambda_mmd | 0.1 ~ 1.0 | 控制对齐强度,过大导致负迁移 |
| 3 | gamma | 中位数启发式 | 影响 MMD 估计质量 |
| 4 | batch size | 32 ~ 64 | 太小导致 MMD 估计方差大 |
| 5 | 迁移层选择 | bottleneck 层 | 浅层对齐收益低,深层难度大 |
先固定lambda_mmd=0.5、gamma用中位数启发式,调 backbone 学习率直到源域准确率稳定。然后调lambda_mmd,每次翻倍或减半,观察目标域验证集准确率。最后微调gamma。
我自己的习惯是:每次实验只改一个参数,改完跑三个随机种子取平均。迁移学习的方差比普通监督学习大,单次结果不可信。希望帮到你。
本文还有配套的精品资源,点击获取