简介:这份联合分布适配(JDA)代码包面向机器学习域适应方向的学习者与研究人员,围绕源域与目标域联合分布差异最小化,提供从算法理解到实验复现的参考实现。压缩包共28个文件、约53.52MB,以17个mat数据文件和5个m脚本为主,辅以readme与说明文本;数据文件覆盖多组跨域实验数据,脚本则对应JDA主体、核函数及多场景运行入口,便于对照算法步骤梳理特征降维、协方差对齐、类内紧凑性约束与优化求解流程。已有1946人学习,适合想复现经典JDA、搭建迁移学习实验基线或排查参数设置问题的读者,从中可了解目录组织、数据加载与跨域评估的完整脉络。
1. 联合分布适配JDA代码:别急着跑 main.py,先看这三个矩阵
联合分布适配JDA代码这类资源,最容易出现的翻车不是报错,而是脚本跑完输出一个看起来还行的准确率,你也不知道它到底适配了什么。JDA 要同时拉近源域和目标域的边缘分布与条件分布,代码里对应三个矩阵:边缘 MMD 矩阵、按伪标签构造的条件 MMD 矩阵、中心矩阵 H。它适合手上已有带标签源域、目标域无标签、且两家数据采集条件明显不同的场景,比如跨设备图像分类、跨批次传感器识别。下面按「目标函数怎么落矩阵 → 代码怎么跑 → 参数怎么设 → 哪里会翻车」拆开讲,重点放在能复现的步骤和边界。
2. JDA 的目标函数:边缘 MMD、条件 MMD 与广义特征分解
JDA 的代码如果只读入口脚本,很容易被当成「降维 + kNN」的黑匣子。真正决定结果的,是三个矩阵怎么构造、维度怎么对齐、伪标签怎么迭代。先把目标函数落到矩阵上,后面调参和排错才有依据。
2.1 JDA 到底在最小化哪两个分布距离
设源域有 (n_s) 个带标签样本 (X_s),目标域有 (n_t) 个无标签样本 (X_t),特征维度为 (m)。JDA 要找一组投影方向 (A),把两家数据映射到同一个低维子空间,在这个子空间里让源域和目标域的边缘分布尽量接近,同时让同一类别的条件分布也尽量接近。边缘分布用最大均值差异 MMD 度量,落到矩阵上就是 (M_0):源域块填 (1/(n_s^2)),目标域块填 (1/(n_t^2)),交叉块填 (-1/(n_s n_t))。这个块结构不是随便写的,它等价于两个均值向量之差的平方。
条件分布则按类别拆开。对每个类别 (c),取源域中真实标签为 (c) 的样本、目标域中伪标签为 (c) 的样本,构造一个 (M_c),块内归一化方式和 (M_0) 一样。把所有类别的 (M_c) 加起来,就得到条件 MMD 矩阵。JDA 的目标可以写成最小化 (\mathrm{Tr}(A^T X (M_0 + \sum_c M_c) X^T A) + \lambda |A|_F^2),约束是 (A^T X H X^T A = I)。这里的 (H = I - \frac{1}{n}\mathbf{1}\mathbf{1}^T) 是中心矩阵,约束的作用是防止投影塌缩成零,同时让变换后的数据保持散度。没有这个约束,最小化 MMD 会直接把所有样本压到一个点,损失看起来很小,分类器却什么都学不到。
和 TCA 比,JDA 多了条件分布项,所以它需要伪标签;和 CORAL 比,JDA 不是只对齐二阶统计量,而是显式建模类别条件。选型时我一般这样判断:如果目标域完全无标签、类别空间和源域一致、而且类别条件偏移明显,JDA 是合适起点;如果只是均值方差漂移、特征维度不高,CORAL 更轻;如果目标域有少量标签,优先考虑监督或半监督域适应,不要硬套 JDA。
| 方法 | 对齐对象 | 是否需要伪标签 | 适用场景 |
|---|---|---|---|
| TCA | 边缘分布 | 否 | 目标域无标签,类别条件差异不突出 |
| JDA | 边缘分布 + 条件分布 | 是 | 类别条件偏移明显,类别空间一致 |
| CORAL | 二阶统计量 | 否 | 均值方差漂移为主,特征维度不高 |
2.2 从 TCA 到 JDA:为什么条件分布要迭代伪标签
目标域没有标签,条件分布 (P(\phi(x)|y)) 没法直接算。JDA 的做法是用源域训练一个分类器,先给目标域打伪标签,再用伪标签构造 (M_c),解一次广义特征值问题得到新的投影,然后在新的子空间里重新训练分类器、重新打伪标签。这个过程一般迭代 5 到 10 轮。第一轮伪标签通常来自原始特征空间或 PCA 后的子空间,质量不会太高,所以第一轮的 (M_c) 只能算粗对齐;后面几轮随着子空间逐渐对齐,伪标签会稳定一些。
这里有一个很容易被忽略的点:伪标签噪声会沿着迭代传播。如果第一轮把大量目标样本分错,后面的 (M_c) 会把错误类别往一起拉,越拉越错。所以 T 不是越大越好,代码里必须监控每轮伪标签的变化率。变化率突然升高,说明子空间在震荡,常见原因是 k 太小、(\lambda) 太小或者类别不平衡。另一个常见错误是每轮拿目标域真实标签去更新伪标签,这属于评估泄漏,跑出来的准确率没有参考价值。目标域真实标签只在最后评估时用一次,训练过程中不能碰。
为什么原实现常用 kNN 做伪标签?因为它无参数、训练快,而且对子空间旋转不敏感。换成逻辑回归也能跑,但会引入学习率、正则强度等额外超参,调试面变大。我的习惯是先用 kNN 把流程跑通,确认伪标签变化率正常,再考虑换分类器。
2.3 代码骨架与矩阵维度对齐
下面两个函数负责构造 (M_0) 和 (M_c)。注意目标域索引要加上 (n_s) 的偏移,因为拼接后的矩阵是 ([X_s; X_t]) 的块结构。np.ix_用来做块赋值,避免循环写错行列。
import numpy as np def build_m0(n_s, n_t): n = n_s + n_t M = np.zeros((n, n)) M[:n_s, :n_s] = 1.0 / (n_s * n_s) M[n_s:, n_s:] = 1.0 / (n_t * n_t) M[:n_s, n_s:] = -1.0 / (n_s * n_t) M[n_s:, :n_s] = -1.0 / (n_s * n_t) return M def build_mc(ys, yt_pseudo, n_s, n_t, n_classes): n = n_s + n_t Mc = np.zeros((n, n)) for c in range(n_classes): idx_s = np.where(ys == c)[0] idx_t = np.where(yt_pseudo == c)[0] + n_s ns_c, nt_c = len(idx_s), len(idx_t) if ns_c == 0 or nt_c == 0: continue Mc[np.ix_(idx_s, idx_s)] = 1.0 / (ns_c * ns_c) Mc[np.ix_(idx_t, idx_t)] = 1.0 / (nt_c * nt_c) Mc[np.ix_(idx_s, idx_t)] = -1.0 / (ns_c * nt_c) Mc[np.ix_(idx_t, idx_s)] = -1.0 / (ns_c * nt_c) return Mcbuild_m0的参数只有源域和目标域样本数,返回 (n \times n) 矩阵。build_mc多了源域标签、目标域伪标签和类别数;如果某个类别在目标域一个样本都没被分到,continue会跳过该类,这时条件对齐只覆盖有样本的类。类别不平衡时,这个跳过会让少数类被忽略,后面第 5 章会讲怎么用置信度过滤和按类采样补。
核化版本的维度要换过来:线性 JDA 里 (A) 是 (m \times d),核 JDA 里 (A) 是 (n \times d),核矩阵 (K) 是 (n \times n),变换后的特征是 (Z = K A)。目标函数变成 (\mathrm{Tr}(A^T K (M_0 + \sum_c M_c) K A) + \lambda \mathrm{Tr}(A^T A)),约束是 (A^T K H K A = I)。很多代码包默认走核版本,因为图像特征维度高、样本数相对少,核矩阵反而好用;但样本数上万时,(n \times n) 核矩阵会直接把内存打满,这一点在第 5 章会展开。
3. 把代码跑起来:环境、数据组织与一次完整训练
拿到一份 JDA 代码,我一般不会直接改算法,而是先做一次最小闭环:数据能加载、标签能对齐、训练能出结果、结果能落盘。这个闭环跑通之后,再动参数和结构。下面按这个顺序走。
3.1 环境与目录约定
Python 3.8 以上就够,核心依赖是 numpy、scipy、scikit-learn。虚拟环境建议单独建,避免和本机其他包打架。安装命令不需要版本号,先让依赖能解析即可。
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install numpy scipy scikit-learnvenv的作用是隔离依赖,source在 Linux/macOS 下激活,Windows 走 Scripts 目录。JDA 代码本身不依赖深度学习框架,所以环境很轻;如果你拿到的是带 MATLAB 数据加载的版本,scipy 就够了。
目录上我习惯把数据和代码分开:data/source.mat放源域,data/target.mat放目标域,jda.py放矩阵构造和训练函数,train.py负责加载和调用,eval.py只做评估,outputs/存投影矩阵、嵌入特征、预测标签和每轮伪标签变化率。数据文件里用两个变量:X是二维浮点矩阵,行是样本、列是特征;y是一维整数标签。源域和目标域的特征维度必须一致,类别集合也必须一致,否则后面构造 (M_c) 会错位。
提示:先确认数据里没有 NaN 和 Inf,尤其是从传感器或旧版表格转出来的特征。JDA 的核矩阵对异常值很敏感,一个 Inf 就能让整个特征分解失败。
3.2 数据加载与标签对齐
加载函数不要写得太聪明,按变量名直取,然后统一标签编码。源域标签可能是 1 到 C,目标域真实标签可能是 0 到 C-1,直接混用会让伪标签和真实标签错位。下面这段代码把两家标签都过一遍LabelEncoder,并检查类别集合是否一致。
import numpy as np from scipy.io import loadmat from sklearn.preprocessing import LabelEncoder def load_domain(path): mat = loadmat(path) X = np.asarray(mat['X'], dtype=np.float64) y = np.asarray(mat['y']).ravel() if np.isnan(X).any() or np.isinf(X).any(): raise ValueError(f'{path} 里有 NaN 或 Inf') return X, y Xs, ys = load_domain('data/source.mat') Xt, yt = load_domain('data/target.mat') le = LabelEncoder().fit(np.concatenate([ys, yt])) ys = le.transform(ys) yt = le.transform(yt) assert np.array_equal(np.unique(ys), np.unique(yt)), '源域和目标域类别集合不一致' print('源域', Xs.shape, '目标域', Xt.shape, '类别', le.classes_)load_domain只做加载和数值检查,LabelEncoder把标签压到 0 到 C-1,assert检查类别集合。注意yt在这里只是用来对齐编码和最后评估,训练时不能传给 JDA。很多翻车现场就是加载阶段顺手把yt传进了伪标签更新,结果目标域准确率虚高,换一份数据就原形毕露。
归一化也要在这里定好。JDA 常对拼接后的数据按列做 z-score,因为目标域无标签,使用目标域特征统计不算标签泄漏;但如果你要严格模拟线上单样本推断,最好只用源域统计量去变换目标域。两种做法没有绝对对错,关键是评估协议要一致。
X = np.vstack([Xs, Xt]) mu = X.mean(axis=0, keepdims=True) sd = X.std(axis=0, keepdims=True) + 1e-12 X = (X - mu) / sd Xs, Xt = X[:len(Xs)], X[len(Xs):]这里先拼接再算均值和标准差,sd加一个极小值防止除零。变换后重新切回源域和目标域,后面训练函数只接收Xs、Xt。如果你选择只用源域统计量,就把mu、sd改成Xs.mean、Xs.std,目标域用同一组参数变换。
3.3 训练、预测与结果落盘
训练函数的核心是「构造矩阵 → 解广义特征值 → 更新伪标签」的循环。下面这段代码把build_m0、build_mc接进来,返回投影矩阵、嵌入特征、最终伪标签和每轮变化率。第一次跑不要追求高准确率,先看循环有没有正常结束、变化率有没有收敛。
import numpy as np from scipy.linalg import eigh from sklearn.neighbors import KNeighborsClassifier def _kernel(X, kernel='linear', gamma=1.0): if kernel == 'linear': return X @ X.T if kernel == 'rbf': sq = np.sum(X * X, axis=1) D = sq[:, None] + sq[None, :] - 2.0 * X @ X.T D = np.maximum(D, 0.0) return np.exp(-gamma * D) raise ValueError('kernel 只支持 linear 或 rbf') def jda_fit(Xs, ys, Xt, dim=30, kernel='linear', gamma=1.0, lam=1.0, T=10, k=5, verbose=False): Xs = np.asarray(Xs, dtype=np.float64) Xt = np.asarray(Xt, dtype=np.float64) ys = np.asarray(ys).ravel() n_s, n_t = len(Xs), len(Xt) n = n_s + n_t K = _kernel(np.vstack([Xs, Xt]), kernel, gamma) H = np.eye(n) - np.ones((n, n)) / n M0 = build_m0(n_s, n_t) yt_pseudo = KNeighborsClassifier(n_neighbors=k).fit(Xs, ys).predict(Xt) classes = np.unique(ys) # 初始投影用中心化核的主成分,避免第一轮退化解 Kc = H @ K @ H vals, vecs = eigh(Kc) order = np.argsort(vals)[::-1] A = vecs[:, order[:dim]] history = [] for it in range(T): Mc = build_mc(ys, yt_pseudo, n_s, n_t, len(classes)) left = K @ (M0 + Mc) @ K + lam * np.eye(n) right = K @ H @ K + 1e-10 * np.eye(n) vals, vecs = eigh(left, right) idx = np.argsort(vals)[:dim] A = vecs[:, idx] Z = K @ A clf = KNeighborsClassifier(n_neighbors=k).fit(Z[:n_s], ys) new_pseudo = clf.predict(Z[n_s:]) changed = float(np.mean(new_pseudo != yt_pseudo)) history.append(changed) yt_pseudo = new_pseudo if verbose: print(f'iter={it + 1}, pseudo_changed={changed:.4f}') if changed < 1e-3 and it >= 2: break Z = K @ A return A, Z, yt_pseudo, historydim是目标子空间维度,第一次跑可以设 30;kernel先用linear,确认流程后再试rbf;gamma只在 RBF 核下生效;lam是正则项,起点 1.0;T是最大迭代轮数;k是 kNN 的近邻数;verbose控制每轮输出。left和right分别对应广义特征值问题的左右矩阵,eigh返回升序特征值,取前dim个最小特征值对应的向量。right加1e-10的对角项是为了数值稳定,避免矩阵奇异。
训练脚本里把结果存下来,后面调参和复现都靠这些文件。不要只打印准确率,投影矩阵和每轮变化率才是排查问题的依据。
from sklearn.metrics import accuracy_score, classification_report A, Z, yt_pseudo, history = jda_fit( Xs, ys, Xt, dim=30, kernel='linear', gamma=1.0, lam=1.0, T=10, k=5, verbose=True ) clf = KNeighborsClassifier(n_neighbors=5).fit(Z[:len(Xs)], ys) pred = clf.predict(Z[len(Xs):]) np.save('outputs/A.npy', A) np.save('outputs/Z.npy', Z) np.save('outputs/pred.npy', pred) np.save('outputs/pseudo.npy', yt_pseudo) np.save('outputs/history.npy', np.asarray(history)) print('目标域准确率', accuracy_score(yt, pred)) print(classification_report(yt, pred, zero_division=0))这里的accuracy_score和classification_report只在最后评估时用yt,训练过程中没有碰目标域真实标签。history是每轮伪标签变化率,正常情况应该逐轮下降;如果它在 0.2 到 0.5 之间来回跳,说明伪标签在震荡,先别急着加大 T,去看第 5 章的排查项。第一次跑建议先抽 100 个源域样本和 100 个目标域样本做冒烟测试,dim=10、T=3,确认没有维度错误和内存问题,再上全量数据。
4. 参数不是玄学:dim、k、lambda、gamma 的取值边界
JDA 的参数不多,但每个都有明确的边界。调参之前先记住一个原则:不要用目标域标签选参。目标域标签只在最后评估用一次,选参只能用源域交叉验证、伪标签稳定性或者领域知识。下面四个参数按影响从大到小排。
4.1 子空间维度 dim 和 PCA 预降维
dim是 JDA 投影后的维度,常用起点是 30,范围大概 20 到 50。它不能超过 (n-1),也不能超过原始特征维度;核化版本里 (A) 是 (n \times dim),dim太大时广义特征分解会退化,投影矩阵接近噪声。dim太小则欠拟合,类别条件信息被压掉,目标域准确率上不去。我的习惯是先固定dim=30跑一遍,再扫 20、30、50 三个点,看目标域准确率和伪标签变化率是否同步改善;如果只有准确率动、变化率没动,大概率是评估波动,不要当成真实提升。
有些代码包会先做一次 PCA 预降维到 50 到 100 维,再跑 JDA。这个做法在原始特征维度很高时有用,能降低核矩阵计算量;但 PCA 只保方差,不保分布距离,预降维太狠会把 JDA 需要的判别信息提前丢掉。我一般把 PCA 当可选步骤:原始维度超过 500 时才考虑,预降维目标不低于 50,并且记录预降维后的源域验证准确率,确认没有明显掉点。
4.2 近邻数 k 与伪标签迭代轮数 T
k控制伪标签的平滑程度。k太小,伪标签容易被局部噪声带偏;k太大,少数类会被多数类吞掉,类别不平衡时尤其明显。常用范围是 5 到 10,起点 5。目标域类别分布和源域接近时,k=5通常够用;如果目标域噪声大,可以试 7 或 9,但不要超过 15,否则伪标签会过度平滑。
T是最大迭代轮数,常用 5 到 10。JDA 的迭代不是越多越好,伪标签一旦锁死,后面几轮只是重复计算。代码里用变化率早停比固定 T 更稳:连续两轮变化率低于 1% 就可以停。下面这段监控逻辑可以直接加在循环里,变化率突然升高时打印警告,方便定位。
if changed > 0.3: print(f'警告:第 {it + 1} 轮伪标签变化率 {changed:.2f},检查 k 和 lam') if len(history) >= 3 and history[-1] > history[-2] > history[-3]: print('警告:变化率连续上升,可能进入震荡')第一段判断单轮变化率是否超过 0.3,超过说明伪标签大面积翻转;第二段判断连续三轮上升,说明迭代没有收敛。这两个信号比最终准确率更早暴露问题。看到警告时优先增大k或增大lam,不要直接加 T。
4.3 正则项 lambda 与核带宽 gamma
lam是正则项,常用范围 0.1 到 10,起点 1.0。它控制投影矩阵的范数,太小会让广义特征分解数值不稳,太大则抑制适配,结果接近不迁移。判断方法是看源域验证准确率和目标域准确率的差距:lam太大时,目标域表现接近源域直接迁移;lam太小时,训练过程可能报奇异矩阵或者伪标签剧烈震荡。
gamma只在 RBF 核下生效。特征标准化后,可以用中位数启发式给一个起点:计算样本间距离的中位数,取gamma = 1.0 / (2 * median_dist**2)。gamma太大会让核矩阵接近单位阵,模型退化成最近邻;太小会让核矩阵接近全 1,所有样本看起来都相似,适配失去意义。线性核先跑基线,确认 JDA 比不适配有提升,再试 RBF;如果线性核已经够用,不必上核。
| 参数 | 含义 | 常用起点 | 调整方向 | 主要风险 |
|---|---|---|---|---|
| dim | 子空间维度 | 30 | 20 到 50 间扫 | 太大退化,太小欠拟合 |
| k | 伪标签 kNN 近邻数 | 5 | 5 到 10 | 太小噪声大,太大类别偏 |
| lam | 正则项 | 1.0 | 0.1 到 10 | 太小数值不稳,太大欠适配 |
| T | 最大迭代轮数 | 10 | 5 到 10,配合早停 | 太大伪标签锁死 |
| gamma | RBF 核带宽 | 中位数启发 | 按距离尺度调 | 太大近单位阵,太小全相似 |
选参时可以用源域交叉验证做粗扫。下面这段代码把源域切成分层折,验证折当无标签目标域跑 JDA,只用了源域标签,没有碰目标域真实标签。它不能完全替代目标域评估,但能过滤掉明显不合理的参数组合。
from sklearn.model_selection import StratifiedKFold from sklearn.metrics import accuracy_score def source_cv_score(Xs, ys, dim=30, lam=1.0, k=5, kernel='linear', gamma=1.0): skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = [] for tr, va in skf.split(Xs, ys): A, Z, _, _ = jda_fit( Xs[tr], ys[tr], Xs[va], dim=dim, kernel=kernel, gamma=gamma, lam=lam, T=10, k=k ) clf = KNeighborsClassifier(n_neighbors=k).fit(Z[:len(tr)], ys[tr]) pred = clf.predict(Z[len(tr):]) scores.append(accuracy_score(ys[va], pred)) return float(np.mean(scores)) for dim in [20, 30, 50]: for lam in [0.1, 1.0, 10.0]: s = source_cv_score(Xs, ys, dim=dim, lam=lam, k=5) print(f'dim={dim}, lam={lam}, source_cv={s:.4f}')source_cv_score的tr是源域训练折,va是源域验证折;JDA 把验证折当无标签目标域,所以验证折标签只用于最后算分。StratifiedKFold保证每折类别比例接近,random_state固定后结果可复现。这个分数只用来排除明显差的组合,最终选参还要结合目标域伪标签变化率和混淆矩阵,不能拿目标域标签反推。
5. 避坑与排查:五条从翻车现场总结的检查清单
JDA 代码跑通不难,难的是结果可信。下面五条是我在跨域任务里反复遇到的坑,每条
本文还有配套的精品资源,点击获取