Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

如何判断研究证据

上一章的程序能重跑,模型也能重新加载。三个种子的测试 NLL 都下降了。现在可以在报告里写“温度缩放有效”了吗?

先把这句话往桌上一放,问得具体一点:对哪一种模型有效,改善了哪个指标,变化有多大,换一批样本还会不会如此?

研究证据的分量,往往就藏在这些追问里。一张表可以准确记录某次实验,却仍然撑不起很宽的结论。本章继续使用上一章的真实预测记录,亲手给这个结论加几道检查:公平地比,分清波动从哪里来,看看评价方式与数据选择会怎样改变读到的故事。

1. 先给“更好”补上宾语

下面三句话听起来很像,实际要回答的问题不同:

  1. 对同一个分类器,加入温度后,这份测试集的 NLL 更低。
  2. 温度缩放优于其他校准方法。
  3. 温度缩放能让各种真实应用中的概率更可靠。

第一句可以由当前两列预测直接核对。第二句需要其他校准方法作为对照,并给它们合理的调参机会。第三句还需要更多数据、模型和使用条件,尤其要检查分布变化。

我们目前完成的是第一类比较。没有比较向量缩放、等距回归等其他方法,就不能把“比不校准好”升级为“是最好的校准方法”。同样,数字图片上的观察也不会自动变成对医疗图像、语音识别或大模型回答的保证。

这一次,主要量仍是测试集的平均 NLL 差值。准确率用于检查分类结果是否保持不变,ECE 用来观察最高类别置信度的分箱误差。三个指标各自回答一个问题,不互相顶替。

还有一件事必须先交代:上一章的测试结果已经看过了。 本章是在此基础上设计的探索性分析。我们会在运行扩展代码之前固定样本量、重复次数和分箱设置,但这不等于此前从未接触过测试结果,更不等于完成了预注册的独立确认实验。

如果要确认由这一轮启发的新判断,后续应先定方案,再使用没有参与这些选择的新数据。把探索写清楚,不会让探索失去价值;它只是告诉读者,这些证据目前处在哪一步。

2. 公平的基线,要跟问题对得上

基线是用来比较的参照方案。本例的基线很朴素:同一份分类器,温度固定为 T=1T=1,也就是保留原来的概率。

为回答“加上这个温度会改变什么”,我们让下列条件保持一致:

  • 使用同一个训练种子的同一份已选权重;
  • 使用相同的 360 个测试 ID,标签顺序也相同;
  • 使用相同的 NLL 定义、数值精度和 ECE 分箱规则;
  • 校准前后之间,只改变是否应用拟合的共同正温度。

这个比较很适合隔离温度缩放的作用。若一边使用更大的模型、多训练几十轮,再加温度,成绩提高时就很难知道是哪一步起了作用。

不过,“只改变温度”也不表示两边付出的资源完全一样。校准方案使用了 269 条带标签的保留数据,并额外拟合了一个参数;原始概率方案没有利用这些标签来改善预测。我们回答的是已有分类器怎样进行训练后校准,不是“给双方同样多的可用标签,从头设计最优训练方案”。

如果研究问题换了,基线也要跟着换。例如,允许重新训练时,可以比较把这些标签加入训练的方案;比较校准算法时,则应让候选方法获得相同的校准样本,并交代各自的搜索范围和选择规则。本章没有执行这些额外比较。

消融实验,拆的是哪一块

消融是有计划地去掉或替换某个组成部分,观察结果变化。本例把拟合温度替换成 T=1T=1,可以看作去掉校准步骤。

但并不是每个“删掉一块”的实验都同样容易解释。若网络中的某层本来要参与训练,直接在训练好的模型上把它清零,与删除该层后重新训练,是两种不同干预:前者检查当前模型对它的依赖,后者检查简化结构经过训练后的表现。

论文里出现“去掉模块后下降”,先找清楚它怎么去掉、有没有重训、其他部分怎样调整。否则,可能把一次破坏模型输入分布的操作,误读成某种结构具有普遍必要性的证据。

预算,也要说清算到哪儿

两种方法都“跑了 100 步”,不代表计算量相同。模型大小、每步样本数、序列长度和硬件利用率都会改变成本。新方法搜索 100 组超参数,基线只试一组,也不能仅把最后一次训练时间放在表里。

