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

如何复现研究结果

上章读完温度缩放,我们已经能算出一个温度,也知道它为什么不会改变最大类别。现在把手工编的十条分数收起来,真的训练一个分类器。

假设你把程序和结果发给朋友。过了一会儿,他回消息:“跑完了,可我这里的数字不一样。”

先别急着问谁的代码错了。你们用的是同一份数据吗?训练了同样多的轮次吗?一个人取最后一轮,另一个人取验证集最好的一轮?所谓“同一个模型”,到底是结构相同,还是连权重也相同?

复现就是把这些容易省略的细节接起来。本章从真实训练出发,保存一份能追溯的实验记录,再在另一个进程里重新训练、加载和评分。最后,我们也会遇到一个值得留下的结果:方法跑通了,某个指标却变差了。

1. 开始之前,先约定要复现到哪一层

“复现成功”至少可能指几件不同的事。

重新执行同一份实验,关心同一份代码、数据和设置是否能产生相同或足够接近的输出。重新实现一个方法,关心自己写的程序是否正确表达了论文中的计算。重做论文的实验比较,还要对齐数据、模型、训练预算、评价方法和对照组。

这几个目标会互相帮助,却不能互相代替。把温度公式算对,并不等于重现了论文表格;同一个程序连续输出相同结果,也可能只是稳定地重复了同一个错误。

我们继续研究 Guo 等人的 《On Calibration of Modern Neural Networks》,这次明确约定:

在一份小型真实分类数据上,独立实现“训练分类器、冻结权重、用保留样本拟合共同正温度、再评价”的完整流程,并检查这份实验能否重复运行。

本章使用手写数字和小型全连接网络,原论文则包含 CIFAR、ImageNet、文本分类等设置。我们没有照搬原论文的数据、模型或训练预算,所以这是采用新设置的方法复现实验。它能检查流程和实现,不能拿自己的分数去宣称重现了论文中某个 ResNet 的成绩。

这样的约定不是把要求放低。恰恰相反,它让“完成了什么”和“还没有验证什么”都有可检查的边界。

2. 先分数据,再碰模型

实验使用 scikit-learn 的 load_digits:1797 张手写数字图片,每张为 8×88\times8,类别是 0~9,像素值为 0~16。它来自 E. Alpaydin 与 C. Kaynak(1998)的 UCI 手写数字数据,数据页标示 CC BY 4.0;这里使用的是该接口提供的子集,重新划分用于教学,没有沿用 UCI 原始训练与测试方案。

这一回,把数据分成四份:

名称张数用来决定什么
训练集899更新分类器权重
选模集269选择哪一轮的模型
校准集269拟合温度
测试集360最后评价固定方案

选模集就是专门用于选择模型的验证数据。增加这个名字,是为了避免把“验证集”三个字同时用在选模型和拟合温度上,读着读着就分不清谁用过哪些标签了。

图 1:四份数据按原始样本 ID 分开。箭头表示实验次序,不表示同一批样本依次进入四个角色。

代码先用种子 45 分出 360 张测试图,再从剩余样本中用种子 46 分出 269 张校准图,最后用种子 47 分出 269 张选模图。每一步都按类别分层抽样,使各份数据的类别比例尽量接近。分层不保证每类张数完全相同。

保留 ID 比只记“八二分”更可靠。数据文件一旦换了排序,相同比例甚至相同随机种子,也可能切出不同样本。报告因此同时保存原始数据指纹、四份 ID 和对应标签。

我们把像素除以 16。这里的 16 来自数据定义,没有偷看整份数据后再估计均值或标准差。如果改成标准化,均值和标准差应只在训练集上拟合,然后固定用于其他三份数据。

四份 ID 不重叠也不是所有问题都解决了。例如,同一个人的多张相似笔迹仍可能分散到不同集合。这个小实验不能证明模型能同样适应未见过的书写者,更不能代表手机拍照中的真实数字识别难度。

3. 把“我大概是这样跑的”写成配置

