qinghuan
← 回到知识探索

AI 直觉 / 动手实验

两条都是正确资料,为什么一种损失还会压低其中一条?

相同候选、相同正例标签,先求和还是先取对数,会改变多正例训练的目标和梯度。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 固定三条候选,先不训练编码器
  4. 只认第一条正例,会把第二条也往下压
  5. 先把正例概率加起来:总量好就可以
  6. 先分别取对数,再平均:弱正例不能被盖过去
  7. 论文里的 log 内平均,还多一个常数
  8. 运行四种目标,逐项核对导数
  9. 根据任务决定要总量,还是要覆盖每条正例
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 知道 softmax 将候选分数归一化
  • 知道梯度下降会减去梯度
  • 先读 记忆与可学习检索:先了解双编码器和批内候选分数,再深入比较多正例的不同汇总规则。
遇到不熟悉的词,先看这 2 个解释
正例集合
针对同一个查询或锚点,被标为有效匹配的全部候选。
假负例
训练把它当作不相关对照,但按实际任务它也应算有效匹配。
打开完整术语小抄 ↗

一个问题有两篇正确资料。模型给第一篇很高的分,第二篇很低的分。我们已经把两篇都标成正例,为什么换一种多正例损失后,第一篇反而被要求降分?

这不一定是标注错误。你可以要求“总概率尽量落在正例集合”,也可以要求“每条正例都拿到份额”。两种目标都使用多正例,但它们对强弱正例的要求不同。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

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

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。单个查询和固定正例集合的自由分数损失与导数;未训练编码器,未测真实检索或分类效果。

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

两条都标成正例,梯度为什么仍不一样?

正在准备实验控件…

当前目标值
2.024745
正例总概率
0.993427
梯度下降减去梯度;正梯度会降低该分数
候选概率分数梯度单独更新分数时
0 · 正例0.9755590.475559往下拉 ↓
1 · 正例0.017868-0.482132往上推 ↑
2 · 负例0.0065730.006573往下拉 ↓

目标给两条正例各一半份额。强正例可能被往下拉,弱正例被抬高;这不等于程序把正例误标成负例。

三个分数可独立调整,前两条为正例,第三条为负例。分数已含温度缩放,正例数量固定。这里只看对分数的导数;真实编码器共享参数,不能据此保证一次训练后的每条分数变化。

固定三条候选,先不训练编码器

用三个候选的分数 x=[4,0,−1]。前两条是正例 P={0,1},第三条是负例。分数已经包含所需的温度缩放;本篇直接对这些分数求导,不再额外除温度。

softmax 概率 p_j=exp(x_j)/Σ exp(x_k),得到约 [0.975559,0.017868,0.006573]。第一条正例已经占去大部分概率,第二条很弱,负例也很小。

候选列表不包含查询本身。这里把分数视作可以独立调整的三个变量;真实编码器中它们可能共享参数,所以局部分数梯度不等于保证某一次参数更新后每个分数都照此变化。

只认第一条正例,会把第二条也往下压

单正例目标 L_single=−ln(p0)。其分数梯度是 p 减去 [1,0,0],约为 [−0.024441,+0.017868,+0.006573]。

梯度下降会减去这些数:第一条加分,第二条和第三条减分。如果第二条其实也是有效答案,这份单正例标签就把它当成了假负例。

批内其他资料被复用为负例是一种训练安排,不保证它们对当前问题真的无关。核对正例集合是第一步,但把集合填对后,仍要决定怎样汇总多条正例的损失。

先把正例概率加起来:总量好就可以

一种目标是 L_mass=−ln(p0+p1)。它只关心选到正例集合的总概率,集合内部怎样分配不影响这个总量。

对正例 j,梯度是 p_j−exp(x_j)/Σ_{k∈P}exp(x_k);对负例则是 p_j。第二项只在正例内部归一化,因此高分正例也拿到更大的内部权重。

本例损失约 0.006595,梯度约 [−0.006455,−0.000118,+0.006573]。两条正例都被往上推,但第二条信号已经很弱;靠第一条承担几乎所有正例概率,也能让目标接近零。

先分别取对数,再平均:弱正例不能被盖过去

另一种目标是 L_out=−(ln(p0)+ln(p1))/2。其梯度为 p 减去 [0.5,0.5,0],约 [ +0.475559,−0.482132,+0.006573 ]。

这次第一条正例的梯度为正,梯度下降会降低它的分数;第二条被强烈抬高。原因是目标要求两条正例各占一半目标份额,不能让一个强正例遮住另一个弱正例。

在自由分数模型里,固定正例总概率后,平均负对数在正例概率均匀分配时最低。它的最低可能损失不是零;两条正例且负例概率趋零时,下确界是 ln(2)。因此不能拿它的 2.024745 与另一目标的 0.006595 直接比“训练谁更好”。