本章沿用上一章的三份分类器与保存的分数,没有重新训练,也没有把读取记录称为重新推理。新增计算是 183 次温度拟合、指标计算和重采样。样本量实验故意改变校准数据量,因为要问“可用校准数据变少会怎样”;这部分并不是同标签预算下的算法竞赛。

3. 先配成对,再谈差多少

用 AA 表示原始概率,BB 表示完整校准集拟合后的概率。对于同一张测试图,第 ii 条 NLL 分别为:

ℓiA=−log⁡pi,yi,ℓiB=−log⁡qi,yi \ell_i^A=-\log p_{i,y_i}, \qquad \ell_i^B=-\log q_{i,y_i}

先逐条相减:

di=ℓiB−ℓiA d_i=\ell_i^B-\ell_i^A

再求平均:

Δ^=1n∑i=1ndi \widehat{\Delta} =\frac{1}{n}\sum_{i=1}^{n}d_i

负值表示校准后的平均 NLL 较低,正值表示较高。这个正负方向先约定好,读图时就不容易反过来。

为什么强调“同一张图”?有的图片容易,有的图片模糊。如果 A 测的是容易图,B 测的是困难图,两个平均值的差便混入了样本难度差异。配对比较让每张图带着校准前后的两份结果一起进入分析。

这里还藏着一个有趣的细节。种子 22 的平均差值约为 −0.00035-0.00035,但在 360 张测试图里,只有 11 张的单条 NLL 下降,另外 349 张上升。温度变大后,很多原本正确而自信的预测会稍稍降低真实类别概率;少数过分自信的错误,则可能得到较大的损失改善。

因此,“平均损失下降”并不等于“每个样本都改善”,也不等于“改善的样本更多”。这不是数学矛盾,而是平均值把不同幅度的变化合在了一起。报告除了平均数,保留逐条损失和具体失败,往往能帮助我们理解改进来自哪里。

4. 这点差异,遇到抽样波动会怎样

这里至少有三种不同的波动:

变化来源本章怎样观察
训练的随机性保留上一章的三个训练种子
校准样本的选择从校准池反复抽取子集
测试样本的组成对配对损失进行重采样

把它们都塞进一个“±”后面,却不说明是哪一种,就很容易让读者以为所有不确定性都已被考虑。

从已有样本里,模拟再抽一次

先固定模型和完整校准温度,只研究测试样本组成的影响。我们把 360 对结果放在一个袋子里,有放回地抽 360 次:同一张图可能抽到多次,某些图则没有抽到。这就是一种 bootstrap(自助重采样)。

每次抽到的索引同时用于 A、B,不能把两种方法各自打乱。若抽到的索引是:

I1,I2,…,In I_1,I_2,\ldots,I_n

这一轮的平均差值为:

Δ^ ∗=1n∑j=1ndIj \widehat{\Delta}^{\,*} =\frac{1}{n}\sum_{j=1}^{n}d_{I_j}

星号表示“由重采样得到”。重复 2000 次,得到 2000 个平均差值,再取它们的 2.5% 和 97.5% 分位数,构成百分位 bootstrap 区间。分位数可先理解成把数排好后,位于指定比例附近的位置;代码明确采用线性插值。

本例用这个直观版本说明过程。它不是所有场景下最准确的区间构造方法;SciPy 文档还提供 basic 和 BCa 等方法。换一种区间算法也不能修补样本本身的代表性问题。

图 1:模型、完整校准温度与测试池固定,按测试样本 ID 配对重采样 2000 次。横线是名义 95% 百分位区间;这里不包含重新训练或重新选择校准数据的波动。

种子 11 的观察差值约为 −0.01558,区间约为 [−0.04305, 0.00701]。另外两个种子的区间也都跨过零。

这说明:在这一重采样分析下,当前记录还不足以把负的样本平均差值当作稳健的改善证据。区间跨零不等于证明两个方案相同。 数据量、少数大损失和区间构造方式,都可能影响这次分析的分辨能力。

这里还要给“95%”加上它原本需要的条件。通常的区间解释依赖样本可以近似视为来自目标分布的独立观测,以及该重采样近似适用。我们的数字图可能来自相同书写者,逐张独立的假设没有得到充分验证,所以本章把这些区间作为在该假设下的条件性分析,没有声称已获得对真实用户总体可靠的 95% 覆盖。