模型很小:把图片摊成 64 个数,经过两层 ReLU 隐藏层,输出十个类别分数:

64⟶128⟶64⟶10 64\longrightarrow128\longrightarrow64 \longrightarrow10

每个线性层都带偏置,参数量可以自己检查:

P=(64+1)×128+(128+1)×64+(64+1)×10=17 226 \begin{aligned} P&=(64+1)\times128\\ &\quad +(128+1)\times64\\ &\quad +(64+1)\times10\\ &=17\,226 \end{aligned}

它没有 Dropout、数据增强或预训练权重。优化器为 Adam,学习率 0.003,权重衰减为 0;每批 64 张,最后不足 64 张的批次也参与训练。每个种子固定训练 80 轮。

训练种子在看测试结果之前就定为 11、22、33。三个种子都报告,没有先跑很多次再留下最顺眼的一次。数据划分对三次训练保持不变,所以这里观察的是同一划分下训练随机性的影响。

完整配置已经放在 calibration-config.json:

{
  "seeds": [11, 22, 33],
  "split_seeds": [45, 46, 47],
  "test_size": 360,
  "calibration_size": 269,
  "selection_size": 269,
  "hidden": [128, 64],
  "epochs": 80,
  "batch_size": 64,
  "learning_rate": 0.003,
  "temperature_bounds": [0.05, 20.0],
  "ece_bins": 15
}

配置记录的是可调整的设置。固定行为也要能查到,例如 Adam 的选择、是否打乱批次、像素除以 16、损失取平均,都写在代码和本章里。单独一个配置文件不会神奇地补齐所有实验细节。

最后一轮不一定是留下来的那一轮

每轮结束,模型切到评估模式,分别计算整个训练集和选模集的平均 NLL。我们选择选模集 NLL 最低的那一轮;如果数值完全相同,保留更早的轮次。分类器的选择完全不看校准集和测试集。

程序仍然跑满 80 轮,用于保留完整曲线。这是“训练完成后选择检查点”,并没有在最佳轮次提前停止训练。

图 2:每个点都来自真实训练。纵轴使用对数刻度,便于同时看到前期下降和后期小损失的变化;曲线是每轮结束时整份数据的平均损失,不是最后一个批次的损失。

以种子 11 为例,第 45 轮的选模 NLL 约为 0.0529;第 80 轮约为 0.0600。最后一轮的训练损失更低,但按事先约定的规则,我们留下第 45 轮。

看到这条曲线后再把规则改成“取最后一轮”,就等于改变了实验。修改规则当然可以成为后续研究,只是要另开记录,说明修改发生在什么时候。

4. 冻结模型以后,才轮到温度登场

选好分类器,先固定它的所有权重,用校准集得到原始分数。接下来只寻找一个温度:

T⋆=arg min⁡0.05≤T≤20 Lcal(T) T^\star=\underset{0.05\le T\le20} {\operatorname{arg\,min}}\ L_{\mathrm{cal}}(T)

这里的目标仍是平均负对数似然:

Lcal(T)=−1ncal∑i=1ncallog⁡qi,yi(T) L_{\mathrm{cal}}(T) =-\frac{1}{n_{\mathrm{cal}}} \sum_{i=1}^{n_{\mathrm{cal}}} \log q_{i,y_i}(T)

代码实际搜索 s=log⁡Ts=\log T,每次通过 T=exp⁡(s)T=\exp(s) 得到正温度。它使用 SciPy 的有界一维最小化,检查求解是否成功,并额外比较两个端点和 T=1T=1。

为什么还要看端点?因为最优值可能落在给定范围的边缘。若最终得到 0.05 或 20,要把“碰到搜索边界”写进报告,而不是装作已经找到不受约束的最佳温度。本次三个温度都位于区间内部。

这是一个实现差异:上章查看的作者代码直接优化温度参数,使用 LBFGS;我们使用预先限定的正区间和一维求解器。两者都以校准 NLL 为目标,但不能仅凭目标名字相同,就说优化过程完全相同。前章可手算的十条记录,在这里仍应得到约 1.584963,可用来检查新的求解实现。

