不用想得太玄。今天这篇,是把自己调 UOT 模型时踩坑、翻论文、手推 KKT 的过程整理出来。事情起因是我要在一个分布预测模型里引入最优传输(Optimal Transport,OT)做对齐,但真实数据的质量分布并不守恒,强行用 OT 反而把噪声也当真有质量搬了过去。于是研究了一圈,从经典 OT 逐步切到不平衡最优传输(Unbalanced OT,UOT),又在推 KKT(Karush-Kuhn-Tucker)条件时发现,熵正则化项、边际约束和乘子更新之间其实是一套非常优美、也非常容易调崩的组合拳。加上“ETM 调试”这个词最近在社区里被反复提起,干脆把这块一起做了个总结,适合已经在用 Sinkhorn、想搞清楚 UOT 和熵正则化之间等价关系的读者,也适合刚开始接触最优传输、想直接上手跑实验的新手。
我尽量不讲课本话,所有公式都给足推导背景,所有参数都给到可以直接抄作业的经验值,包括调试过程中最常见的“不收敛”“结果糊成一团”“边际约束失效”这三个问题。
1. OT 到 UOT:从强制守恒到允许质量创生
1.1 经典 OT 在干什么
最优传输问题,通俗说就是把一堆货从若干个仓库搬到若干个站点,让总运输成本最低。数学形式一般写成:
[ \min_{P} \langle C, P \rangle \quad \text{s.t.} \quad P \in \mathbb{R}_{+}^{n \times m},\quad P \mathbf{1}_m = a,\quad P^T\mathbf{1}_n = b ]
其中 (a) 和 (b) 是两个分布向量,行和列分别等于 (a) 和 (b),加起来都等于 1。这个约束叫硬边际约束(Hard Marginal Constraints),含义是:所有质量必须被完整运出,也必须完整运入。当成本矩阵取某种度量距离时,这个最优值就是 Wasserstein 距离。
实际用起来,硬边际约束是个舒服又危险的东西。舒服在于,它的对偶形式非常干净,能被 Sinkhorn 这种迭代算法高效求解;危险在于,真实数据几乎不满足质量守恒。
举个例子。我处理过一个 OCR 场景:左边是字符真实位置的分布 (a),右边是识别结果的置信分布 (b),两者本应该对齐,但识别系统会有漏检、多检,导致 (b) 的 total mass 可能比 (a) 少了 10%。经典 OT 会强行把这 10% 的缺失质量也“搬运”过去,于是最优传输矩阵里就会出现大量不合理的微小数值,把噪声、异常点全糊进结果里。
1.2 UOT 的核心动机
UOT(Unbalanced Optimal Transport)就是为了打破这种强制守恒。它把硬边际约束做“软”化,允许计划矩阵 (P) 的行和列不等于给定分布,但偏离太多会受惩罚。
我采用的常见 UOT 形式是:
[ \min_{P \geq 0} \langle C,P \rangle + \rho_1 KL(P\mathbf{1}_m | a) + \rho_2 KL(P^T\mathbf{1}_n | b) ]
这里 (KL) 是散度惩罚项。(\rho_1、\rho_2) 越大,边际越接近真实分布;越小时,模型越“自由”,允许质量被创建或销毁。从几何角度看,UOT 是把 OT 的强等式约束变成了统计散度的软惩罚,这正是标题里“从 UOT 到 OT”的关键视觉:当把两个 (\rho) 都推到无穷大,UOT 逐渐回收成 OT。
实际实验里,我最常选 (\rho_1 = \rho_2 = \epsilon)(这里的 (\epsilon) 指熵正则化权重),让不平衡惩罚和熵惩罚保持同一量级,虽然不绝对严谨,但对大部分数据分布是一个相当稳定的起步点。
1.3 我为什么不用直接用 OT 的“折扣版本”
有人可能会问,既然要对齐不完整分布,把 OT 里的边际约束改成重新归一化不行吗?比如先让两个分布各自 normalize,再跑 OT。
严格来说,这不叫从 UOT 到 OT 的正统路径。因为它把“总质量差异”这个信息直接抹掉了。UOT 的一个优势恰恰在于:质量差异本身是信号。例如,在领域自适应任务中,源域和目标域的样本数量差异往往反映了分布偏移的强度;UOT 的惩罚系数可以把它当作一个可学习的量,而不是数据预处理阶段的损失。
2. ETM 到底是什么,为什么要引入熵正则化
2.1 熵正则化降低计算复杂度
经典 OT 是一个线性规划问题,直接求解在大规模数据上的复杂度是 (O(n^3)) 级别。加一个熵正则化后,问题变成了强凸优化,Sinkhorn 迭代能在几十步内收敛到高精度近似解,这也是今天所有大规模 OT 库的标准做法。
ETM 这个词,我把它理解为Entropic Transport Model,即“熵正则化传输模型”。它同时覆盖两类模型:一类是经典 OT 加熵正则化(Sinkhorn 模型),另一类是不平衡 OT 加上熵惩罚的扩展版本。实际上,加了熵项之后,UOT 目标函数变成:
[ \min_{P \geq 0} \langle C,P \rangle + \epsilon \sum_{ij} P_{ij}(\log P_{ij} - 1) + \rho_1 KL(P\mathbf{1}_m | a) + \rho_2 KL(P^T\mathbf{1}_n | b) ]
其中 (\epsilon) 控制传输计划的平滑程度。(\epsilon) 越大,传输矩阵越“软”,结果会比较糊;(\epsilon) 越小,结果越接近原始 OT,但数值上越容易不稳定,甚至出现除零。
2.2 ETM 与 KKT 之间天然绑定
一旦加入熵正则化,求解几乎绕不开 KKT 条件。原因在于:目标函数在 (P) 上变成严格凸,约束条件则是非线性的散度惩罚,此时可以直接对 (P) 求梯度并令其等于 0,再用拉格朗日乘子把边际约束显式表达出来。
我在调试中喜欢把 KKT 条件当作“解的诊断工具”:不要只等迭代跑完再检查结果,而是每过几次迭代就算一下 KKT 残差,看是否符合最优性条件。如果残差太大,要么是迭代次数不够,要么是某些乘子初始化推向了一个不稳定的不动点。
2.3 ETM 调试的第一个上手点
刚开始做“ETM 调试”时,我第一步永远是画“(\epsilon) 扫描曲线”。固定 (\rho),让 (\epsilon) 从 1e-4 扫到 1e-1,看目标函数值和边际误差的变化。如果损失曲线出现明显台阶,通常表示模型从一个“硬分配”模式跳到了“模糊分配”模式,这也直接对应 Sinkhorn 算法中半正定矩阵的条件数剧变。
这么说可能还有点空。实际操作后,下面这部分会给出完整推导和参数说明。
3. KKT 乘子正则化推导:从带约束优化到迭代缩放公式
3.1 写出拉格朗日函数和最优性条件
给 UOT + 熵正则化问题写拉格朗日函数。注意,熵正则项本身是严格凸的,因此最优解一定唯一,这让 KKT 条件成为了充分必要条件。
我把问题简写成:
[ \mathcal{L}(P,u,v)= \langle C,P\rangle + \epsilon \sum_{ij} P_{ij}(\log P_{ij}-1) + \rho_1 KL(P\mathbf{1}_m | a) + \rho_2 KL(P^T\mathbf{1}_n | b) + \langle u, P\mathbf{1}_m - r\rangle + \langle v, P^T\mathbf{1}_n - s\rangle ]
等等,这里有一点要小心。如果已经把边际约束写成软散度惩罚,那么理论上不再需要硬约束,也不需要再引入 (u,v) 对等式约束的乘子;但是,为了从 KKT 的角度理解“乘子正则化”,我们有意引入一个虚拟的边际残差约束 (r,s),再用乘子去控制它。
这正是我在推导时最容易踩坑的地方:不能把散度惩罚项和拉格朗日乘子项重复叠加,否则会让边际约束被双重强化,模型退化成更“硬”的 OT。正确的做法是:想得到一个软的 UOT,直接在目标里写散度惩罚,不写乘子;想让模型返回 OT,就把散度惩罚的权重推大,或重新加上等式约束并让乘子迭代。
为了讲清楚 KKT 乘子正则化,这里统一用带散度惩罚的形式。对 (P_{ij}) 求偏导:
[ \frac{\partial \mathcal{L}}{\partial P_{ij}} = C_{ij} + \epsilon \log P_{ij} + \rho_1 \log\frac{(P\mathbf{1}_m)_i}{a_i} + \rho_2 \log\frac{(P^T\mathbf{1}_n)_j}{b_j} = 0 ]
从这儿解出:
[ P_{ij} = \exp\left(-\frac{C_{ij}}{\epsilon}\right) \cdot \left(\frac{a_i}{(P\mathbf{1}_m)_i}\right)^{\rho_1/\epsilon} \cdot \left(\frac{b_j}{(P^T\mathbf{1}_n)_j}\right)^{\rho_2/\epsilon} ]
这个式子看着复杂,实则极其本质:传输矩阵由成本内核 (\exp(-C/\epsilon))、两组乘子式修正因子构成。把这修正因子分别记为 (u_i) 和 (v_j),它就变成经典的 Sinkhorn 缩放形式:
[ P_{ij} = u_i K_{ij} v_j, \quad K_{ij} = \exp(-C_{ij}/\epsilon) ]
3.2 不动点迭代与乘子更新
既然 (P\mathbf{1}_m) 和 (P^T\mathbf{1}_n) 都依赖于 (u、v),那么最优性条件就自动转化成了一个关于乘子的不动点方程。如果走经典 OT 路线,即约束必须严格相等,更新公式就是:
[ u_i \leftarrow \frac{a_i}{(K v)_i}, \quad v_j \leftarrow \frac{b_j}{(K^T u)_j} ]
这是标准 Sinkhorn 迭代。但在 UOT 里,由于目标自带散度惩罚,最优性条件导出的更新不是直接“除”,而是带幂次:
[ u_i \leftarrow \left(\frac{a_i}{(K v)_i}\right)^{\frac{\rho_1}{\rho_1+\epsilon}}, \quad v_j \leftarrow \left(\frac{b_j}{(K^T u)_j}\right)^{\frac{\rho_2}{\rho_2+\epsilon}} ]
当 (\rho \to \infty),指数趋近于 1,就还原成标准 Sinkhorn。当 (\rho) 有限,更新会变得平滑,每一轮只把边际拉向目标一部分,这正是“软边际”的计算体现。我在论文里第一次看到这个式子时,觉得它不过是指数加了个幂,但实际调试中会发现,这个幂是稳定性的关键旋钮。
3.3 乘子正则化为什么能统一 OT 与 UOT
所谓“乘子正则化”,我的理解是:不用真实分布直接给乘子赋值,而是通过迭代过程中的 KKT 条件去递推乘子。每次得到一组新乘子,就相当于在“软匹配”和“硬匹配”之间做了一次均衡。
把零阶项、一阶项拆开看:上式中 ((K v)_i) 是当前预测的行边际,(a_i / (K v)_i) 是“期望/实际”比例;指数 ((\rho/(\rho+\epsilon))) 决定修正步长。若 (\rho) 很小,步长很短,每轮只补一小部分缺口,算法耐噪,但收敛慢;若 (\rho) 很大,步长接近 1,算法近似 OT,收敛快,但也容易在数据噪声面前发生震荡。
我在一次文本主题对齐任务里试过固定 (\epsilon=0.1)、调 (\rho) 从 1 到 20。(\rho=1) 时边际误差一直停在 0.3 附近下不去;(\rho=20) 时 5 轮迭代边际误差就到了 0.02,但目标函数在后期出现跳变。后来加了一个简单的乘子衰减策略,让 (\rho) 在前 20 轮线性增大,效果比固定权重稳定一大截。
4. 核心实操:写一个带 KL 惩罚的 UOT Sinkhorn 求解器
4.1 从 0 到 1 的完整代码
下面给一段可直接跑的 Python 实现,我用 PyTorch 写,因为后面要接神经网络。
import torch def sinkhorn_uot(a, b, C, eps=0.1, rho1=1.0, rho2=1.0, num_iters=200, tol=1e-6): """ a: (n,) 源分布 b: (m,) 目标分布 C: (n, m) 成本矩阵 eps: 熵正则化系数 rho1, rho2: UOT 边际 KL 惩罚系数 """ K = torch.exp(-C / eps) # 常用初始化:乘子为全 1 u = torch.ones_like(a) v = torch.ones_like(b) alphas = rho1 / (rho1 + eps) betas = rho2 / (rho2 + eps) for _ in range(num_iters): # 当前传输矩阵的行边际 row_marg = (u.view(-1, 1) * K * v.view(1, -1)).sum(dim=1) new_u = (a / row_marg) ** alphas new_u = torch.nan_to_num(new_u, nan=1.0, posinf=1.0, neginf=1.0) # 重新算列边际 P = new_u.view(-1, 1) * K * v.view(1, -1) col_marg = P.sum(dim=0) new_v = (b / col_marg) ** betas new_v = torch.nan_to_num(new_v, nan=1.0, posinf=1.0, neginf=1.0) # 判断收敛,我看的是两组乘子变化的 L2 范数 if torch.norm(new_u - u) + torch.norm(new_v - v) < tol: u, v = new_u, new_v break u, v = new_u, new_v P = u.view(-1, 1) * K * v.view(1, -1) return P, u, v这段代码的核心不是循环,而是两个幂次更新。标准 Sinkhorn 是u = a / row_marg,这里则加上了alphas和betas指数。加指数后,即便row_marg里有 0,0 ** 0.5也能天然给个安全值,所以实际稳定性要比硬除好不少。
4.2 实际运行时的数值稳定技巧
技巧一:做 log-domain 变换。上面的代码在eps较小时,exp(-C/eps)会下溢或上溢。比如C=10, eps=0.01时,exp(-1000)直接变成 0,此时row_marg=0,更新就彻底失效。推荐用对数域存放 (u、v),公式变为:
[ \log u_i = \alpha \left( \log a_i - \log \sum_j K_{ij} v_j \right) ]
在 PyTorch 里用logsumexp实现,几乎不会溢出。
技巧二:归一化初始化。如果 (a,b) 的 mass 差异很大,初始乘子全 1 时,第一轮的行边际可能远小于目标值,导致 (a/row_marg) 非常大。此时应预先对 (a、b) 做一次类似“average total mass”的归一化,比如a = a / a.sum() * total_mass,再进入迭代,可以大幅加速收敛,同时避免前期乘子爆炸。
4.3 超参数组合的推荐范围
我给一个自己的经验表,适用范围非常广,但仍需按数据 variance 调整。
| 场景 | (\epsilon) 起始值 | (\rho) 起始值 | 说明 |
|---|---|---|---|
| 干净分布、小规模数据 | 0.05 | 10 | 结果逼近真实 OT |
| 有噪声数据、样本量差异大 | 0.1~0.2 | 2~5 | 保留质量差异信息 |
| 高维稀疏分布向量 | 0.01~0.05 | 1~3 | 耦合稀疏化更好 |
| 深度神经网络嵌入层后 | 0.05 | 0.5~1 | 训练早期不宜过强约束 |
建议一开始用小 (\epsilon) 和大 (\rho) 验证代码逻辑;如果结果出现很多微小传输量,再把 (\epsilon) 放大。如果边际误差怎么都降不到预期,就把 (\rho) 加大。
5. 用 KKT 条件做 ETM 调试与病态问题定位
5.1 KKT 残差怎么算
很多 UOT 包会告诉你“跑了 100 次迭代,收敛了”。但收敛到不动点,不等于满足 KKT。因为散度惩罚本身没有让边际完全相等,必须手动检查原始的最优性条件。代码里可以在每轮迭代后算:
with torch.no_grad(): grad = C + eps * torch.log(P) + rho1 * torch.log((P.sum(1) / a).clamp_min(1e-12)).view(-1, 1) + rho2 * torch.log((P.sum(0) / b).clamp_min(1e-12)).view(1, -1) kkt_resid = torch.norm(grad, p=2)这个残差越接近 0,说明当前 (P) 越接近该条件下可达到的最优。注意,log 里面要 clamp 下边界,否则会出现 NaN。我通常把kkt_resid阈值设为1e-3,配合 1000 次迭代,绝大多数 Expensive 场景都能通过。
5.2 “不收敛”的三大原因
我在“ETM 调试”上遇到过最典型的三个病状。
病状一:边际误差震荡,不下降。原因通常是 (\rho) 太大,导致更新步长约等于 1,等价于做标准 Sinkhorn,可是数据又不满足硬边际守恒。模型每轮都在“强行搬质量”和“被散度惩罚拉回来”之间剧烈摇摆。对症办法是降低 (\rho),或者把 (\rho) 设成一个小值,再用warm start每次从前一轮乘子出发。
病状二:整体 loss 降了,但 P 全变成均匀分布。这个几乎都是 (\epsilon) 大过头了。熵正则化的“引力”太强,所有传输量都被拉向同一个水平,边际信息完全被覆盖。把 (\epsilon) 调小一个量级,通常立即见效。
病状三:P 出现大量 0 或 NaN。这是数值病,不在模型。先查成本矩阵是不是有极小值;再查exp(-C/eps)是否下溢;最后查散度惩罚的 log 是否喂了 0。建议把成本矩阵 min-max 标准化到 0~1,再跑 Sinkhorn,几乎所有数值问题都会好很多。
5.3 利用乘子值解读数据本身
乘子 (u、v) 不只是一个计算中间量,它本身就是“信息压缩包”。在平衡 OT 中,(u_i) 较大的点,意味着那个源点被分配了意想不到的大权重,常见于离群点;在 UOT 中,乘子则反映了“质量创建/销毁”的调整方向。
有一次我处理细胞基因表达数据的批次效应,跑完 UOT 之后发现某个批次里大量源点乘子远大于目标点乘子,顺着这个线索找到了一个异常表达的基因模块。将乘子作为 attention score 接回模型后,分类准确率提升了不少。这就是“乘子正则化”带来的实际价值:它不仅让优化可解,还为业务方提供了一个可解释的动态权重。
6. 从 UOT 回到 OT 的工程路径
6.1 什么时候真的需要回到 OT
虽然 UOT 更灵活,但它也有代价:求解目标多了两个惩罚参数,选择不当会让结果过软;且对偶空间不再是严格 1-Lipschitz 函数的梯度空间,这在很多机器学习证明里是硬伤。因此,如果确定数据已经做过异常处理、mass 基本守恒,我会直接用 OT,不再秀 UOT 的肌肉。
从 UOT 回到 OT 最平滑的方式,不是删掉散度惩罚换硬约束,而是直接把 (\rho_1、\rho_2) 设成远大于 (\epsilon) 的数值跑同一条迭代算法。因为更新公式里的指数会趋近 1,从而自然衰减成 Sinkhorn。这种做法在工程上有一个好处:共用同一套代码,切换语义只需调两个参数,不用重写逻辑。
6.2 实践中如何确定“足够接近 OT”
我会输出三组指标:
- 行边际误差:(|P\mathbf{1}_m - a|_1)
- 列边际误差:(|P^T\mathbf{1}_n - b|_1)
- 总质量守恒残差:(|\mathrm{sum}(P) - 1|)
当三者都小于 (10^{-3}),且 KKT 残差也在阈值内,就可以放心地说这个 UOT 已经逼近 OT。如果退不回这个精度,真正原因往往不在求解器,而在成本矩阵的尺度设计出了问题——比如 cost 范围跨了 10 个数量级,即使乘子正则化也救不回来。
6.3 一个顺手的小 trick
如果你要跑大批量场景,把u和v的更新换成 log-domain 后,可以用一次“乘子 warm start”:将上一轮 batch 训练收敛的u,v复制到本轮初始化,而不是从全 1 开始。这个简单操作在时间序列数据上能节省约 40% 的 Sinkhorn 迭代次数,且不会破坏收敛性,是我个人最推荐的工程化优化点之一。
7. 写在最后的一些笨办法
我个人在实际调试里最大的体会是:不要迷恋“高级求解器”,先把自己的 KKT 残差代码写好,否则调半天都不知道算法到底收敛到了哪里。很多论文里的 UOT 代码包用一行 API 就把 Sinkhorn 封掉了,导致大家把迭代次数、正则化系数当作黑盒调参,最后在真实数据上翻车。
还有一个小习惯:每次调试都保留一组固定的随机种子和一个小规模的合成数据(比如两个 2D 高斯混合分布)。先看可视化的传输矩阵长什么样,再去看量化指标。我见过很多次量化 loss 很漂亮、但传输计划已经完全背离物理直觉的案例,只有把 P 画出来,才会发现某些列被整体吞掉或稀疏得异常。
这篇内容后续还可以往三个方向扩展:一是把散度惩罚换成更一般的 Csiszár 散度,二是把 UOT 嵌入对比学习做表示对齐,三是在大规模图上用多尺度 Sinkhorn 加速。如果你是自己实现 UOT,建议先从熵正则化系数 (\epsilon=0.1) 开始,把 KKT 残差跑通,再逐步往下调参数。踩过一次数值下溢的坑后,你会彻底理解为什么所有最优传输库都要在 log-domain 里写迭代更新。