如果一位书写者贡献了很多张图,应考虑按书写者分组抽样;患者、文档、对话等数据也有类似问题。缺少分组信息时,要把限制写出来,而不是让重采样次数掩盖它。

本例原来的测试划分还使用了类别分层,而这里逐张重采样允许每类数量变化。这也是教学近似的一部分,没有严格复制原划分的抽样设计;若研究目标要求固定类别比例,应事先选择相应的分层重采样方案。

此外,2000 次重采样没有创造 2000 份新的测试集。原始信息仍来自那 360 张图。增加重采样次数主要减少计算近似的随机波动,不会自动增加对现实世界的了解。三个种子的区间也是分别计算的,不能直接当作经过多重比较校正的联合结论。

5. 扩展实验:只有少量校准数据时,会发生什么

现在换一种变化来源。分类器和测试集仍固定,只改变用于拟合温度的样本。

实验方案在 calibration-evidence-plan.json 中保存:校准池仍是上一章的 269 条记录;使用 20、50、100 条时,各抽取 20 份子集。完整 269 条只拟合一次。

具体抽法是:每次随机排列这 269 条 ID,取前 20、前 50、前 100 条。因此,同一次排列中的小集合包含在大集合里;三个分类器也使用相同的子集 ID。抽样不放回,没有要求每个小子集都包含十个类别。

这样的安排方便对照“增加了哪些样本”,同时要记住:不同大小的集合相互嵌套,不同重复之间也可能重叠。这些结果不能当成互不相关的 20 份新数据集,更不能把三种子乘二十次写成 60 个独立任务。

图 2:每个点代表一次实际拟合,短横线表示该组中位数。红色点线是搜索下界 0.05,灰色虚线是原始温度 1。点在水平方向稍微错开以减少重叠,没有改变样本量。完整集只有一个结果,不能用它的“零波动”宣称无限稳定。

以是否比原始 T=1T=1 的测试 NLL 更高来计数,结果如下。每一格的分母都是 20 次子集抽取:

训练种子20 条50 条100 条
1112/208/203/20
2220/2016/209/20
3318/208/200/20

在这批记录里,小校准集经常带来更差的测试 NLL。样本量增加后,上表的变差次数减少;但这不是“增加样本必然逐次改善”的证明,也不能据此宣布 100 条对所有任务都足够。

为什么会一路跑到温度下界

看一个具体原因。种子 11 的 20 份小子集中,有 7 份恰好没有分类错误;这 7 次拟合都到达了 T=0.05T=0.05 的下界。种子 22 和 33 分别有 10 次、9 次出现同样情况。

假设一条校准样本的真实类别严格排第一。降低温度会把它与其他类别的差距拉大,让真实类别概率更接近 1,于是这条样本的 NLL 继续下降。如果小集合里条条如此,拟合目标就没有见到“过于自信地猜错”的代价。

可是测试集仍然有错误。温度过低时,这些错误会变得更加自信;真实类别概率被压得更小,NLL 就可能猛涨。种子 11 的某些 20 条子集,使测试平均 NLL 从约 0.1597 升到 2.6033。我们保留了这些结果,没有为了让图表好看而删掉边界解。

这里发现的是一个值得继续研究的失败条件。可以据此提出正则化、温度范围或校准样本选择的新方案,但不应现在挑一个让这份测试集最好看的下界,再把改善当作独立验证。后续方案需要自己的选择数据和新检验。

6. 换一把尺子,读数也可能变化

第 44 章已经说明,NLL 是本例拟合温度的目标,ECE 是另一个评价量。本节进一步固定模型、温度与测试预测,只改变 ECE 的分箱数。

按最高预测概率分箱时,我们其实在做一层汇总。箱子较粗,箱内不同位置的偏差可能被平均掉;箱子较细,每箱样本又可能太少。样本波动与汇总造成的偏差会同时影响读数,不能简单说“箱子越多就越真实”。

Vaicenavicius 等人在 《Evaluating model calibration in classification》中分析了校准评价的这些细节,也区分了最高类别置信度与整条类别概率向量的校准。这里的 ECE 只看最高类别置信度,并没有检验全部类别概率或所有子人群。

图 3:横轴按四个分箱设置排列,连线方便追踪同一个方案;没有重新拟合温度。三幅图使用相同纵轴范围。

种子 22 的结果是:

区间数校准前 ECE校准后 ECE
50.00830.0187
100.01490.0197
150.01300.0239
300.01920.0284

这一次,四种设置都显示它的 ECE 上升;没有出现方向翻转,我们也不编造一个。数值大小和差距则确实改变了。种子 33 在 30 箱时的前后差距很小,而在 10 箱时大得多。

还要看每箱有多少样本。种子 22 校准后的 30 箱中,最高置信度那一箱有 286 条,另有多个非空箱只有 1~2 条。把所有柱子画得一样宽,容易让人忽略证据量的巨大差别。原始报告因此保留每个非空箱的计数、平均置信度和准确率。

好的做法是说明预定的主要评价口径,再透明展示敏感性分析。看完结果,只挑最有利的分箱数、任务子集或随机种子,就会把“选择的运气”包装成“方法的能力”。

7. 数据泄漏与评测偏差,会绕过漂亮的统计公式

假设两个方案都重复跑了十次,误差条很窄。后来才发现,测试图的近重复版本已经出现在训练里。很窄的误差条只能说明它们稳定地利用了这份信息,不能把泄漏变成有效证据。

数据泄漏关心本应不可用的信息,是否以某条路径参与了模型或方案的构建。它不只包括把测试标签直接喂给训练程序。例如:

  • 同一文档、同一个人的近重复记录跨越训练与测试;
  • 预处理或特征选择先在全部数据上拟合,再划分数据;
  • 用未来的信息预测过去,时间顺序与真实用途不一致;
  • 不断查看同一测试集,直到挑到表现最好的提示、温度范围或模型。

Kapoor 与 Narayanan 的相关研究讨论了机器学习科学应用中的多种泄漏与复现问题。这里最值得带走的习惯是:沿着信息流追问“这个决定用到了哪些记录”,而不只检查训练函数有没有一个叫 test_labels 的参数。

本例可以查证四份 ID 不重叠,温度拟合只接触校准标签。但这还没有排除书写者层面的相关性;本章反复分析的是已看过的测试集,也没有因此重新获得一份从未使用的最终检验。

到了大模型,类似问题还会出现在预训练语料、提示示例、检索库与评测题之间。没有找到重复文本,并不能证明从未接触过近似题目;模型答对,也不能单独证明它是在现场推理。评测污染的核查方法见预训练数据工程,应用中的评分与任务覆盖见应用评估与可靠性。

评测偏差的范围更广。只挑擅长的题型、让评审看到方法名称、忽略超时和拒答、只对成功输出评分,都可能让表格偏离实际表现。修复的方向也应具体:固定任务范围,保留失败,对称处理两种方法,让评分者尽可能不知道来源,并核对自动评分与人的判断是否一致。

8. 把实验跑一遍,再把结论写窄一点

本章脚本重新分析上一章保存的真实分数:重新拟合温度、重新计算指标和重采样区间,不重新训练分类器。

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/calibration_evidence.py

完整输出:

固定三份分类器;重新拟合温度 183 次;不增加分类器训练。
完整校准集:测试 NLL 差值与配对百分位区间(负值表示下降)
seed=11:差值 -0.01558,区间 [-0.04305, 0.00701]
seed=22:差值 -0.00035,区间 [-0.02784, 0.02039]
seed=33:差值 -0.01151,区间 [-0.03965, 0.01129]
每个小样本量抽取 20 份;下列为测试 NLL 变差次数:
seed=11:n=20 时 12/20;n=50 时 8/20;n=100 时 3/20
seed=22:n=20 时 20/20;n=50 时 16/20;n=100 时 9/20
seed=33:n=20 时 18/20;n=50 时 8/20;n=100 时 0/20
分箱敏感性:种子 22 的测试 ECE(温度不重新拟合)
bins=5:0.0083 → 0.0187
bins=10:0.0149 → 0.0197
bins=15:0.0130 → 0.0239
bins=30:0.0192 → 0.0284
这是已看过第 45 章测试结果后的探索;抽样区间不覆盖所有研究不确定性。

加上 --out runs/calibration-evidence.json 可保存本次报告;文件必须尚不存在。随书的原始报告保留全部子集 ID、类别计数、错误数、边界解、温度、测试指标,以及每张图的 NLL 差值和 2000 个重采样均值,没有只保留摘要。

