如何复现研究结果
上章读完温度缩放,我们已经能算出一个温度,也知道它为什么不会改变最大类别。现在把手工编的十条分数收起来,真的训练一个分类器。
假设你把程序和结果发给朋友。过了一会儿,他回消息:“跑完了,可我这里的数字不一样。”
先别急着问谁的代码错了。你们用的是同一份数据吗?训练了同样多的轮次吗?一个人取最后一轮,另一个人取验证集最好的一轮?所谓“同一个模型”,到底是结构相同,还是连权重也相同?
复现就是把这些容易省略的细节接起来。本章从真实训练出发,保存一份能追溯的实验记录,再在另一个进程里重新训练、加载和评分。最后,我们也会遇到一个值得留下的结果:方法跑通了,某个指标却变差了。
1. 开始之前,先约定要复现到哪一层
“复现成功”至少可能指几件不同的事。
重新执行同一份实验,关心同一份代码、数据和设置是否能产生相同或足够接近的输出。重新实现一个方法,关心自己写的程序是否正确表达了论文中的计算。重做论文的实验比较,还要对齐数据、模型、训练预算、评价方法和对照组。
这几个目标会互相帮助,却不能互相代替。把温度公式算对,并不等于重现了论文表格;同一个程序连续输出相同结果,也可能只是稳定地重复了同一个错误。
我们继续研究 Guo 等人的 《On Calibration of Modern Neural Networks》,这次明确约定:
在一份小型真实分类数据上,独立实现“训练分类器、冻结权重、用保留样本拟合共同正温度、再评价”的完整流程,并检查这份实验能否重复运行。
本章使用手写数字和小型全连接网络,原论文则包含 CIFAR、ImageNet、文本分类等设置。我们没有照搬原论文的数据、模型或训练预算,所以这是采用新设置的方法复现实验。它能检查流程和实现,不能拿自己的分数去宣称重现了论文中某个 ResNet 的成绩。
这样的约定不是把要求放低。恰恰相反,它让“完成了什么”和“还没有验证什么”都有可检查的边界。
2. 先分数据,再碰模型
实验使用 scikit-learn 的 load_digits:1797 张手写数字图片,每张为 ,类别是 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 隐藏层,输出十个类别分数:
每个线性层都带偏置,参数量可以自己检查:
它没有 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. 冻结模型以后,才轮到温度登场
选好分类器,先固定它的所有权重,用校准集得到原始分数。接下来只寻找一个温度:
这里的目标仍是平均负对数似然:
代码实际搜索 ,每次通过 得到正温度。它使用 SciPy 的有界一维最小化,检查求解是否成功,并额外比较两个端点和 。
为什么还要看端点?因为最优值可能落在给定范围的边缘。若最终得到 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 结果的复制。三次训练不能支持对所有分类器或真实使用场景的普遍保证。本章没有比较不同校准样本量、分箱方式或其他校准方法;下一章研究证据会继续考察前两项。
如果结果与论文的方向不同,报告仍可以完整。把数据、模型、实现差异和不确定之处交代清楚,比写一句“可能环境不同”更有用。原论文、作者实现、本书实现和本书观察,是四种材料,引用时不要混成一个来源。
下一章会沿着这个实验继续:怎样选公平的对照,怎样看待波动,以及扩展实验究竟能回答什么。
练习与自测
- 模型结构相同,但一个人用最后一轮,另一个人用选模 NLL 最低的一轮,可以称为同一份实验吗?还缺少什么约定?
- 为什么本例把选模集和校准集分开?如果两者复用,会让哪一种独立性消失?
- 把像素除以 16,与用全部 1797 张图估计均值和方差,有什么区别?
- 手算本章网络的参数量。如果删除两个隐藏层的偏置,总数减少多少?
- 种子 22 的 NLL 下降而 ECE 上升,是否足以判定程序有错?应先做哪些更直接的检查?
- 同样设置种子 11,把批量大小从 64 改为 32,为什么不能期待权重完全相同?
- 两个模型文件的 SHA-256 不同,但按名字排列后的所有张量相同,是否一定出现了模型差异?
- 校准结果恰好为搜索上限 20。可以直接把它写成“最佳温度为 20”而不说明约束吗?
- 亲手完成保存、加载和第二次独立训练,比较两份报告的
numeric_sha256。如果不同,从本章排查表的第一行开始记录,不要先修改测试集或挑另一个种子。
参考答案与实验提示
- 还没有对齐检查点选择规则。至少要约定评价数据、指标、评价频率和并列处理方式;最后一轮与最佳验证轮次可能是不同权重。
- 选模标签影响分类器的选择;校准标签影响温度。复用时,校准样本已经参与过模型选择,它们相对于所选模型不再具有同样的保留性质。并非所有研究都必须使用四份数据,但复用的作用和限制应说明。
- 16 是数据定义已给出的范围上限。用全部样本估计均值方差,则会让保留数据参与预处理参数的估计;应只用训练集拟合,再应用到其他集合。
- 参数量为 17,226;两个隐藏层偏置合计 ,删除后为 17,034。输出层偏置仍保留。
- 不能。先独立检查概率归一化、NLL 计算、温度为正、类别排序、ECE 权重与区间边界,以及是否错误使用了测试标签。NLL 和 ECE 本来就不是同一目标。
- 梯度来自不同批次,每轮更新次数也改变;Adam 的状态和参数轨迹随之变化。相同种子只能固定相关随机过程的起点,不能让不同训练算法变成同一个过程。
- 不一定。文件的包装或元数据可以不同。比较张量名字、形状、数据类型和数值,才能更直接地判断权重内容是否相同。
- 不可以省略约束。应写成“在 的预定范围内,结果位于上界”,并把更宽范围的检查作为另一次有记录的实验,不能假装本次已找到全局无约束最优值。
- 本书固定环境的两份数值指纹一致。自己的环境若不同,先保留失败记录,再比较数据、配置、版本与中间结果;不要为了让一个摘要数字相同而反复调整测试相关设置。
延伸阅读
- Guo 等,On Calibration of Modern Neural Networks,ICML 2017:本章方法来源。模型与数据等实验差异见正文。
- 作者温度缩放实现,固定提交:对照校准目标、评估模式和优化方式,不把旧环境直接当作当前环境。
- PyTorch 2.14:Reproducibility:随机来源、确定性算法与跨环境限制。
- scikit-learn:load_digits 与 UCI 数据来源:辨别本例子集和原始数据设置。
- SciPy:minimize_scalar:理解有界搜索、返回状态和求解限制。
本章接口与实验环境核查日期:2026-10-20。后续重跑应同时保留自己的日期、环境与结果。