论文里的 log 内平均,还多一个常数

Supervised Contrastive Learning 第 3.2.2 节比较 log 外平均与 log 内平均。本文 L_out 对应一个锚点的 log 外平均;论文另一形式是 L_in=−ln((p0+p1)/2)。

L_in=L_mass+ln(2)。正例数量固定时,ln(2) 与分数无关,所以 L_in 和 L_mass 的梯度完全相同,损失数值却不同。本例 L_in≈0.699742。

这只说明此固定正例集合上的加法常数不影响梯度。若不同锚点的正例数不同,日志里的常数也不同;若你还用损失值进行样本筛选或改变权重,那些额外操作需要单独分析。

运行四种目标,逐项核对导数

保存为 demo.py,运行 python demo.py,仅需 Python 3.14 标准库。程序打印四种目标的完整损失和梯度,用分数的微小扰动逐项核对解析导数。

35 个检查还覆盖单个正例时的等价、全部候选都是正例时总概率目标为零,以及全部分数加一千后的不变性。另核对三个相等的 ±1e16 分数,以及两条很低但相等的正例,避免计算时丢掉局部归一化的小数项。没有训练文本编码器,也没有进行真实召回测试。

代码把总候选和正例内分布分别减去各自最大值,并让损失计算留在平移后的尺度。只在 exp 前减最大值,再把巨大值加回 logZ 后相减,仍可能丢失 ln(2) 等小数部分。这个修正不能恢复输入浮点数在进入函数之前已经舍入丢掉的差值。

本实现会先减去最大分数;如果两个有限分数的差已经溢出为无穷,则明确拒绝。它不承诺用普通浮点数表示任意大的损失或分数跨度。

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

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

小实验 · python
"""Compare single-positive and two multi-positive score-level objectives."""
import math


def objective(scores, positives, mode):
    if (not scores or not positives or len(set(positives)) != len(positives)
            or any(i < 0 or i >= len(scores) for i in positives)
            or any(not math.isfinite(x) for x in scores)):
        raise ValueError("finite scores and unique, valid positive indices required")
    # Keep losses and probabilities centered; adding back a huge max loses log(n).
    high = max(scores)
    shifted = [s - high for s in scores]
    if any(not math.isfinite(value) for value in shifted):
        raise ValueError("score difference exceeds finite floating-point range")
    weights = [math.exp(s) for s in shifted]
    total = math.fsum(weights)
    log_z = math.log(total)
    probability = [weight / total for weight in weights]
    gradient = probability.copy()
    if mode == "single":
        chosen = positives[0]
        loss = log_z - shifted[chosen]
        gradient[chosen] -= 1
    elif mode == "outside_mean":
        loss = math.fsum((log_z - shifted[i]) / len(positives) for i in positives)
        for i in positives:
            gradient[i] -= 1 / len(positives)
    elif mode in ("positive_mass", "inside_mean"):
        # Positive-only normalization needs its own max, even after global centering.
        positive_high = max(shifted[i] for i in positives)
        positive_weights = [math.exp(shifted[i] - positive_high) for i in positives]
        positive_total = math.fsum(positive_weights)
        loss = (log_z - positive_high) - math.log(positive_total)
        for i, weight in zip(positives, positive_weights):
            gradient[i] -= weight / positive_total
        if mode == "inside_mean":
            loss += math.log(len(positives))
    else:
        raise ValueError("unknown objective")
    return loss, gradient, probability