计算 NLL 时,程序从分数出发使用稳定的 logsumexp,避免先把很小的概率舍入成零,再去算 log(0)。训练使用 CPU FP32;离线校准与评分把保存的分数转成 NumPy float64。这种精度区别也属于实验设置。

5. 跑起来,留下全部结果

在仓库根目录运行:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/reproduce_calibration.py \
  --out runs/calibration-a

输出目录必须尚不存在,避免覆盖旧实验。只想先运行一遍时,可以去掉 --out;程序仍会真实训练三次,只是不保存文件。

也可以下载 完整脚本、配置和依赖清单。单独下载时,把配置放在脚本旁的 data/calibration-config.json,或通过 --config 配置路径 指定它。

本次实际输出如下:

样本数:train=899 / selection=269 / calibration=269 / test=360
MLP 参数量:17226;每个种子训练 80 轮
seed=11:选第 45 轮,T=1.473;准确率 0.958 → 0.958;NLL 0.1597 → 0.1441;ECE 0.0212 → 0.0182
seed=22:选第 51 轮,T=1.609;准确率 0.967 → 0.967;NLL 0.1081 → 0.1077;ECE 0.0130 → 0.0239
seed=33:选第 42 轮,T=1.537;准确率 0.961 → 0.961;NLL 0.1411 → 0.1296;ECE 0.0284 → 0.0224
温度只使用校准标签;三个种子全部保留。

箭头前后都使用同一批 360 张测试图和同一份分类器权重,区别只有是否应用拟合的温度。准确率以比例表示;ECE 也以比例表示,0.02 相当于 2 个百分点。ECE 使用 15 个等宽区间,边界约定与上一章相同。

图 3:连接线只表示同一次训练的校准前后变化,不表示中间温度的曲线。两幅图的纵轴范围不同,且均未从零开始;请按数值比较变化大小。

先看一个预期内的现象:三次准确率都不变。正温度保持类别排序,程序的观察与上一章的推导一致。

再看一个没那么整齐的现象:种子 22 的测试 NLL 只下降了约 0.00035,ECE 却从约 0.0130 上升到 0.0239。我们不能把这么小的 NLL 差异直接写成“显著提升”,更不能删掉不配合结论的 ECE。

这不一定是实现错误。温度按校准集 NLL拟合,没有直接优化测试 ECE。两项指标衡量的对象不同,ECE 还受分箱和有限样本影响。在种子 11 的校准集上,NLL 下降的同时,ECE 也从约 0.0200 上升到了 0.0247;即使没有换数据,两项指标也不必一起下降。

这里只能说:在这份固定划分和三个预先指定的训练种子上,我们观察到了这些变化。三次训练共享相同测试样本,它们不是从所有可能任务中抽来的三份独立证据。怎样设计更有说服力的对照、估计波动,下一章继续讨论。

展开完整实验代码
"""第 45 章:真实训练、独立校准、保存及重新加载。CPU,约数秒至几十秒。"""

from argparse import ArgumentParser
import copy
import hashlib
import json
import math
from pathlib import Path
import platform
import time

import numpy as np
import scipy
from scipy.optimize import minimize_scalar
from scipy.special import logsumexp
import sklearn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
import torch
from torch import nn
from torch.nn import functional as F

HERE = Path(__file__).resolve().parent
DEFAULT_CONFIG = HERE / "data/calibration-config.json"


def digest(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"),
                                     allow_nan=False).encode()).hexdigest()


def file_hash(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


def state_hash(state):
    h = hashlib.sha256()
    for name, tensor in sorted(state.items()):
        a = tensor.detach().cpu().contiguous().numpy()
        h.update(json.dumps([name, list(a.shape), a.dtype.str]).encode())
        h.update(a.tobytes())
    return h.hexdigest()


def configure():
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)