若单独下载,需要把本章脚本与上一章方法模块放在同一目录,在其 data/ 下保存第 45 章报告和本章方案,并安装固定依赖。输入报告与方法代码的指纹会在运行时核对。

展开完整证据分析代码
"""第 46 章:重新分析真实预测记录,不重新训练分类器。"""

from argparse import ArgumentParser
import json
from pathlib import Path
import platform
import time
import numpy as np
import scipy
from scipy.special import logsumexp
import reproduce_calibration as method

HERE = Path(__file__).resolve().parent
SOURCE = HERE / "data/calibration-reproduction.json"
PLAN = HERE / "data/calibration-evidence-plan.json"


def losses(logits, labels, temperature=1.0):
    z, y = method.checked(logits, labels, temperature)
    return logsumexp(z, axis=1) - z[np.arange(len(y)), y]


def paired_bootstrap(before, after, indices):
    """同一个索引同时抽取一对结果;保留逐条差值,不把两组拆开。"""
    a, b = np.asarray(before), np.asarray(after)
    if a.ndim != 1 or a.shape != b.shape or len(a) == 0:
        raise ValueError("两种方法需要同一批、按相同 ID 排列的逐条损失")
    delta = b - a
    means = delta[indices].mean(axis=1)
    low, high = np.quantile(means, [.025, .975], method="linear")
    return {"mean": float(delta.mean()), "interval": [float(low), float(high)],
            "per_item_delta": delta.tolist(), "resampled_means": means.tolist()}


def subset_orders(ids, repeats, seed):
    rng = np.random.default_rng(seed)
    return [rng.permutation(len(ids)) for _ in range(repeats)]


def brief(values):
    a = np.asarray(values)
    return {"median": float(np.median(a)), "min": float(a.min()),
            "max": float(a.max())}


def source_checks(source):
    if method.digest(source["numeric"]) != source["numeric_sha256"]:
        raise ValueError("第 45 章数值记录与指纹不一致")
    if method.file_hash(method.__file__) != source["source_sha256"]:
        raise ValueError("第 45 章方法代码已变化,请先核对实验版本")
    groups = list(source["numeric"]["splits"].values())
    ids = [i for group in groups for i in group]
    if len(ids) != len(set(ids)):
        raise ValueError("数据划分中出现重复 ID")


