简介:这份资源是清华大学龙明盛老师《迁移学习理论与算法》的PDF讲义,面向机器学习方向的研究生、算法工程师及希望系统理解域适应理论的读者,用于解决源域与目标域分布不一致时的模型泛化问题。压缩包内仅1个PDF文件,约13.68MB,内容以理论推导与算法框架为主,涵盖H∆H-Divergence、DAN、DANN、MCD、MDD等经典方法,并延伸至DEV深度嵌入式模型选择与Accuracy、Precision、Recall、F1-score等评估实现环节。讲义从监督学习的误差界出发,逐步过渡到域间差异度量与对抗式表示学习,配有公式推导和算法对比,适合作为迁移学习课程讲义或研究入门参考。目前已有268人学习,对需要夯实域适应理论基础、梳理算法脉络的读者具有较高参考价值。
1. 迁移学习到底在解决什么问题:从龙明盛的理论框架说起
你在做一个工业质检项目,标注了三千张缺陷样本,模型在测试集上表现不错,但换了一条产线、换了一批光照条件,准确率直接掉到没法看的程度。重新标注?成本高、周期长,老板等不起。这时候迁移学习就是那根救命稻草——它要解决的核心问题只有一个:当目标域标注数据稀缺甚至没有时,如何把源域学到的知识搬过来用。
龙明盛在《迁移学习理论与算法》里把这套方法论拆得很清楚:从问题定义、度量准则到算法设计,形成了一条完整的链路。这不是一本翻两页就犯困的理论书,它的价值在于给出了可落地的数学框架——什么条件下知识可迁移、用什么度量衡量两个域的距离、怎么把分布对齐写进损失函数里。适合谁看?做跨域检测、跨场景推荐、小样本分类的工程师,以及需要把实验室模型推到真实业务里、被域偏移反复折磨的人。直推式迁移学习、数值最优化算法与理论这些热搜词背后,反映的正是大家在“怎么对齐分布”和“怎么把优化解出来”这两件事上的真实焦虑。
2. 迁移学习的数学底座:域、任务与分布距离怎么定义
2.1 域和任务的形式化:别被符号吓住
迁移学习的形式化定义其实不复杂。一个域由两部分组成:特征空间和边缘概率分布。一个任务也由两部分组成:标签空间和条件概率分布。源域和目标域不同,要么是特征空间不同,要么是边缘分布不同,要么是标签空间不同,要么是条件分布不同。四种不同组合,对应了迁移学习的不同分支。
我一般会用一个具体例子来理解:假设你在做电商评论情感分类。源域是书评数据,目标域是电子产品评论。特征空间都是文本词袋,标签空间都是正面/负面,但边缘分布不同——书评里“引人入胜”出现频率高,电子产品评论里“续航持久”出现频率高。条件分布也可能不同——同样出现“轻薄”,在书评里可能是褒义,在电子产品评论里可能是在说手感但未必是好评。这就是为什么直接拿源域模型去目标域跑,效果会打折扣。
龙明盛在书里强调了一个关键点:迁移学习的可行性取决于域之间的“可迁移性”。如果两个域毫无关联,强行迁移只会带来负迁移。判断可迁移性,需要引入分布距离度量。
2.2 分布距离度量:MMD 和它的变体
最大均值差异是迁移学习里最常用的分布距离度量。它的核心思想很直白:如果两个分布相同,那么把任意函数在这两个分布上的期望值相减,结果应该为零。实际操作中,我们用核函数把样本映射到再生核希尔伯特空间,然后计算两个分布在那个空间里的均值距离。
import numpy as np def mmd_linear(X_source, X_target, gamma=1.0): """ 计算源域和目标域之间的线性MMD距离 X_source: 源域特征矩阵, shape=(n_s, d) X_target: 目标域特征矩阵, shape=(n_t, d) gamma: RBF核的带宽参数 """ n_s = X_source.shape[0] n_t = X_target.shape[0] # 计算源域内部的核矩阵 K_ss = rbf_kernel(X_source, X_source, gamma) # 计算目标域内部的核矩阵 K_tt = rbf_kernel(X_target, X_target, gamma) # 计算源域和目标域之间的核矩阵 K_st = rbf_kernel(X_source, X_target, gamma) # MMD的无偏估计 mmd = (K_ss.sum() / (n_s * (n_s - 1)) + K_tt.sum() / (n_t * (n_t - 1)) - 2 * K_st.sum() / (n_s * n_t)) return mmd def rbf_kernel(X, Y, gamma): """RBF核函数""" XX = np.sum(X**2, axis=1, keepdims=True) YY = np.sum(Y**2, axis=1, keepdims=True) XY = np.dot(X, Y.T) dist = XX + YY.T - 2 * XY return np.exp(-gamma * dist)这段代码里,gamma控制核函数的带宽。gamma太大,核矩阵接近单位矩阵,MMD 估计方差大;gamma太小,所有样本的核值都接近 1,MMD 趋近于零,失去区分度。我一般会取特征维度倒数作为初始值,然后在验证集上微调。注意 MMD 的无偏估计里分母是n*(n-1)而不是n*n,这是为了避免自己和自己比较带来的偏差。
龙明盛书里还介绍了多核 MMD,就是把多个不同带宽的核函数加权组合,让距离度量更鲁棒。实际用的时候,如果单核效果不稳定,换成多核通常能提升几个点。
2.3 从度量到算法:分布对齐的三种思路
有了距离度量,接下来就是怎么把它塞进模型训练里。常见做法有三类:
第一类是样本加权。给源域样本算权重,和目标域分布更接近的样本权重大,差得远的权重小。这种方法实现简单,但权重估计本身可能不稳定。
第二类是特征变换。学一个映射,把源域和目标域的特征映射到公共子空间,在子空间里分布距离最小。TCA 和 JDA 是经典代表。TCA 只对齐边缘分布,JDA 同时对齐边缘分布和条件分布。
第三类是模型参数自适应。在损失函数里加正则项,约束源域和目标域的模型参数不要差太远。Fine-tune 就是最朴素的形式。
选哪种?如果目标域有少量标注,优先考虑 Fine-tune 加分布对齐正则;如果目标域完全无标注,TCA/JDA 这类特征变换方法更稳。我自己的经验是,工业场景里目标域通常能搞到几十条标注,这时候在预训练模型基础上做对抗式域适应,效果比纯无监督方法好一大截。
3. 动手复现 TCA 和 JDA:从核矩阵到特征分解
3.1 TCA 的完整实现步骤
TCA 的目标是找到一个变换矩阵,把源域和目标域的数据映射到一个低维公共空间,在这个空间里两个域的边缘分布距离最小。数学上可以转化为一个广义特征分解问题。
def tca(X_source, X_target, dim=30, kernel_type='rbf', gamma=1.0, mu=1.0): """ TCA: 迁移成分分析 X_source: 源域特征, shape=(n_s, d) X_target: 目标域特征, shape=(n_t, d) dim: 降维后的维度 kernel_type: 核函数类型 gamma: RBF核带宽 mu: 正则化参数 返回: 源域和目标域在公共空间中的表示 """ n_s, n_t = X_source.shape[0], X_target.shape[0] X = np.vstack([X_source, X_target]) # 构建核矩阵K if kernel_type == 'rbf': K = rbf_kernel(X, X, gamma) elif kernel_type == 'linear': K = np.dot(X, X.T) # 构建MMD矩阵L L = np.zeros((n_s + n_t, n_s + n_t)) L[:n_s, :n_s] = 1.0 / (n_s * n_s) L[n_s:, n_s:] = 1.0 / (n_t * n_t) L[:n_s, n_s:] = -1.0 / (n_s * n_t) L[n_s:, :n_s] = -1.0 / (n_s * n_t) # 构建中心化矩阵H H = np.eye(n_s + n_t) - 1.0 / (n_s + n_t) * np.ones((n_s + n_t, n_s + n_t)) # 广义特征分解: (K L K + mu I) W = K H K W Lambda # 实际求解时转化为: inv(K L K + mu I) @ (K H K) 的特征分解 A = np.linalg.inv(np.dot(np.dot(K, L), K) + mu * np.eye(n_s + n_t)) B = np.dot(np.dot(K, H), K) eig_vals, eig_vecs = np.linalg.eig(np.dot(A, B)) # 按特征值从大到小排序,取前dim个 idx = np.argsort(eig_vals)[::-1][:dim] W = eig_vecs[:, idx].real # 得到降维后的表示 Z = np.dot(K, W) Z_source = Z[:n_s, :] Z_target = Z[n_s:, :] return Z_source, Z_target, Wmu是正则化参数,防止矩阵不可逆。我一般设 0.1 到 1.0 之间。dim是降维后的维度,太小会丢信息,太大会保留域间差异,通常取 30 到 100 之间,根据原始特征维度调整。核函数带宽gamma对结果影响很大,建议用多核或者交叉验证选。
跑完 TCA 之后,把降维后的源域特征拿去训练分类器,再在目标域上测试。如果目标域有少量标注,可以在降维后的空间里做半监督学习。
3.2 JDA 的改进:同时对齐边缘分布和条件分布
JDA 在 TCA 基础上多了一步:不仅让边缘分布距离小,还让条件分布距离小。条件分布距离的计算需要伪标签——先用源域模型给目标域打伪标签,然后迭代优化。
def jda(X_source, y_source, X_target, dim=30, gamma=1.0, mu=1.0, max_iter=10, kernel_type='rbf'): """ JDA: 联合分布适配 在TCA基础上增加条件分布对齐,通过伪标签迭代 """ n_s, n_t = X_source.shape[0], X_target.shape[0] X = np.vstack([X_source, X_target]) # 初始伪标签:用源域数据训练一个简单分类器 from sklearn.linear_model import LogisticRegression clf = LogisticRegression(max_iter=1000) clf.fit(X_source, y_source) y_pseudo = clf.predict(X_target) # 合并标签用于条件分布计算 y_all = np.concatenate([y_source, y_pseudo]) classes = np.unique(y_source) for iteration in range(max_iter): # 构建核矩阵 K = rbf_kernel(X, X, gamma) H = np.eye(n_s + n_t) - 1.0 / (n_s + n_t) * np.ones((n_s + n_t, n_s + n_t)) # 边缘分布MMD矩阵 L_marginal = np.zeros((n_s + n_t, n_s + n_t)) L_marginal[:n_s, :n_s] = 1.0 / (n_s * n_s) L_marginal[n_s:, n_s:] = 1.0 / (n_t * n_t) L_marginal[:n_s, n_s:] = -1.0 / (n_s * n_t) L_marginal[n_s:, :n_s] = -1.0 / (n_s * n_t) # 条件分布MMD矩阵 L_conditional = np.zeros((n_s + n_t, n_s + n_t)) for c in classes: idx_s = np.where(y_all[:n_s] == c)[0] idx_t = np.where(y_all[n_s:] == c)[0] + n_s n_sc, n_tc = len(idx_s), len(idx_t) if n_sc == 0 or n_tc == 0: continue L_conditional[np.ix_(idx_s, idx_s)] = 1.0 / (n_sc * n_sc) L_conditional[np.ix_(idx_t, idx_t)] = 1.0 / (n_tc * n_tc) L_conditional[np.ix_(idx_s, idx_t)] = -1.0 / (n_sc * n_tc) L_conditional[np.ix_(idx_t, idx_s)] = -1.0 / (n_sc * n_tc) # 联合分布矩阵 L = L_marginal + L_conditional # 广义特征分解 A = np.linalg.inv(np.dot(np.dot(K, L), K) + mu * np.eye(n_s + n_t)) B = np.dot(np.dot(K, H), K) eig_vals, eig_vecs = np.linalg.eig(np.dot(A, B)) idx = np.argsort(eig_vals)[::-1][:dim] W = eig_vecs[:, idx].real # 更新伪标签 Z = np.dot(K, W) Z_source, Z_target = Z[:n_s, :], Z[n_s:, :] clf = LogisticRegression(max_iter=1000) clf.fit(Z_source, y_source) y_pseudo_new = clf.predict(Z_target) # 判断收敛 if np.array_equal(y_pseudo_new, y_pseudo): break y_pseudo = y_pseudo_new y_all = np.concatenate([y_source, y_pseudo]) return Z_source, Z_target, WJDA 的迭代次数max_iter一般设 10 次以内就能收敛。伪标签的质量直接影响条件分布对齐的效果,如果初始伪标签太差,JDA 可能还不如 TCA。我一般会先用源域模型在目标域上跑一个基线,如果准确率低于随机猜,说明域差异太大,JDA 也救不了,得考虑换源域或者加中间域。
3.3 参数怎么调:核带宽、维度和正则项
这三个参数是 TCA/JDA 的命门。核带宽gamma决定样本间的相似度衰减速度,我一般用1/特征维度作为初始值,然后按[0.1, 0.5, 1, 2, 5]倍率搜索。维度dim取 30 到 100,如果原始特征维度低于 50,直接取原始维度的一半。正则项mu取 0.1 到 1.0,数据噪声大就取大一点。
有个血泪经验:不要用目标域的标签来调参。如果目标域有标签,那还做什么迁移学习?调参只能用源域验证集或者目标域的无监督指标。我见过有人拿目标域测试集调参,然后报告一个漂亮的结果,实际部署直接翻车。
4. 深度迁移学习实战:用预训练网络做域适应
4.1 为什么微调不是万能药
很多人觉得迁移学习就是拿预训练模型微调一下。微调确实有用,但它有个前提:源域和目标域的数据分布不能差太远。如果源域是自然图像,目标域是医学影像,微调的效果可能还不如从零训练。因为预训练模型学到的特征在目标域上可能根本不适用。
深度域适应的思路是:在微调的同时,显式地约束源域和目标域的特征分布。常见做法是在特征提取层后面加一个域判别器,让判别器分不清特征是来自源域还是目标域,从而迫使特征提取器学到域不变特征。这就是 DANN 的核心思想。
4.2 DANN 的损失函数设计与梯度反转
DANN 的结构很简单:一个特征提取器,一个标签预测器,一个域判别器。标签预测器要最小化分类损失,域判别器要最大化域分类损失,特征提取器要最小化域分类损失。这三者形成对抗。
import torch import torch.nn as nn from torch.autograd import Function class GradientReversal(Function): """梯度反转层:前向不变,反向传播时梯度取反并乘以lambda""" @staticmethod def forward(ctx, x, lambda_): ctx.lambda_ = lambda_ return x.view_as(x) @staticmethod def backward(ctx, grad_output): return -ctx.lambda_ * grad_output, None class DANN(nn.Module): def __init__(self, feature_dim=256, num_classes=10, lambda_=1.0): super(DANN, self).__init__() self.lambda_ = lambda_ # 特征提取器 self.feature_extractor = nn.Sequential( nn.Linear(784, 512), nn.ReLU(), nn.Dropout(0.5), nn.Linear(512, feature_dim), nn.ReLU() ) # 标签预测器 self.label_predictor = nn.Sequential( nn.Linear(feature_dim, 128), nn.ReLU(), nn.Linear(128, num_classes) ) # 域判别器 self.domain_discriminator = nn.Sequential( nn.Linear(feature_dim, 128), nn.ReLU(), nn.Linear(128, 2) ) def forward(self, x, lambda_=None): if lambda_ is None: lambda_ = self.lambda_ features = self.feature_extractor(x) label_pred = self.label_predictor(features) # 梯度反转后送入域判别器 reversed_features = GradientReversal.apply(features, lambda_) domain_pred = self.domain_discriminator(reversed_features) return label_pred, domain_predlambda_控制域对抗的强度。太小,域对齐效果弱;太大,特征提取器会被域判别器带偏,分类性能下降。我一般用动态调整策略:训练初期lambda_设小一点,让分类器先学好,然后逐渐增大到 1.0。具体公式可以用lambda_ = 2 / (1 + exp(-10 * p)) - 1,其中p是训练进度从 0 到 1。
训练时,源域样本有标签,目标域样本无标签。总损失是源域分类损失加上域判别损失。注意域判别器的标签:源域样本标 0,目标域样本标 1。
4.3 训练流程和验证方法
训练循环里,每个 batch 同时取源域和目标域样本。源域样本算分类损失和域损失,目标域样本只算域损失。优化器更新所有参数,但梯度反转层会自动把域损失的梯度取反后再传给特征提取器。
验证的时候,如果目标域有少量标注,直接算准确率。如果没有,可以用目标域上的熵或者一致性指标来选模型。我一般会保留目标域上预测置信度最高的那部分样本,看它们的预测一致性。如果模型在目标域上预测很分散,说明域适应没做好。
有个坑要注意:BatchNorm 在域适应里会捣乱。源域和目标域的统计量不同,如果共享 BatchNorm 层,目标域的统计量会被源域带偏。常见做法是把 BatchNorm 换成 InstanceNorm,或者给两个域分别维护 BatchNorm 统计量。我在一个跨摄像头行人重识别项目里就因为这个细节,mAP 掉了 8 个点,换成 InstanceNorm 才恢复。
5. 避坑指南:迁移学习里那些让你白干三个月的坑
5.1 负迁移:源域选错,越训越差
现象:加了迁移学习模块后,目标域准确率反而比不用迁移还低。
原因:源域和目标域差异太大,或者源域数据本身噪声大。强行对齐分布会把源域的噪声也搬过来。
解决:先做可迁移性评估。用源域模型在目标域上跑一个零样本基线,如果准确率低于随机猜,说明两个域基本没关系。这时候要么换源域,要么加一个中间域做桥接。我一般会算源域和目标域的 MMD 距离,如果距离比源域内部类间距离还大,直接放弃迁移。
5.2 伪标签确认偏差:JDA 迭代越跑越偏
现象:JDA 迭代几次后,目标域准确率不升反降。
原因:初始伪标签错误率高,条件分布对齐时把错误标签也对齐了,导致错误累积。
解决:只对高置信度的目标域样本算条件分布 MMD。置信度阈值一般设 0.8 到 0.9。另外,每轮迭代后重新评估伪标签质量,如果准确率下降就回退到上一轮。我一般会限制 JDA 最多迭代 5 次,超过 5 次基本没有额外收益。
5.3 特征维度选太大:域不变特征没学到,噪声全保留了
现象:TCA 降维后,源域分类器在目标域上表现不稳定,方差很大。
原因:降维维度设太大,保留了太多域相关的噪声维度,MMD 最小化没有真正对齐分布。
解决:降维维度不要超过源域样本数的十分之一。如果源域只有几百个样本,维度设 30 到 50 就够了。另外,可以在降维后加一个 L2 正则化的分类器,进一步抑制噪声。
5.4 忽略类别不平衡:源域和目标域的类别比例不同
现象:迁移后模型在目标域的少数类上表现极差。
原因:源域和目标域的类别分布不同,边缘分布对齐了,但条件分布没对齐。JDA 虽然考虑了条件分布,但如果伪标签在少数类上错误率高,对齐效果也差。
解决:在算 MMD 时给每个类别加权,权重和类别频率成反比。或者在采样时做分层采样,让每个 batch 里各类别比例均衡。我在一个缺陷检测项目里,目标域缺陷样本只占 2%,源域占 20%,不做类别加权的话,迁移后缺陷召回率只有 30% 多,加权后能到 70%。
5.5 用目标域测试集调参:自欺欺人的最高境界
现象:实验报告里目标域准确率很高,上线后一塌糊涂。
原因:调参时用了目标域测试集,模型过拟合了测试集。
解决:目标域数据分成验证集和测试集,调参只用验证集。如果目标域数据太少,用交叉验证。另外,源域验证集也可以用来选模型,虽然不完全一致,但至少不会作弊。我自己的习惯是,目标域测试集只在最后跑一次,跑完就封存,不再用来做任何决策。
6. 从理论到落地:怎么判断一个迁移学习方案值不值得做
判断一个迁移学习方案值不值得投入,我一般看三个信号。第一,源域和目标域的特征空间是否一致。如果一个是文本一个是图像,那就不叫迁移学习,叫多模态,复杂度完全不是一个量级。第二,目标域能不能搞到哪怕几十条标注。有标注就能做半监督域适应,比纯无监督方法稳得多。第三,业务对误差的容忍度。如果目标域准确率要求 99%,而源域模型在目标域上零样本只有 60%,那迁移学习大概率也到不了 99%,趁早换思路。
验证方法上,我习惯先跑一个最朴素的基线:源域模型直接在目标域上测试。这个数字是底线,任何迁移方法如果连这个底线都超不过,直接扔掉。然后跑 TCA 或者 JDA,看提升幅度。如果提升不到 5 个点,说明域差异不是主要矛盾,可能数据标注质量或者特征工程才是瓶颈。最后跑深度域适应,看能不能再往上推。每步都记录目标域验证集指标,不要只看训练损失。
有个技巧我经常用:把目标域数据按时间或者来源分成多份,做增量式域适应。比如目标域有三个批次的数据,先用第一批做适应,然后在第二批上测试,再用前两批做适应,在第三批上测试。这样能看出方法的泛化能力,也能模拟真实场景里数据逐步到位的节奏。如果方法只在特定批次上好使,换个批次就崩,那说明它学到的不是域不变特征,而是过拟合了特定批次的噪声。
最后说一个我自己的教训。早期做迁移学习,我总想把所有能加的模块都加上,TCA 加 JDA 加对抗训练,结果调参调到崩溃,效果还不如只做微调。后来才明白,迁移学习的核心是找到域之间真正不变的那个东西,而不是堆模块。源域和目标域共享的是什么?是类别语义,是特征之间的相对关系,不是绝对数值。抓住这个,方案就简单了,也稳了。希望帮到你。
本文还有配套的精品资源,点击获取