def load_data(config):
    x, y = load_digits(return_X_y=True)
    raw_hash = digest({"pixels": x.astype(int).tolist(), "labels": y.tolist()})
    pool = np.arange(len(y))
    splits = {}
    for role, seed in zip(["test", "calibration", "selection"], config["split_seeds"]):
        pool, splits[role] = train_test_split(
            pool, test_size=config[role + "_size"],
            stratify=y[pool], random_state=seed)
    splits["train"] = pool
    # 16 是数据定义给出的像素上限,不从全部样本估计归一化统计量。
    return (torch.tensor(x / 16, dtype=torch.float32),
            torch.tensor(y, dtype=torch.long), splits, raw_hash)


def make_model(config):
    a, b = config["hidden"]
    return nn.Sequential(nn.Linear(64, a), nn.ReLU(), nn.Linear(a, b),
                         nn.ReLU(), nn.Linear(b, 10))


def checked(logits, labels, temperature=1.0):
    z, y = np.asarray(logits, dtype=np.float64), np.asarray(labels)
    if (z.ndim != 2 or len(z) == 0 or z.shape[1] < 2 or
            y.shape != (len(z),) or not np.issubdtype(y.dtype, np.integer) or
            not np.isfinite(z).all() or np.any(y < 0) or np.any(y >= z.shape[1]) or
            not math.isfinite(temperature) or temperature <= 0):
        raise ValueError("需要有限分数、合法整数标签和正温度")
    return (z - z.max(axis=1, keepdims=True)) / temperature, y


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


def metrics(logits, labels, temperature=1.0, bins=15):
    z, y = checked(logits, labels, temperature)
    p = np.exp(z - logsumexp(z, axis=1, keepdims=True))
    prediction, confidence = z.argmax(axis=1), p.max(axis=1)
    correct = prediction == y
    bucket = np.clip(np.ceil(confidence * bins).astype(int) - 1, 0, bins - 1)
    groups = []
    for m in range(bins):
        use = bucket == m
        if use.any():
            groups.append({"bin": m, "count": int(use.sum()),
                           "confidence": float(confidence[use].mean()),
                           "accuracy": float(correct[use].mean())})
    return {"nll": nll(logits, labels, temperature),
            "accuracy": float(correct.mean()), "confidence": float(confidence.mean()),
            "ece": sum(g["count"] / len(y) * abs(g["accuracy"] - g["confidence"])
                       for g in groups), "bins": groups}


def fit_temperature(logits, labels, bounds):
    low, high = bounds
    if not (0 < low < high and math.isfinite(high)):
        raise ValueError("温度区间必须有限且为正")
    objective = lambda s: nll(logits, labels, math.exp(s))
    result = minimize_scalar(objective, bounds=(math.log(low), math.log(high)),
                             method="bounded", options={"xatol": 1e-10})
    if not result.success:
        raise RuntimeError(result.message)
    # 显式比较端点;也保留区间内的 T=1,避免数值误差输给“不校准”。
    candidates = [low, high, math.exp(result.x)]
    if low <= 1 <= high:
        candidates.append(1.0)
    return float(min(candidates, key=lambda t: nll(logits, labels, t)))


def train_one(config, seed, x_train, y_train, x_selection, y_selection):
    """接口只接触训练和选模数据,校准与测试数据不进入训练。"""
    torch.manual_seed(seed)
    generator = torch.Generator().manual_seed(seed + 1000)
    model = make_model(config)
    initial_hash = state_hash(model.state_dict())
    optimizer = torch.optim.Adam(model.parameters(), lr=config["learning_rate"],
                                 weight_decay=0)
    curves, best, best_loss, first_order = [], None, math.inf, None
    for epoch in range(1, config["epochs"] + 1):
        model.train()
        order = torch.randperm(len(y_train), generator=generator)
        if first_order is None:
            first_order = digest(order.tolist())
        for ids in order.split(config["batch_size"]):
            optimizer.zero_grad(set_to_none=True)
            F.cross_entropy(model(x_train[ids]), y_train[ids]).backward()
            optimizer.step()
        model.eval()
        with torch.no_grad():
            train_loss = F.cross_entropy(model(x_train), y_train).item()
            selection_loss = F.cross_entropy(model(x_selection), y_selection).item()
        curves.append({"epoch": epoch, "train_nll": train_loss,
                       "selection_nll": selection_loss})
        if selection_loss < best_loss:
            best_loss, best_epoch = selection_loss, epoch
            best = copy.deepcopy(model.state_dict())
    model.load_state_dict(best)
    return model, {"seed": seed, "selected_epoch": best_epoch, "curves": curves,
                   "initial_sha256": initial_hash, "first_order_sha256": first_order,
                   "weights_sha256": state_hash(best)}


