
简介这份联合分布适配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], dtypenp.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-1assert检查类别集合。注意yt在这里只是用来对齐编码和最后评估训练时不能传给 JDA。很多翻车现场就是加载阶段顺手把yt传进了伪标签更新结果目标域准确率虚高换一份数据就原形毕露。归一化也要在这里定好。JDA 常对拼接后的数据按列做 z-score因为目标域无标签使用目标域特征统计不算标签泄漏但如果你要严格模拟线上单样本推断最好只用源域统计量去变换目标域。两种做法没有绝对对错关键是评估协议要一致。X np.vstack([Xs, Xt]) mu X.mean(axis0, keepdimsTrue) sd X.std(axis0, keepdimsTrue) 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, kernellinear, gamma1.0): if kernel linear: return X X.T if kernel rbf: sq np.sum(X * X, axis1) 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, dim30, kernellinear, gamma1.0, lam1.0, T10, k5, verboseFalse): Xs np.asarray(Xs, dtypenp.float64) Xt np.asarray(Xt, dtypenp.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_neighborsk).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_neighborsk).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(fiter{it 1}, pseudo_changed{changed:.4f}) if changed 1e-3 and it 2: break Z K A return A, Z, yt_pseudo, historydim是目标子空间维度第一次跑可以设 30kernel先用linear确认流程后再试rbfgamma只在 RBF 核下生效lam是正则项起点 1.0T是最大迭代轮数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, dim30, kernellinear, gamma1.0, lam1.0, T10, k5, verboseTrue ) clf KNeighborsClassifier(n_neighbors5).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_division0))这里的accuracy_score和classification_report只在最后评估时用yt训练过程中没有碰目标域真实标签。history是每轮伪标签变化率正常情况应该逐轮下降如果它在 0.2 到 0.5 之间来回跳说明伪标签在震荡先别急着加大 T去看第 5 章的排查项。第一次跑建议先抽 100 个源域样本和 100 个目标域样本做冒烟测试dim10、T3确认没有维度错误和内存问题再上全量数据。4. 参数不是玄学dim、k、lambda、gamma 的取值边界JDA 的参数不多但每个都有明确的边界。调参之前先记住一个原则不要用目标域标签选参。目标域标签只在最后评估用一次选参只能用源域交叉验证、伪标签稳定性或者领域知识。下面四个参数按影响从大到小排。4.1 子空间维度 dim 和 PCA 预降维dim是 JDA 投影后的维度常用起点是 30范围大概 20 到 50。它不能超过 (n-1)也不能超过原始特征维度核化版本里 (A) 是 (n \times dim)dim太大时广义特征分解会退化投影矩阵接近噪声。dim太小则欠拟合类别条件信息被压掉目标域准确率上不去。我的习惯是先固定dim30跑一遍再扫 20、30、50 三个点看目标域准确率和伪标签变化率是否同步改善如果只有准确率动、变化率没动大概率是评估波动不要当成真实提升。有些代码包会先做一次 PCA 预降维到 50 到 100 维再跑 JDA。这个做法在原始特征维度很高时有用能降低核矩阵计算量但 PCA 只保方差不保分布距离预降维太狠会把 JDA 需要的判别信息提前丢掉。我一般把 PCA 当可选步骤原始维度超过 500 时才考虑预降维目标不低于 50并且记录预降维后的源域验证准确率确认没有明显掉点。4.2 近邻数 k 与伪标签迭代轮数 Tk控制伪标签的平滑程度。k太小伪标签容易被局部噪声带偏k太大少数类会被多数类吞掉类别不平衡时尤其明显。常用范围是 5 到 10起点 5。目标域类别分布和源域接近时k5通常够用如果目标域噪声大可以试 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 与核带宽 gammalam是正则项常用范围 0.1 到 10起点 1.0。它控制投影矩阵的范数太小会让广义特征分解数值不稳太大则抑制适配结果接近不迁移。判断方法是看源域验证准确率和目标域准确率的差距lam太大时目标域表现接近源域直接迁移lam太小时训练过程可能报奇异矩阵或者伪标签剧烈震荡。gamma只在 RBF 核下生效。特征标准化后可以用中位数启发式给一个起点计算样本间距离的中位数取gamma 1.0 / (2 * median_dist**2)。gamma太大会让核矩阵接近单位阵模型退化成最近邻太小会让核矩阵接近全 1所有样本看起来都相似适配失去意义。线性核先跑基线确认 JDA 比不适配有提升再试 RBF如果线性核已经够用不必上核。参数含义常用起点调整方向主要风险dim子空间维度3020 到 50 间扫太大退化太小欠拟合k伪标签 kNN 近邻数55 到 10太小噪声大太大类别偏lam正则项1.00.1 到 10太小数值不稳太大欠适配T最大迭代轮数105 到 10配合早停太大伪标签锁死gammaRBF 核带宽中位数启发按距离尺度调太大近单位阵太小全相似选参时可以用源域交叉验证做粗扫。下面这段代码把源域切成分层折验证折当无标签目标域跑 JDA只用了源域标签没有碰目标域真实标签。它不能完全替代目标域评估但能过滤掉明显不合理的参数组合。from sklearn.model_selection import StratifiedKFold from sklearn.metrics import accuracy_score def source_cv_score(Xs, ys, dim30, lam1.0, k5, kernellinear, gamma1.0): skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores [] for tr, va in skf.split(Xs, ys): A, Z, _, _ jda_fit( Xs[tr], ys[tr], Xs[va], dimdim, kernelkernel, gammagamma, lamlam, T10, kk ) clf KNeighborsClassifier(n_neighborsk).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, dimdim, lamlam, k5) print(fdim{dim}, lam{lam}, source_cv{s:.4f})source_cv_score的tr是源域训练折va是源域验证折JDA 把验证折当无标签目标域所以验证折标签只用于最后算分。StratifiedKFold保证每折类别比例接近random_state固定后结果可复现。这个分数只用来排除明显差的组合最终选参还要结合目标域伪标签变化率和混淆矩阵不能拿目标域标签反推。5. 避坑与排查五条从翻车现场总结的检查清单JDA 代码跑通不难难的是结果可信。下面五条是我在跨域任务里反复遇到的坑每条本文还有配套的精品资源点击获取