def run(source, plan):
    source_checks(source)
    start = time.perf_counter()
    numeric = source["numeric"]
    cy = np.asarray(numeric["labels"]["calibration"], dtype=int)
    ty = np.asarray(numeric["labels"]["test"], dtype=int)
    cal_ids, test_ids = numeric["splits"]["calibration"], numeric["splits"]["test"]
    if (sorted(set(plan["sizes"])) != plan["sizes"] or
            plan["sizes"][0] < 1 or plan["sizes"][-1] != len(cy)):
        raise ValueError("样本量必须递增且最后包含完整校准集")
    orders = subset_orders(cal_ids, plan["subset_repeats"], plan["subset_seed"])
    bootstrap_ids = np.random.default_rng(plan["bootstrap_seed"]).integers(
        len(ty), size=(plan["bootstrap_repeats"], len(ty)))
    runs, full, sensitivity = [], [], []
    for original in numeric["results"]:
        seed = original["seed"]
        cz = np.asarray(original["logits"]["calibration"])
        tz = np.asarray(original["logits"]["test"])
        base_loss = losses(tz, ty)
        for size in plan["sizes"]:
            # 完整 269 条只有同一份集合,不冒充 20 次不同的抽样。
            chosen = [np.arange(len(cy))] if size == len(cy) else [p[:size] for p in orders]
            for repeat, positions in enumerate(chosen):
                t = method.fit_temperature(cz[positions], cy[positions],
                                           plan["temperature_bounds"])
                after = method.metrics(tz, ty, t, bins=15)
                runs.append({
                    "seed": seed, "size": size, "repeat": repeat,
                    "calibration_ids": [cal_ids[i] for i in positions],
                    "class_counts": np.bincount(cy[positions], minlength=10).tolist(),
                    "calibration_errors": int((cz[positions].argmax(1) != cy[positions]).sum()),
                    "temperature": t, "at_boundary": t in plan["temperature_bounds"],
                    "fit_nll_before": method.nll(cz[positions], cy[positions]),
                    "fit_nll_after": method.nll(cz[positions], cy[positions], t),
                    "test_nll": after["nll"], "test_ece": after["ece"],
                    "test_accuracy": after["accuracy"],
                    "delta_nll": float(after["nll"] - base_loss.mean()),
                })
                if size == len(cy):
                    # 独立重新拟合;应与上一章保存的完整校准温度一致。
                    if abs(t - original["temperature"]) > 1e-8:
                        raise ValueError("完整校准温度与第 45 章不一致")
                    pair = paired_bootstrap(base_loss, losses(tz, ty, t), bootstrap_ids)
                    full.append({"seed": seed, "temperature": t, "bootstrap": pair})
                    for bins in plan["ece_bins"]:
                        sensitivity.append({
                            "seed": seed, "bins": bins,
                            "before": method.metrics(tz, ty, 1, bins),
                            "after": method.metrics(tz, ty, t, bins)})
    summaries = []
    for seed in [r["seed"] for r in numeric["results"]]:
        for size in plan["sizes"]:
            group = [r for r in runs if r["seed"] == seed and r["size"] == size]
            summaries.append({
                "seed": seed, "size": size, "count": len(group),
                "temperature": brief([r["temperature"] for r in group]),
                "delta_nll": brief([r["delta_nll"] for r in group]),
                "worse_count": sum(r["delta_nll"] > 0 for r in group),
                "boundary_count": sum(r["at_boundary"] for r in group),
                "zero_error_count": sum(r["calibration_errors"] == 0 for r in group)})
    report = {
        "plan": plan, "plan_sha256": method.digest(plan),
        "source_file_sha256": method.file_hash(SOURCE),
        "source_numeric_sha256": source["numeric_sha256"],
        "script_sha256": method.file_hash(__file__),
        "method_sha256": method.file_hash(method.__file__),
        "environment": {"python": platform.python_version(), "numpy": np.__version__,
                        "scipy": scipy.__version__, "platform": platform.platform()},
        "test_ids": test_ids,
        "subset_orders": [[cal_ids[i] for i in order] for order in orders],
        "bootstrap_indices_sha256": method.digest(bootstrap_ids.tolist()),
        "runs": runs, "summaries": summaries, "full": full,
        "bin_sensitivity": sensitivity,
        "fit_count": len(runs), "seconds": time.perf_counter() - start}
    return report


def show(report):
    print(f"固定三份分类器;重新拟合温度 {report['fit_count']} 次;不增加分类器训练。")
    print("完整校准集:测试 NLL 差值与配对百分位区间(负值表示下降)")
    for row in report["full"]:
        b = row["bootstrap"]
        print(f"seed={row['seed']}:差值 {b['mean']:.5f},"
              f"区间 [{b['interval'][0]:.5f}, {b['interval'][1]:.5f}]")
    print("每个小样本量抽取 20 份;下列为测试 NLL 变差次数:")
    for seed in [r["seed"] for r in report["full"]]:
        groups = [r for r in report["summaries"] if r["seed"] == seed and r["count"] > 1]
        print(f"seed={seed}:" + ";".join(
            f"n={r['size']} 时 {r['worse_count']}/{r['count']}" for r in groups))
    print("分箱敏感性:种子 22 的测试 ECE(温度不重新拟合)")
    for r in report["bin_sensitivity"]:
        if r["seed"] == 22:
            print(f"bins={r['bins']}:{r['before']['ece']:.4f} → {r['after']['ece']:.4f}")
    print("这是已看过第 45 章测试结果后的探索;抽样区间不覆盖所有研究不确定性。")


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--out", type=Path)
    args = parser.parse_args()
    report = run(json.loads(SOURCE.read_text()), json.loads(PLAN.read_text()))
    if args.out:
        args.out.parent.mkdir(parents=True, exist_ok=True)
        # 留下旧结果:输出文件若存在,要求另选路径。
        with args.out.open("x") as stream:
            stream.write(json.dumps(report, indent=2) + "\n")
    show(report)


if __name__ == "__main__":
    main()

现在再回头看开头那句“温度缩放有效”,我们可以写得更准确:

在固定 digits 划分、三个已训练 MLP 和完整 269 条校准数据上,观察到测试 NLL 都下降,但条件性配对重采样区间均跨零,尚不能据此作稳健的总体改善判断。小校准子集可能把温度推到下界,并大幅增加这份测试集的 NLL;ECE 的大小也受分箱设置影响。本次分析属于探索,结论仍需在独立数据与更多模型上检验。