def environment():
    return {"python": platform.python_version(), "platform": platform.platform(),
            "machine": platform.machine(), "numpy": np.__version__,
            "scipy": scipy.__version__, "sklearn": sklearn.__version__,
            "torch": torch.__version__, "torch_build": torch.__config__.show(),
            "device": "cpu", "dtype": "float32", "threads": torch.get_num_threads(),
            "deterministic": torch.are_deterministic_algorithms_enabled()}


def run(config, out=None):
    configure()
    if out is not None:
        out.mkdir(parents=True, exist_ok=False)
        (out / "config.json").write_text(json.dumps(config, indent=2) + "\n")
    start = time.perf_counter()
    x, y, splits, raw_hash = load_data(config)
    tr, sel = splits["train"], splits["selection"]
    results, artifacts = [], {}
    for seed in config["seeds"]:
        model, row = train_one(config, seed, x[tr], y[tr], x[sel], y[sel])
        row["logits"] = {}
        model.eval()
        with torch.no_grad():
            for role in ["selection", "calibration", "test"]:
                row["logits"][role] = model(x[splits[role]]).tolist()
        cal = row["logits"]["calibration"]
        cy = y[splits["calibration"]].tolist()
        row["temperature"] = fit_temperature(cal, cy, config["temperature_bounds"])
        row["metrics"] = {}
        for role in ["calibration", "test"]:
            labels = y[splits[role]].tolist()
            row["metrics"][role] = {
                stage: metrics(row["logits"][role], labels, t, config["ece_bins"])
                for stage, t in [("before", 1.0), ("after", row["temperature"])]}
        if out is not None:
            name = f"seed-{seed}.pt"
            torch.save(model.state_dict(), out / name)
            artifacts[name] = file_hash(out / name)
        results.append(row)
    numeric = {"splits": {k: v.tolist() for k, v in splits.items()},
               "labels": {k: y[v].tolist() for k, v in splits.items()},
               "results": results}
    report = {"config": config, "config_sha256": digest(config),
              "source_sha256": file_hash(__file__), "data_sha256": raw_hash,
              "environment": environment(), "parameters": sum(p.numel() for p in model.parameters()),
              "numeric_sha256": digest(numeric), "numeric": numeric,
              "artifacts": artifacts, "seconds": time.perf_counter() - start}
    if out is not None:
        (out / "report.json").write_text(json.dumps(report, indent=2) + "\n")
    return report