def main():
    scores, positives = [4.0, 0.0, -1.0], (0, 1)
    modes = ("single", "positive_mass", "inside_mean", "outside_mean")
    results = {mode: objective(scores, positives, mode) for mode in modes}
    checks = {}
    for mode in modes:
        loss, gradient, _ = results[mode]
        checks[mode + "_finite"] = math.isfinite(loss)
        checks[mode + "_gradient_sum_zero"] = abs(sum(gradient)) < 1e-12
        h = 1e-5
        for i in range(len(scores)):
            high, low = scores.copy(), scores.copy()
            high[i] += h
            low[i] -= h
            numerical = (objective(high, positives, mode)[0]
                         - objective(low, positives, mode)[0]) / (2 * h)
            checks[f"{mode}_finite_difference_{i}"] = math.isclose(numerical, gradient[i], abs_tol=1e-9)
    checks["single_pushes_down_unmarked_positive"] = results["single"][1][1] > 0
    checks["mass_pushes_up_strong_positive"] = results["positive_mass"][1][0] < 0
    checks["outside_pushes_down_strong_positive"] = results["outside_mean"][1][0] > 0
    checks["outside_pushes_up_weak_positive"] = results["outside_mean"][1][1] < 0
    checks["inside_constant_difference"] = math.isclose(results["inside_mean"][0] - results["positive_mass"][0], math.log(2))
    checks["inside_same_gradient_as_mass"] = results["inside_mean"][1] == results["positive_mass"][1]
    checks["one_positive_same_objective"] = all(math.isclose(objective(scores, (0,), mode)[0], results["single"][0]) for mode in modes)
    checks["all_positive_mass_zero"] = abs(objective(scores, (0, 1, 2), "positive_mass")[0]) < 1e-12
    checks["constant_shift_invariant"] = all(math.isclose(objective([s + 1000 for s in scores], positives, mode)[0], results[mode][0], abs_tol=1e-12) for mode in modes)
    equal = {mode: objective([0.0, 0.0, 0.0], positives, mode) for mode in modes}
    huge = {mode: objective([1e16, 1e16, 1e16], positives, mode) for mode in modes}
    checks["huge_equal_losses"] = all(math.isclose(huge[mode][0], equal[mode][0], abs_tol=1e-12) for mode in modes)
    checks["huge_equal_gradients"] = all(huge[mode][1] == equal[mode][1] for mode in modes)
    checks["huge_equal_probabilities"] = all(huge[mode][2] == [1 / 3] * 3 for mode in modes)
    checks["huge_negative_equal_scores"] = all(objective([-1e16] * 3, positives, mode) == equal[mode] for mode in modes)
    distant = objective([0.0, -1e16, -1e16], (1, 2), "positive_mass")
    checks["distant_positive_cluster"] = distant[1] == [1.0, -0.5, -0.5]
    all_huge = objective([1e16] * 3, (0, 1, 2), "positive_mass")
    checks["huge_all_positive_zero"] = all_huge[0] == 0.0 and all_huge[1] == [0.0] * 3
    assert all(checks.values()), checks
    print("scores=[4, 0, -1]; positives=[0, 1]; scores already include temperature")
    print("probabilities=[" + ", ".join(f"{x:.6f}" for x in results['single'][2]) + "]")
    for mode in modes:
        loss, gradient, _ = results[mode]
        print(f"{mode}: loss={loss:.6f} gradient=[" + ", ".join(f"{g:.6f}" for g in gradient) + "]")
    print(f"inside_minus_mass=ln(2)={math.log(2):.6f}")
    print(f"PASS {len(checks)} checks; free-score derivatives only, no encoder training")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。单个查询和固定正例集合的自由分数损失与导数;未训练编码器,未测真实检索或分类效果。

运行后对照结果
scores=[4, 0, -1]; positives=[0, 1]; scores already include temperature
probabilities=[0.975559, 0.017868, 0.006573]
single: loss=0.024745 gradient=[-0.024441, 0.017868, 0.006573]
positive_mass: loss=0.006595 gradient=[-0.006455, -0.000118, 0.006573]
inside_mean: loss=0.699742 gradient=[-0.006455, -0.000118, 0.006573]
outside_mean: loss=2.024745 gradient=[0.475559, -0.482132, 0.006573]
inside_minus_mass=ln(2)=0.693147
PASS 35 checks; free-score derivatives only, no encoder training

根据任务决定要总量,还是要覆盖每条正例

如果标注表达的是“这些候选任意一条都可以满足目标”,正例集合总概率是一个可解释的目标;如果希望每条已知正例都被认真学习,平均负对数会施加不同的要求。选择仍需结合采样、标注质量和实际评价。

某篇论文在其图像分类实验中比较了这些目标,不构成对所有文本检索任务的胜负保证。尤其不能只凭这个三分数实验,承诺某种损失召回率一定更高。

最后检查两件事:正例集合是否真的完整且符合任务含义;实现究竟把求和、平均和 log 放在哪里。给所有正例贴上同一个标签,还不足以定义同一个训练目标。

记住这三点

  • 多正例集合相同,汇总方式不同,目标和梯度仍可能不同。
  • 正例总概率允许强正例承担大部分概率;平均负对数会约束每条正例。
  • log 内平均比正例总概率损失多 ln|P|,正例集合固定时两者梯度相同。

别把例子用过头

  • 将未标注的候选自动视为事实上的负例。
  • 把 log(mean(p)) 与 mean(log(p)) 当成同一计算。
  • 见到正例梯度为正就认为代码有错。
  • 把不同目标的 loss 数值直接比较,或把分数梯度当成真实编码器的必然更新结果。

自己试一小步

把两条正例分数都设为 2,负例仍为 −1;哪些目标的梯度会相同,损失是否也相同?

  1. 先手算三个候选的 softmax 概率。
  2. 分别代入正例总概率、log 内平均与 log 外平均。
  3. 再只降低第二条正例分数,观察三者开始在哪里分开。

做到这些,就算完成

  • 始终保留同一个候选分母。
  • 分别比较损失值与梯度,别只比较一个。
  • 说明正例数固定与否对加法常数的意义。

查原始资料

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