这段话没有让实验白做。它给出了下一步更清楚的问题:哪些校准样本不足的情形最危险?温度约束能否在新数据上改善稳定性?按书写者分组以后,观察是否还保持?

结论外推,就是把当前观察推向尚未检验的条件。每多走一步,都要问新增了什么假设。从固定种子到其他种子,从同一数据集到新的书写者,再到手机照片或另一个任务,所需证据逐步增加。一个范围清楚的负面结果,也能帮我们排除不可靠的设计。

下一章会把论文阅读、方法复现和这次扩展实验整理成一个完整的小型研究项目:既交代实际发现,也把尚未回答的问题留在合适的位置。

练习与自测

  1. 对照组使用原始概率,新方案使用更大的分类器加温度。若新方案更好,能否把差异归因于温度?怎样补充对照?
  2. 为什么本章要使用同一张图的两份损失组成一对?如果每张图的差值都为 −0.1,配对重采样的均值会怎样?
  3. 某差值区间为 [−0.03, 0.01],能否据此证明两个方法等价?为什么?
  4. 把 bootstrap 次数从 2000 增加到 200000,是否相当于收集了更多测试图片?
  5. 为什么 269 条完整校准集只报告一次,而不是换 20 个排列后称为 20 次独立结果?
  6. 小校准子集里没有分类错误,为什么可能得到很小的温度,却在测试集上损失很大?
  7. 温度和预测都没变,ECE 却因分箱数量而改变。这是在重新训练模型吗?应该怎样报告?
  8. 三个模型共享同一测试集,各有一个名义 95% 区间。能否直接宣称“三个结论同时正确的概率为 95%”?
  9. 复现结果稳定,但同一书写者的图片跨越了数据划分。这个稳定性结果还能支持什么,不能自动支持什么?
  10. 用本章原始报告找出种子 22、20 条校准样本时温度最小的一次,核对它的校准错误数、温度和测试 NLL。然后写一句只描述这次观察、不外推到所有任务的结论。
参考答案与实验提示
  1. 不能区分模型变化与温度变化。至少对同一分类器分别比较有无温度;若要研究模型大小,还应在对应设置下保留相同的训练与评价规则,另行说明计算预算。
  2. 配对保留样本难度与两种结果之间的关系。若每个差值都恰好为 −0.1,任意配对重采样的平均仍是 −0.1;独立打乱两种损失会破坏这一性质。
  3. 不能。跨零说明当前区间未排除零,也没有排除区间内其他差异。等价判断需要预先定义可接受差异范围,并使用适合该问题的方法与足够的数据。
  4. 不相当。只是把已有数据上的重采样近似算得更细,原始样本信息没有增加,也没有补上缺失的人群或场景。
  5. 在本例按整份校准集计算目标时,排列不改变其统计内容。把同一集合重复计算并不能得到新的数据选择证据。程序只保留每个模型的一次完整集拟合。
  6. 在真实类别严格排第一的样本上,降低温度会提高真实类别概率,使校准 NLL 下降;小样本可能没有展示过分自信的错误。到了含错误的测试集,过低温度会把部分真实类别概率压得很小,造成很大损失。
  7. 没有重新训练,也没有重新拟合温度,只是改变汇总尺度。应报告分箱定义、区间数、每箱计数,并透明展示预定的敏感性分析,不能事后只选最有利的数。
  8. 不能。频率学区间本来就不是对已算出结论赋予这样的概率;多个分别构造的区间也不自动提供联合覆盖保证,且这些结果共享样本。
  9. 它支持相同设置能被重复执行,仍不自动证明对未见书写者的泛化。应检查研究目标是否需要按书写者划分,并在具备信息时按相关单位评价与重采样。
  10. 多次结果达到并列最小温度 0.05,这些子集的校准错误数为 0,测试 NLL 约为 1.5386,而原始约为 0.1081。可以写“在这份固定划分与抽到的零错误小子集中,拟合到下界后测试 NLL 明显上升”,不能写“温度缩放总会失败”。

延伸阅读

实验与接口核查日期:2026-10-20。所有图均由本书保存的实验记录绘制,没有使用论文图表代替本次测量。