def verify(folder):
    """只加载本例生成的可信 state_dict;重新计算分数,拒绝静默不一致。"""
    configure()
    report = json.loads((folder / "report.json").read_text())
    config = json.loads((folder / "config.json").read_text())
    if config != report["config"] or digest(config) != report["config_sha256"]:
        raise ValueError("配置不一致")
    if file_hash(__file__) != report["source_sha256"]:
        raise ValueError("源代码已变化,请保留原版本再核对")
    if digest(report["numeric"]) != report["numeric_sha256"]:
        raise ValueError("数值记录已变化")
    x, y, splits, raw_hash = load_data(config)
    if raw_hash != report["data_sha256"]:
        raise ValueError("数据版本不一致")
    for role, ids in splits.items():
        if (ids.tolist() != report["numeric"]["splits"][role] or
                y[ids].tolist() != report["numeric"]["labels"][role]):
            raise ValueError("数据划分或标签不一致")
    for row in report["numeric"]["results"]:
        name = f"seed-{row['seed']}.pt"
        if file_hash(folder / name) != report["artifacts"][name]:
            raise ValueError("模型文件已变化")
        state = torch.load(folder / name, map_location="cpu", weights_only=True)
        if state_hash(state) != row["weights_sha256"]:
            raise ValueError("权重内容不一致")
        model = make_model(config)
        model.load_state_dict(state)
        model.eval()
        with torch.no_grad():
            for role in ["selection", "calibration", "test"]:
                if model(x[splits[role]]).tolist() != row["logits"][role]:
                    raise ValueError("重新推理的分数不完全一致;请检查环境")
        cal = row["logits"]["calibration"]
        t = fit_temperature(cal, y[splits["calibration"]].tolist(),
                            config["temperature_bounds"])
        if t != row["temperature"]:
            raise ValueError("重新拟合的温度不一致")
        for role in ["calibration", "test"]:
            for stage, value in [("before", 1.0), ("after", t)]:
                actual = metrics(row["logits"][role], y[splits[role]].tolist(),
                                 value, config["ece_bins"])
                if actual != row["metrics"][role][stage]:
                    raise ValueError("重新评分不一致")
    return report


def show(report):
    n = report["numeric"]
    print("样本数:" + " / ".join(f"{k}={len(n['splits'][k])}"
                              for k in ["train", "selection", "calibration", "test"]))
    print(f"MLP 参数量:{report['parameters']};每个种子训练 {report['config']['epochs']} 轮")
    for row in n["results"]:
        a, b = row["metrics"]["test"]["before"], row["metrics"]["test"]["after"]
        print(f"seed={row['seed']}:选第 {row['selected_epoch']} 轮,T={row['temperature']:.3f};"
              f"准确率 {a['accuracy']:.3f} → {b['accuracy']:.3f};"
              f"NLL {a['nll']:.4f} → {b['nll']:.4f};ECE {a['ece']:.4f} → {b['ece']:.4f}")
    print("温度只使用校准标签;三个种子全部保留。")


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--out", type=Path, help="保存到尚不存在的目录")
    group.add_argument("--verify", type=Path, help="从本例输出目录加载并核对")
    args = parser.parse_args()
    if args.verify:
        report = verify(args.verify)
        print("核对通过:数据、划分、权重、逐条分数、温度和指标一致。")
    else:
        config = json.loads(args.config.read_text())
        report = run(config, args.out)
    show(report)


if __name__ == "__main__":
    main()

6. 随机种子是起点,不是护身符

随机数生成器会按内部状态依次产生数。设种子,相当于约定一个起点。但如果中途多取了几个数,后面拿到的数就会不同。

本例用 torch.manual_seed(seed) 控制参数初始化;批次顺序则使用一个独立的 torch.Generator,种子为训练种子加 1000。这样,批次打乱不会意外消耗初始化所用的同一条随机数序列。数据划分又由另外三个固定种子控制。

这里没有随机数据增强,也没有多进程加载器。换成更复杂的实验,还要追踪 Python、NumPy、加载器工作进程、数据增强以及采样过程各自的随机来源。只在文件开头写一句设种子,并不会自动管住所有来源。

此外,程序启用:

torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)

单线程便于缩小这个 CPU 教学实验的差异来源;确定性算法选项则要求相关算子使用可用的确定性实现,某些无法满足的情况会报错。它不保证更换硬件、操作系统或库版本后仍然逐位相同。PyTorch 的复现说明也明确区分随机数控制与算子的确定性,并说明跨版本、平台和设备的限制。

随书实验记录使用 Python 3.12.12、PyTorch 2.14.1、NumPy 2.5.4、SciPy 1.18.1、scikit-learn 1.9.1,运行于 macOS ARM64、CPU、单线程。报告还保存完整平台字符串和 PyTorch 构建信息。

