qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

沙子原地不动能零运费,为什么平滑算法还报了一个正数?

进阶实验:从两堆沙的四格运输表出发,区分纯运费、完整 KL 目标和负熵目标,再检查自成本修正。

展开本文目录
  1. 下载完整练习包
  2. 先规定沙子怎么搬、费用怎么算
  3. 额外的分流惩罚改变了目标
  4. 选一个能手算出结果的参数
  5. 同一分布,未必让这个原始评分最低
  6. 减掉双方的自成本后,比较对象变了
  7. 纯运费、KL目标和负熵目标不能混成一个数
  8. 运行原型并核对独立参考
  9. 交付结果时,先说明自己报告的是哪一列
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了

位置0和1各放半堆沙,目标仍是在这两处各放半堆。只算搬运费时,原地不动就够了,最低费用为0。现在评分额外鼓励沙子分流,算法可能愿意付出一点运费来减轻这项惩罚,于是返回的总评分不再是0。

这不是凭空多收了一笔物理费用,而是你给程序换了目标。下面把四格运输表、额外惩罚和最终报告的数分别列清楚。本文是进阶的离线数学实验,没有调用深度学习框架,也没有训练生成模型。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本;2026-10-18 实测 Windows / Python 3.14.0,3.10 仅做语法检查。仅用标准库,无第三方依赖。仅使用标准库。实际运行日期、环境和范围见随包核验记录。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • sinkhorn_self_cost.py:教学源码
  • sinkhorn_self_cost.observed.stdout.txt:说明或核验记录
  • README.md:说明或核验记录
  • verification.json:说明或核验记录
  • SHA256SUMS.txt:说明或核验记录

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B sinkhorn_self_cost.py

解压到独立练习文件夹,在包含源码的目录打开终端,按下方顺序运行。示例运行记录不代表独立练习已完成;修改代码后需重新验证。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先规定沙子怎么搬、费用怎么算

把总沙量定为1,源与目标质量都是 a=b=[1/2,1/2]。本例约定每单位沙的运费为距离的平方,C(x,y)=(x−y)²,不带额外的1/2。因此两处之间的成本表是[[0,1],[1,0]]。

运输表 π 的第 i 行第 j 列表示从源位置 i 搬到目标位置 j 的质量。每行之和必须等于对应源质量,每列之和必须等于对应目标质量;这些约束保证沙子没有被凭空增减。

原地不动的表是[[1/2,0],[0,1/2]],运费为0。所有满足这组行列和的表还可以写成[[t,1/2−t],[1/2−t,t]],其中0≤t≤1/2。用这一个数 t 就能检查整个小问题。

额外的分流惩罚改变了目标

先假想源与目的地独立选择:从源 i 到目的地 j 的质量应为 a_i b_j。本例四格都等于1/4。KL(π||a⊗b)=Σπ_ij ln[π_ij/(a_i b_j)]衡量运输表与这张独立分流表的差异,其中ln是自然对数,0ln0按0处理。

我们要最小化的完整目标是 OT_ε(a,b)=min_π{Σπ_ij C_ij+ε KL(π||a⊗b)},ε>0控制额外惩罚的强度。前一项是纯运费,后一项是正则项。本文所有写作“完整成本”的数,都采用这一定义。

原地不动虽然不花运费,却偏离四格均分的独立表。四格均分的 KL 为0,却会把一部分沙搬到对面。最优解要在这两项之间折中;它求的已经不是单独最小化运费。

选一个能手算出结果的参数

取 ε=1/ln3。将一元运输表代入完整目标,得到 J(t)=1−2t+2ε[t ln(4t)+(1/2−t)ln(4(1/2−t))]。这里1−2t就是搬到对面的总质量,也就是纯运费。

在0<t<1/2内求导,J′(t)=−2+2ε ln[t/(1/2−t)]。令它为0,得到 t/(1/2−t)=3,所以 t=3/8。二阶导数为2ε[1/t+1/(1/2−t)]>0,说明这个驻点是该可行区间中的唯一最小点。