版本号相同很有帮助,但仍不是对所有机器的数学保证。若要承诺更宽范围的兼容性,就要在那些环境里实际运行,并事先说明允许多大的数值差异。

7. 保存以后,真的重新打开一次

输出目录里有:

calibration-a/
├── config.json
├── report.json
├── seed-11.pt
├── seed-22.pt
└── seed-33.pt

.pt 文件保存选中轮次的权重。报告保存温度、曲线、选中轮次、各份样本 ID、标签、选模/校准/测试的逐条原始分数,以及校准前后的指标和分箱计数。它还记录代码、数据、配置和权重的 SHA-256 指纹。

把指纹想成文件或内容的一张简短“身份证”就好。内容变了,指纹通常也会变;不过指纹只能帮我们判断是不是同一份内容,不能证明内容正确、数据干净或结论可信。

模型文件的字节指纹和权重内容指纹分别保存。两个文件即使包装不同,也可能装着相同的张量;因此,比较权重时,代码按名字排序,连同形状、数据类型和实际数值计算指纹。比较完整数值结果时,也使用固定键排序的 JSON 表示。

现在退出训练进程,用一条新命令加载:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/reproduce_calibration.py \
  --verify runs/calibration-a

核对程序会重新读取数据,重建划分和模型,加载权重,再计算逐条分数、温度与指标。本次独立进程检查通过,开头输出:

核对通过:数据、划分、权重、逐条分数、温度和指标一致。

随后会再打印前面的三行实验结果。加载使用 weights_only=True,对象是本例自己生成的权重字典;不要把任意来源的模型文件当作可信输入。这里保存的是推理检查点,没有优化器和随机数的完整训练状态,因此没有提供断点续训功能。

接着,用完全相同的命令训练到另一个新目录,例如 runs/calibration-b。本书实际在两个独立进程中运行了这一步,两次的划分、全部训练曲线、权重内容、分数、温度和指标都精确一致。完整比较记录见 calibration-repeat.json,第一次的原始报告见 calibration-reproduction.json。

两次运行用时当然不必一样。报告里的计时也没有混进数值结果指纹,否则电脑被其他任务占用一下,就会被误报为“结果不一致”。本例的计时从加载数据前开始,到训练、评分和保存权重之后结束,不包含 Python 启动、导入依赖以及最后写入报告文件的时间;它只是运行记录,不是硬件性能比较。

真有差异时,从最早分叉的地方找

别盯着最终准确率猜原因。两条实验路径可能很早就分开了:

最早发现不同的地方先检查什么
原始数据指纹数据版本、内容和顺序
四份样本 ID划分种子、数量、分层规则
初始权重网络结构、初始化、随机调用
第一轮样本顺序批次生成器与打乱方式
第一轮损失预处理、损失归约、优化器
选中的模型选模指标、并列规则、保存时机
分数相同,指标不同温度、精度、标签顺序、分箱

代码保存初始权重和第一轮样本顺序的指纹,就是为了把排查往前移。若直到最终指标才发现差异,排查范围会大得多。

如果只差很小的浮点尾数,也不要先把阈值放宽到“让它通过”。先记录绝对差和相对差,定位来源,再判断它会不会改变轮次选择、类别或结论。本例的 --verify 使用严格相等,适合核对本章固定环境;在别的环境失败时会报出差异,而不会悄悄替你接受它。

8. 给这次实验写一份能独立阅读的报告

报告不需要从“安装依赖很顺利”开始。读者首先想知道,你检验了什么,做了哪些决定,实际看到了什么。

本次实验可以这样写:

问题。 在小型手写数字分类器上,能否实现并重复运行训练后的温度缩放流程?三个预定种子的测试 NLL 与 ECE 会怎样变化?

方法。 使用 1797 条 digits 样本,固定分成训练、选模、校准、测试四份。训练 17,226 参数的 MLP;每个种子训练 80 轮,按选模 NLL 选择检查点,再只按校准 NLL 拟合一个共同正温度。完整配置与逐条分数随报告保存。

观察。 三个种子的分类准确率均保持不变,测试 NLL 均下降,但种子 22 的变化很小;测试 ECE 有两次下降、一次上升。两个独立训练进程的数值记录精确一致,另一个加载进程重算分数与指标也一致。

范围。 这是一份固定数据划分上的方法复现,不是原论文 CIFAR 或 ImageNet 结果的复制。三次训练不能支持对所有分类器或真实使用场景的普遍保证。本章没有比较不同校准样本量、分箱方式或其他校准方法;下一章研究证据会继续考察前两项。

如果结果与论文的方向不同,报告仍可以完整。把数据、模型、实现差异和不确定之处交代清楚,比写一句“可能环境不同”更有用。原论文、作者实现、本书实现和本书观察,是四种材料,引用时不要混成一个来源。

下一章会沿着这个实验继续:怎样选公平的对照,怎样看待波动,以及扩展实验究竟能回答什么。

练习与自测

  1. 模型结构相同,但一个人用最后一轮,另一个人用选模 NLL 最低的一轮,可以称为同一份实验吗?还缺少什么约定?
  2. 为什么本例把选模集和校准集分开?如果两者复用,会让哪一种独立性消失?
  3. 把像素除以 16,与用全部 1797 张图估计均值和方差,有什么区别?
  4. 手算本章网络的参数量。如果删除两个隐藏层的偏置,总数减少多少?
  5. 种子 22 的 NLL 下降而 ECE 上升,是否足以判定程序有错?应先做哪些更直接的检查?
  6. 同样设置种子 11,把批量大小从 64 改为 32,为什么不能期待权重完全相同?
  7. 两个模型文件的 SHA-256 不同,但按名字排列后的所有张量相同,是否一定出现了模型差异?
  8. 校准结果恰好为搜索上限 20。可以直接把它写成“最佳温度为 20”而不说明约束吗?
  9. 亲手完成保存、加载和第二次独立训练,比较两份报告的 numeric_sha256。如果不同,从本章排查表的第一行开始记录,不要先修改测试集或挑另一个种子。
参考答案与实验提示
  1. 还没有对齐检查点选择规则。至少要约定评价数据、指标、评价频率和并列处理方式;最后一轮与最佳验证轮次可能是不同权重。
  2. 选模标签影响分类器的选择;校准标签影响温度。复用时,校准样本已经参与过模型选择,它们相对于所选模型不再具有同样的保留性质。并非所有研究都必须使用四份数据,但复用的作用和限制应说明。
  3. 16 是数据定义已给出的范围上限。用全部样本估计均值方差,则会让保留数据参与预处理参数的估计;应只用训练集拟合,再应用到其他集合。
  4. 参数量为 17,226;两个隐藏层偏置合计 128+64=192128+64=192,删除后为 17,034。输出层偏置仍保留。
  5. 不能。先独立检查概率归一化、NLL 计算、温度为正、类别排序、ECE 权重与区间边界,以及是否错误使用了测试标签。NLL 和 ECE 本来就不是同一目标。
  6. 梯度来自不同批次,每轮更新次数也改变;Adam 的状态和参数轨迹随之变化。相同种子只能固定相关随机过程的起点,不能让不同训练算法变成同一个过程。
  7. 不一定。文件的包装或元数据可以不同。比较张量名字、形状、数据类型和数值,才能更直接地判断权重内容是否相同。
  8. 不可以省略约束。应写成“在 [0.05,20][0.05,20] 的预定范围内,结果位于上界”,并把更宽范围的检查作为另一次有记录的实验,不能假装本次已找到全局无约束最优值。
  9. 本书固定环境的两份数值指纹一致。自己的环境若不同,先保留失败记录,再比较数据、配置、版本与中间结果;不要为了让一个摘要数字相同而反复调整测试相关设置。

延伸阅读

本章接口与实验环境核查日期:2026-10-20。后续重跑应同时保留自己的日期、环境与结果。