最优表因此是[[3/8,1/8],[1/8,3/8]]。纯运费为1/4;KL为3/4 ln(3/2)+1/4 ln(1/2)≈0.130812036;完整成本为ln(3/2)/ln3≈0.369070246。把这三个量放在不同列,才不会把运费误称为完整目标。

同一分布,未必让这个原始评分最低

把目标改为中间位置1/2的一整堆沙。运输方案只有一列,每个源都搬出1/2;两边搬运距离都是1/2,所以纯运费为1/4。这个方案恰好等于 a⊗b,KL为0,完整目标也是0.25。

在所选 ε 下,0.25小于原目标自比较的0.369070246。仅比较这两个目标时,原始完整成本更偏好中间一堆。这给“分布与自己比较必为0,而且一定最低”提供了一个具体反例。

我们没有搜索所有可能目标,更没有运行模型训练;这里的结论只需要比较两种明确给定的目标。论文中讨论的熵偏差解释了类似收缩现象,但它的真实训练影响仍需单独评估。

减掉双方的自成本后,比较对象变了

Sinkhorn divergence 使用 S_ε(a,b)=OT_ε(a,b)−OT_ε(a,a)/2−OT_ε(b,b)/2。代入 a=b 时,三个同样的数直接抵消,S_ε(a,a)=0。这个性质来自定义,不是运行次数多了才出现。

中间单堆与自己比较时不用搬运,唯一运输表的KL也为0。因此原分布与中间单堆的修正值是0.25−0.369070246/2≈0.065464877,仍大于自比较的0。

这里“去偏”指扣除熵正则造成的自相互作用,不表示一个有限样本统计量已经无偏。原论文对非负、可分辨分布等性质有成本和核的假设;我们采用有界支持上的平方欧氏成本。有限实验不能替代这些定理,也不能顺势宣称满足三角不等式。

纯运费、KL目标和负熵目标不能混成一个数

还有一种常见写法是负熵目标 J_NE=运费+εΣπ_ij lnπ_ij。令 H(a)=−Σa_i ln a_i,展开KL可得 J_KL=J_NE+ε[H(a)+H(b)]。固定源与目标质量时,最后一项与运输方案无关,两种写法找到的最优表相同,报告的目标值却不同。

若改变目标分布 b,这个常数也会跟着变。本例 ε=1/ln3 时,两堆沙自比较的负熵目标约为−0.892789261,中间单堆的为−0.380929754。于是负熵目标在这两个选项中偏好前者,完整KL目标却偏好后者。不能把“只差常数”误说成跨不同目标也可以随意替换。

参数也会改变三列数字。对两堆沙自比较,ε=0.1时纯运费约0.000045398、完整KL目标约0.069310178、负熵目标约−0.069319258;ε=3时相应为0.417429794、0.458524818、−3.700358266。负熵目标出现负数并不表示运费为负。

下方实际输出保留5种ε、两类目标共10行对照。2013论文的某个报告量只取正则最优运输表的运输项,本文完整目标采用2019论文式(1)的KL约定。读库文档时,还应核对成本是否额外乘1/2,以及返回的是哪一列。

运行原型并核对独立参考

程序把 K_ij=exp(−C_ij/ε) 交替按行、列缩放,直到两边质量约束的最大残差小于1e−13,或达到迭代上限。这是小型 Sinkhorn 计算,不是通用数值库。

主例逐格核对3/8、1/8的闭式答案;另外对144组二乘二问题,用一元目标导数的二分求根作为独立参考。144来自4种源质量、4种目标质量、3组目标位置、3种ε,而不是144次模型训练或144个真实数据集。

本次实际通过462项检查。独立参考比较的最大运输表差约9.7644e−14,最大质量残差约9.9476e−14;另有10行参数约定对照,以及6类非法输入或下溢请求。源码与下方输出一一对应。

原型只处理极小、严格正质量且数值范围温和的离散问题。它拒绝零权重、错误形状、非正或非有限ε,以及指数下溢;未实现对数域稳定算法,未运行 GeomLoss、GPU或训练过程。

展开收起代码 · 小实验python · 170 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Tiny entropic-transport checks. KL convention: KL(pi || a tensor b)."""
import json
import math
from pathlib import Path
import platform


def cost_matrix(x, y):
    # Squared distance, with no factor 1/2.
    return [[(u - v) ** 2 for v in y] for u in x]


def objective(plan, a, b, costs, epsilon):
    transport = sum(plan[i][j] * costs[i][j] for i in range(len(a)) for j in range(len(b)))
    kl = sum(plan[i][j] * math.log(plan[i][j] / (a[i] * b[j]))
             for i in range(len(a)) for j in range(len(b)) if plan[i][j] > 0)
    return transport, kl, transport + epsilon * kl


def negative_entropy_objective(plan, costs, epsilon):
    transport = sum(plan[i][j] * costs[i][j] for i in range(len(plan)) for j in range(len(plan[i])))
    negative_entropy = sum(p * math.log(p) for row in plan for p in row if p > 0)
    return transport + epsilon * negative_entropy


def sinkhorn(a, b, costs, epsilon, tolerance=1e-13, max_iterations=20000):
    if not math.isfinite(epsilon) or epsilon <= 0:
        raise ValueError('positive finite epsilon required')
    if not a or not b or any(not math.isfinite(v) or v <= 0 for v in a + b):
        raise ValueError('strictly positive finite probability weights required')
    if abs(sum(a) - 1) > tolerance or abs(sum(b) - 1) > tolerance:
        raise ValueError('unit mass required')
    if len(costs) != len(a) or any(len(row) != len(b) for row in costs):
        raise ValueError('cost shape mismatch')
    if any(not math.isfinite(c) or c < 0 for row in costs for c in row):
        raise ValueError('finite nonnegative costs required')
    kernel = [[math.exp(-c / epsilon) for c in row] for row in costs]
    if any(v == 0 for row in kernel for v in row):
        raise ValueError('kernel underflow: use a log-domain solver outside this prototype')
    v = [1.0] * len(b)
    for iteration in range(1, max_iterations + 1):
        u = [a[i] / sum(kernel[i][j] * v[j] for j in range(len(b))) for i in range(len(a))]
        v = [b[j] / sum(kernel[i][j] * u[i] for i in range(len(a))) for j in range(len(b))]
        plan = [[u[i] * kernel[i][j] * v[j] for j in range(len(b))] for i in range(len(a))]
        residual = max([abs(sum(plan[i]) - a[i]) for i in range(len(a))]
                       + [abs(sum(plan[i][j] for i in range(len(a))) - b[j]) for j in range(len(b))])
        if residual < tolerance:
            return plan, residual, iteration
    raise RuntimeError('iteration cap reached')


def reference_2x2(a, b, costs, epsilon):
    """Independent 1D convex optimization via the monotone analytic derivative."""
    lo, hi = max(0.0, a[0] + b[0] - 1), min(a[0], b[0])
    delta = costs[0][0] - costs[0][1] - costs[1][0] + costs[1][1]
    for _ in range(100):
        t = (lo + hi) / 2
        cells = (t, a[0] - t, b[0] - t, 1 - a[0] - b[0] + t)
        derivative = delta + epsilon * (math.log(cells[0]) + math.log(cells[3])
                                          - math.log(cells[1]) - math.log(cells[2]))
        if derivative > 0:
            hi = t
        else:
            lo = t
        if hi - lo <= 2e-15:
            break
    t = (lo + hi) / 2
    return [[t, a[0] - t], [b[0] - t, 1 - a[0] - b[0] + t]]


def main():
    checks = 0
    def check(condition, label):
        nonlocal checks
        checks += 1
        if not condition:
            raise AssertionError(label)
    a = [0.5, 0.5]
    epsilon = 1 / math.log(3)
    costs = cost_matrix([0, 1], [0, 1])
    plan, residual, iterations = sinkhorn(a, a, costs, epsilon)
    expected = [[3 / 8, 1 / 8], [1 / 8, 3 / 8]]
    check(max(abs(plan[i][j] - expected[i][j]) for i in range(2) for j in range(2)) < 1e-14, 'closed-form plan')
    transport, kl, raw_self = objective(plan, a, a, costs, epsilon)
    closed_raw = math.log(1.5) / math.log(3)
    check(abs(raw_self - closed_raw) < 1e-13, 'closed-form regularized cost')
    check(abs(transport - 0.25) < 1e-13 and raw_self > 0, 'positive self transport and objective')
    identity = [[0.5, 0], [0, 0.5]]
    check(objective(identity, a, a, costs, epsilon)[0] == 0, 'unregularized self cost')
    check(raw_self < objective(identity, a, a, costs, epsilon)[2], 'splitting reduces regularized objective')
    collapsed_plan, _, _ = sinkhorn(a, [1.0], cost_matrix([0, 1], [0.5]), epsilon)
    raw_collapsed = objective(collapsed_plan, a, [1.0], cost_matrix([0, 1], [0.5]), epsilon)[2]
    debiased_collapsed = raw_collapsed - 0.5 * raw_self  # delta_0.5 self cost is exactly zero.
    check(abs(raw_collapsed - 0.25) < 1e-13 and raw_collapsed < raw_self, 'raw cost favors collapsed target in this case')
    check(debiased_collapsed > 0 and raw_self - 0.5 * raw_self - 0.5 * raw_self == 0, 'debiased comparison')

    convention_table = []
    for eps in (0.1, 0.3, epsilon, 1.5, 3.0):
        for label, bb, yy in [('same_two_piles', a, [0, 1]), ('one_pile_at_mean', [1.0], [0.5])]:
            cc = cost_matrix([0, 1], yy)
            pp, res, its = sinkhorn(a, bb, cc, eps)
            pure, relative_kl, full_kl = objective(pp, a, bb, cc, eps)
            negative_entropy_value = negative_entropy_objective(pp, cc, eps)
            marginal_entropy = -sum(w * math.log(w) for w in a + bb)
            check(abs((full_kl - negative_entropy_value) - eps * marginal_entropy) < 1e-12,
                  'KL versus negative entropy marginal constant')
            if label == 'same_two_piles':
                exact_pure = 1 / (1 + math.exp(1 / eps))
                exact_full = eps * (math.log(2) - math.log1p(math.exp(-1 / eps)))
                check(abs(pure - exact_pure) < 1e-12, 'epsilon sweep pure transport reference')
                check(abs(full_kl - exact_full) < 1e-12, 'epsilon sweep KL objective reference')
            convention_table.append({'epsilon': eps, 'target': label, 'pure_transport': pure,
                                     'KL_pi_vs_a_tensor_b': relative_kl, 'full_KL_objective': full_kl,
                                     'negative_entropy_objective': negative_entropy_value,
                                     'KL_minus_negative_entropy_constant': eps * marginal_entropy})
    special = [row for row in convention_table if row['epsilon'] == epsilon]
    check(special[0]['full_KL_objective'] > special[1]['full_KL_objective'], 'KL objective comparison across different target marginals')
    check(special[0]['negative_entropy_objective'] < special[1]['negative_entropy_objective'], 'negative entropy comparison can reverse across target marginals')

    comparisons = 0
    worst_plan_error = 0.0
    worst_residual = 0.0
    max_used_iterations = 0
    for mass_a in (0.2, 0.35, 0.5, 0.8):
        for mass_b in (0.2, 0.4, 0.5, 0.7):
            aa, bb = [mass_a, 1 - mass_a], [mass_b, 1 - mass_b]
            for target in ([0.0, 1.0], [0.2, 1.4], [-0.3, 0.7]):
                cc = cost_matrix([0, 1], target)
                for eps in (0.3, 0.7, 1.5):
                    pp, res, its = sinkhorn(aa, bb, cc, eps)
                    ref = reference_2x2(aa, bb, cc, eps)
                    err = max(abs(pp[i][j] - ref[i][j]) for i in range(2) for j in range(2))
                    check(err < 5e-12, 'independent 1D reference')
                    check(abs(objective(pp, aa, bb, cc, eps)[2] - objective(ref, aa, bb, cc, eps)[2]) < 1e-11, 'reference objective')
                    check(res < 1e-13, 'marginal residual')
                    worst_plan_error = max(worst_plan_error, err)
                    worst_residual = max(worst_residual, res)
                    max_used_iterations = max(max_used_iterations, its)
                    comparisons += 1
    rejected = 0
    for aa, bb, cc, eps in [(a, a, costs, 0), ([0, 1], a, costs, 1),
                             ([0.6, 0.6], a, costs, 1), (a, a, [[0]], 1),
                             (a, a, costs, 1e-6), (a, a, costs, float('nan'))]:
        try:
            sinkhorn(aa, bb, cc, eps)
        except ValueError:
            rejected += 1
    check(rejected == 6, 'invalid inputs and underflow rejected')
    report = {'status': 'PASS', 'runtime': platform.python_version(), 'checks': checks,
              'independent_2x2_comparisons': comparisons, 'worst_plan_error': worst_plan_error,
              'worst_marginal_residual': worst_residual, 'maximum_iterations_used': max_used_iterations,
              'epsilon': epsilon, 'self_plan': plan, 'self_transport': transport, 'self_KL': kl,
              'self_regularized_objective': raw_self, 'collapsed_raw_objective': raw_collapsed,
              'self_sinkhorn_divergence': 0.0, 'collapsed_sinkhorn_divergence': debiased_collapsed,
              'epsilon_and_convention_table': convention_table,
              'convention_identity': 'J_KL = J_negative_entropy + epsilon*(H(a)+H(b)); the added constant is fixed only when both marginals are fixed.',
              'conventions': 'C(x,y)=(x-y)^2; no factor 1/2. OT_eps=min <C,pi>+eps*KL(pi||a tensor b).',
              'boundary': 'Only tiny strictly-positive finite discrete distributions. No log-domain solver, GPU, GeomLoss run, training, or model-quality claim.'}
    Path('report.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
    print('epsilon = 1/log(3); self plan = [[3/8,1/8],[1/8,3/8]]')
    print(f'self: transport={transport:.9f}; KL={kl:.9f}; full objective={raw_self:.9f}')
    print(f'collapsed mean: raw={raw_collapsed:.9f}; debiased={debiased_collapsed:.9f}; debiased self=0')
    print('epsilon | target | pure transport | full KL objective | negative entropy objective')
    for row in convention_table:
        print(f"{row['epsilon']:.9f} | {row['target']} | {row['pure_transport']:.9f} | {row['full_KL_objective']:.9f} | {row['negative_entropy_objective']:.9f}")
    print(f'PASS: {checks} checks; {comparisons} independent 2x2 comparisons; {rejected} rejected inputs')


if __name__ == '__main__':
    main()

怎样运行:保存为 sinkhorn_self_cost.py,在独立练习目录执行 python -B sinkhorn_self_cost.py。2026-10-18 已在 Windows、Python 3.14.0 实际运行此份源码,生成 report.json。Python 3.10 仅做语法兼容检查,未启动该版本解释器。程序只使用标准库,不访问网络。

运行后对照结果
epsilon = 1/log(3); self plan = [[3/8,1/8],[1/8,3/8]]
self: transport=0.250000000; KL=0.130812036; full objective=0.369070246
collapsed mean: raw=0.250000000; debiased=0.065464877; debiased self=0
epsilon | target | pure transport | full KL objective | negative entropy objective
0.100000000 | same_two_piles | 0.000045398 | 0.069310178 | -0.069319258
0.100000000 | one_pile_at_mean | 0.250000000 | 0.250000000 | 0.180685282
0.300000000 | same_two_piles | 0.034445196 | 0.197428429 | -0.218459879
0.300000000 | one_pile_at_mean | 0.250000000 | 0.250000000 | 0.042055846
0.910239227 | same_two_piles | 0.250000000 | 0.369070246 | -0.892789261
0.910239227 | one_pile_at_mean | 0.250000000 | 0.250000000 | -0.380929754
1.500000000 | same_two_piles | 0.339243631 | 0.418165641 | -1.661275901
1.500000000 | one_pile_at_mean | 0.250000000 | 0.250000000 | -0.789720771
3.000000000 | same_two_piles | 0.417429794 | 0.458524818 | -3.700358266
3.000000000 | one_pile_at_mean | 0.250000000 | 0.250000000 | -1.829441542
PASS: 462 checks; 144 independent 2x2 comparisons; 6 rejected inputs

交付结果时,先说明自己报告的是哪一列

一份可复核的报告应同时写明:源与目标的坐标和质量、成本公式、ε、完整目标的熵约定、是否扣除自成本,以及运输表的行列残差。只写一个“Sinkhorn距离=某数”,通常不足以还原计算。

这个实验能说明定义改变如何影响一个小问题的最优方案与数值。它不能证明某个模型更好、收敛更快,或者某种ε适合你的数据。参数选择、训练稳定性与任务效果都需要另外的证据。

练习时先保留所有列,再对照变化。若某两列看起来只差一个常数,要检查这个常数是否随着你正在比较的参数或目标一起变化。

记住这些要点

  • 熵正则优化的是运费与额外惩罚的总和,自比较不必为0。
  • 纯运费、完整KL目标和负熵目标的数值含义不同。
  • KL与负熵写法只在固定两边质量时相差与运输方案无关的常数。
  • 自成本扣除处理的是熵偏差,不能与有限样本估计的无偏混为一谈。

别把例子用过头

  • 省略成本的平方或1/2因子,然后比较不兼容的输出。
  • 把完整正则成本误读为物理运费,或见负熵目标为负就认定程序错误。
  • 改变目标质量后仍把边缘熵当成同一个常数。
  • 把有限数值核验、质量残差通过与模型效果证明混为一谈。

自己试一小步

把ε改为0.2和2.0,再改变一边的质量,哪些数可以直接比较?

  1. 先保留两边各半的质量,手算或推导两种ε下的最优表,再核对三列目标。
  2. 把源质量改成[0.3,0.7],目标保持各半,用独立一元参考核验运输表与行列和。
  3. 把全部成本与ε同时乘同一个正数,先预测运输表和三列数值各会怎样变化,再运行检查。

做到这些,就算完成

  • 每行和、每列和分别满足源与目标质量,没有只检查总质量。
  • 完整KL目标与负熵目标的差,等于当次参数对应的ε[H(a)+H(b)]。
  • 保留成本、ε和返回量约定,没有把试出的几个数推广成模型效果结论。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Interpolating between Optimal Transport and MMD using Sinkhorn Divergences (新标签页)↗Feydy 等 / AISTATS 2019 原论文 · 英文

    本文目标的精确定义、自比较偏差与修正的适用条件。

    2026-10-18 实际阅读范围:实际打开PDF并查看第2、3页渲染;阅读式(1)的KL目标、式(3)的自成本扣除、The entropic bias段及Theorem 1的成本/核假设。 阅读边界:未通读全部10页证明或复现原文GPU/生成实验。

  • Sinkhorn Distances: Lightspeed Computation of Optimal Transport (新标签页)↗Marco Cuturi / NeurIPS 2013 原论文 · 英文

    小型Sinkhorn缩放原型的计算形式,以及运输项与完整正则目标的区别。

    2026-10-18 实际阅读范围:已打开PDF正文第4节式(2)、Lemma 2和Algorithm 1;实际下载并查看第5页渲染,核对K=exp(−λM)、交替缩放和返回运输项。 阅读边界:未复现MNIST、速度比较和GPU实验。

  • GeomLoss: Blur parameter, scaling strategy (新标签页)↗GeomLoss 官方教程 · 英文

    原始目标的收缩倾向和自成本扣除的工程入口。

    2026-10-18 实际阅读范围:已打开并阅读 Scaling strategy 中The entropic bias、Unbiased Sinkhorn divergences及自成本修正公式,并核对debias参数在示例中的用途。 阅读边界:页面示例与图示不属于本次运行结果;未执行该教程、导数计算或其图像数据集。