从问题到小型研究项目
读懂一篇论文以后,最容易冒出来的念头是:“我也想做点什么。”
接着,想法开始长大:换一种网络,找一个新数据集,再试几个优化器,也许还能顺手加上大模型。几天过去,目录里多了十几个脚本,却越来越难回答一个简单问题:这些实验究竟想弄清楚什么?
这一章把范围收拢到一个可以走完的问题。我们不另起一个庞大的项目,而是把第 44~46 章已经做过的工作接起来:选论文、形成问题、实现方法、完成扩展,最后交出一份别人能独立阅读和检查的报告。
项目的主题仍是温度校准。它足够小,可以在普通 CPU 上运行;又足够真实,会遇到数据划分、随机性、指标冲突和不支持预期的结果。研究的基本功,正可以在这样的地方练起来。
1. 一个能做完的问题,通常比一个响亮的题目有用
“改进人工智能的可靠性”是一个方向,还不是我们这周能回答的问题。
沿着前三章,可以一步步把它缩小:
分类器报出的概率可信吗?
已训练的分类器能否通过温度缩放改善概率?
校准数据很少时,拟合出来的温度和保留样本上的损失会怎样变化?
最后一句已经包含几件重要的事:研究对象是固定的分类器,改变的是校准样本量,观察的是温度与未参与拟合的数据上的损失。它允许出现改善、变差或暂时看不清的结果。
本项目围绕两个问题展开:
- 实现问题:能否独立实现温度缩放的核心流程,并在固定环境中重跑得到一致记录?
- 扩展问题:固定分类器与测试池,减少校准样本后,温度和测试 NLL 会怎样变化?
第一个问题主要靠实现检查和重复运行回答;第二个问题需要对照实验。它们都没有预先承诺“我们一定能提出更好的算法”。
完成一项小研究,也不要求结果必须刷新排行榜。复现一个重要步骤、指出特定设置下的失败、把一个过宽的说法缩小,都可以产生有用的信息。至于能否成为新的学术贡献,还需要更充分的文献检索和证据,不能仅凭自己第一次看见这个现象就下结论。
2. 文献检索,要能说明自己找过什么
第 44 章选定了 Guo 等人 2017 年的校准论文。到了扩展阶段,我们继续检索“小校准集”和“温度缩放”,实际使用过的关键词包括:
"temperature scaling" "small" "calibration set" calibration
"temperature scaling" "regularization" calibration sample size
关键词只是入口。搜索摘要能帮助筛选,却不能代替原文。找到一篇可能有关的论文后,继续检查正式来源、版本、研究对象、使用的数据和对应章节。
还可以沿两个方向走:向后找它引用的方法来源,向前找后来如何讨论或修改它。这样做往往比不停换一个更宽泛的关键词更容易找到研究之间的联系。
这次保留了三篇用途不同的材料:
- 方法起点。 Guo 等,On Calibration of Modern Neural Networks,ICML 2017。我们精读温度公式、结果表和补充材料,并核对作者代码。它决定了核心复现做什么。
- 评价方法。 Vaicenavicius 等,Evaluating model calibration in classification,AISTATS 2019。它帮助我们区分校准对象,理解分箱评价不能被一个小数概括掉的限制。
- 相关扩展。 Le Coz、Herbin 与 Adjed,Confidence Calibration of Classifiers with Many Classes,NeurIPS 2024。该文关注大量类别下的置信度校准,提出 Top-versus-All 的处理方式;第 5.4 节还研究了校准样本量。我们查阅了相关部分,但没有运行它的方法。
第三篇尤其提醒我们:“研究校准样本量”不是一个没人碰过的新点子。 它的模型、类别数量、方法和数据规模又不同于我们的十类小网络,因此也不能把它的观察直接搬来当作本次实验结果。
把文献整理成能继续工作的记录,比收集一长串标题更有帮助。本项目的文献记录包含检索日期、实际查询、作者、版本、原文、读过的部分、与当前问题的关系,以及尚未实现的内容。它是一份目标明确的小范围检索记录,不是系统综述,也不足以证明某个想法在全部文献中从未出现。
如果要进一步主张“新方法”,检索就要围绕具体机制展开。例如,给温度加正则项,应该继续查清类似约束、参数化和使用场景,而不能只搜索自己新取的算法名字。
3. 问题、假设和观察,要留下各自的时间
研究问题可以是开放的:“少量校准数据会发生什么?”
可检验的假设更明确:“在指定设置中,20 条校准样本比 100 条更容易得到很差的测试 NLL。”
观察则是运行以后实际得到的计数、曲线和错误。
假设的价值不在于听起来大胆,而在于能说清怎样可能被数据反驳。如果所有方向的结果都能被解释成“支持假设”,那就很难检验。
本项目的真实顺序是:
- 阅读论文时,提出“小校准集是否不稳定”的疑问。
- 完成三个分类器与完整校准集的核心实验,查看了测试结果。
- 在此基础上固定第 46 章的样本量、重复次数和分箱设置,运行探索性扩展。
- 观察到边界解和损失恶化,再提出可能的机制与后续改进方向。
不能把最后一步得到的解释,写成第一步就已固定并受到独立验证的假设。也不能因为扩展方案在扩展代码运行前保存了,就抹掉此前看过测试成绩的事实。
以后设计新项目时,可以提前写下一段简短约定:对象是什么,改变什么,主要指标是什么,采用哪些对照,运行多少次,以及哪些结果会削弱自己的判断。过程中若改变设置,就追加变更原因和时间,而不是把旧计划改写得像一开始就如此。
这种记录不需要很复杂。它只是让未来的自己和合作者分得清:这是事先的预测,还是看到数据以后形成的解释。
4. 把问题拆成一条能走完的实验路线
前三章已经留下了四类可以交付的材料:
图 1:这是本项目已经完成的路径。它的终点是一份有范围、有结果、有原始记录的报告,不要求把下一项研究的问题也提前回答完。
第一步:用一个能手算的例子检查方法
第 44 章的十条手工分数有解析答案,温度约为 1.584963。它让我们先检查概率、NLL 和温度求解,避免在完整训练失败时,一口气怀疑十几个环节。
这一层只证明具体计算与推导相符。不能把手工例子的成功当作真实数据效果。
第二步:做一次完整而可追踪的真实实验
第 45 章从真实 digits 数据训练三个 MLP。训练、选模、校准、测试分别承担不同角色;配置和种子先固定,全部训练曲线、逐条分数及不利结果都保存。
两个独立进程重跑得到相同的数值记录,另一次加载也能重算相同分数。这回答了“我们做的究竟是哪一份实验”,但还没有回答“方法在所有环境中都有效”。
第三步:一次只改变一个关心的条件
第 46 章保持分类器和测试池不变,改变校准样本量。同一次排列的 20、50、100 条子集相互嵌套,三个模型使用相同 ID;完整 269 条每个模型只有一次结果。总共拟合 183 个温度。
这种控制让样本量问题比较容易解释,但结果的依赖关系也必须保留:很多子集重叠,三种子共享测试池。它们不是 183 个独立研究。
第四步:先核对事实,再写报告
最后把“我们想说明什么”和“哪份记录支持它”连起来。例如:
| 想说明的事 | 可以回到哪里检查 |
|---|---|
| 选中了哪一轮模型 | 全部选模曲线与选中轮次 |
| 温度是否改变类别 | 同一批测试分数与准确率 |
| 小子集是否出现失败 | 子集 ID、错误数、温度与 NLL |
| 重跑是否一致 | 两个进程的数值与权重指纹 |
| 观察能否外推 | 数据来源、相关性与未测条件 |
最后一行不会仅靠一个哈希值就得到答案。实验记录可以支持事实追踪;结论的范围仍需要人认真判断。
5. 负面结果,常常比一句“效果一般”更有内容
第 46 章的小样本实验里,有些校准子集一张都没有分错,温度却被推到 0.05 下界,随后让测试 NLL 大幅上升。
这时可以先排查实现:是不是用错标签,温度为负,指标算错,或者混入了测试数据?这些检查通过以后,再问有没有与目标函数一致的解释。
设某条样本的真实类别 严格排在第一位。它与其他类别 的分数间隔为:
把 softmax 的分子分母都除以 ,真实类别概率可写成:
当 变小时,正间隔除以更小的正数,负指数项便变小, 更接近 1。对应的 继续下降。
因此,在所有真实类别都严格领先的校准子集上,NLL 本身就会鼓励更小的温度。这个小集合没有提供“错误时别过分自信”的信号;到了仍有错误的测试集,代价才显现出来。
这是一个由公式支持、与当前记录相符的解释。它不是本书新发现的理论,也没有证明所有小样本失败都只由这个机制导致。
把负面结果写好,至少要留下三个层次:
- 观察:哪些配置、哪些样本下,什么数值发生了变化?
- 解释:什么机制可能造成变化,已有多少证据支持?
- 未解决:还有哪些替代解释或条件没有排除?
例如,“20 条样本时有零错误子集到达下界”是观察;上面的概率推导解释了为什么这种情况可能发生;“换一个数据集是否同样常见”仍是未回答的问题。三者分开,读者才知道下一步能接着做什么。
6. 让报告中的数字,尽量少经过手工搬运
报告最怕一种普通的小错误:训练重新跑过了,正文中的旧表格却没有更新。
本章提供一个只用 Python 标准库的报告生成器。它读取前三章的冻结记录,核对来源关联、代码指纹、选择轮次和子集覆盖,再用第 44 章的独立标准库实现重算测试 NLL、核心指标和逐条差值。子集错误数与汇总计数也从具体记录计算。
python3 src/examples/research_project.py
实际输出:
核对通过:3 次分类器训练记录,183 次温度拟合,1080 对测试损失。
报告中的核心成绩与子集计数均从原始记录生成;未重新训练或拟合温度。
项目结论:完成方法复现与探索性扩展;不宣称原论文基准复制或新方法有效。
1080 对指三个模型各自在同一批 360 张图上的前后损失,并不表示有 1080 张不同测试图。
这个程序没有重新训练分类器,也没有重新运行全部统计实验。它核对已有记录并生成报告;前两章的训练、重新拟合与专项检查仍有自己的职责。程序更不可能判断你的研究问题是否重要,或者文献检索是否已经充分。
要把报告、代码、依赖和原始记录装在一起,导出到一个尚不存在的目录:
python3 src/examples/research_project.py --out runs/my-calibration-study
python3 src/examples/research_project.py --verify-bundle runs/my-calibration-study
第二条命令输出:
资料包文件核对通过:13 个文件与 SHA-256 清单一致。
目录包含 report.md、manifest.json 和 examples/。清单记录报告及实验文件的指纹;它可以发现文件是否相对这份清单发生变化,但不是数字签名,也不能证明数据或结论本身正确。
本书已经实际导出并在另一个目录运行过资料包中的核对程序。你可以直接下载完整资料包,解压后在 calibration-study/ 目录运行 python3 examples/research_project.py。报告也说明怎样另行重训模型;新训练结果不会自动覆盖随附的历史记录。
展开完整报告生成与资料包代码
"""第 47 章:从既有实验记录复核表格,生成研究报告和可复制资料包。只用标准库。"""
from argparse import ArgumentParser
import hashlib
import json
import math
from pathlib import Path
import shutil
import calibration_reading as reference
HERE = Path(__file__).resolve().parent
INPUTS = [
"calibration-reading.json", "calibration-reproduction.json",
"calibration-repeat.json", "calibration-evidence.json",
"calibration-config.json", "calibration-evidence-plan.json",
"calibration-literature.json",
]
SCRIPTS = ["calibration_reading.py", "reproduce_calibration.py",
"calibration_evidence.py", "research_project.py"]
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.read_bytes()).hexdigest()
def require(condition, reason):
if not condition:
raise ValueError(reason)
def close(a, b, reason):
require(math.isclose(a, b, rel_tol=1e-10, abs_tol=1e-10), reason)
def quantile(values, p):
ordered = sorted(values)
position = (len(ordered) - 1) * p
lo = math.floor(position)
hi = math.ceil(position)
return ordered[lo] + (position-lo) * (ordered[hi]-ordered[lo])
def load():
return {name: json.loads((HERE / "data" / name).read_text()) for name in INPUTS}
def audit(records):
training = records["calibration-reproduction.json"]
evidence = records["calibration-evidence.json"]
repeat = records["calibration-repeat.json"]
reading = records["calibration-reading.json"]
require(training["config"] == records["calibration-config.json"], "训练配置不一致")
require(digest(training["config"]) == training["config_sha256"], "训练配置指纹不一致")
require(digest(training["numeric"]) == training["numeric_sha256"], "训练数值记录已变化")
require(evidence["plan"] == records["calibration-evidence-plan.json"], "扩展方案不一致")
require(digest(evidence["plan"]) == evidence["plan_sha256"], "扩展方案指纹不一致")
for name, expected in [
("calibration_reading.py", reading["source_sha256"]),
("reproduce_calibration.py", training["source_sha256"]),
("calibration_evidence.py", evidence["script_sha256"]),
("reproduce_calibration.py", evidence["method_sha256"]),
]:
require(file_hash(HERE / name) == expected, f"代码版本不一致:{name}")
require(file_hash(HERE / "data/calibration-reproduction.json") ==
evidence["source_file_sha256"], "扩展分析引用的原始文件已变化")
require(evidence["source_numeric_sha256"] == training["numeric_sha256"], "实验关联不一致")
require(repeat["same_numeric_sha256"] == training["numeric_sha256"], "重复实验关联不一致")
numeric = training["numeric"]
ids = [i for group in numeric["splits"].values() for i in group]
require(len(ids) == 1797 and set(ids) == set(range(1797)), "四份划分的覆盖或隔离不符")
a, b = training["config"]["hidden"]
require(training["parameters"] == (64+1)*a + (a+1)*b + (b+1)*10, "参数量不一致")
require(evidence["test_ids"] == numeric["splits"]["test"], "扩展测试 ID 不一致")
rows = numeric["results"]
seeds = training["config"]["seeds"]
require([r["seed"] for r in rows] == seeds, "遗漏或重排训练种子")
require(len(repeat["runs"]) == 2, "缺少两次独立运行的记录")
for run in repeat["runs"]:
require(run["numeric_sha256"] == training["numeric_sha256"], "重复运行结果不一致")
require(run["weights"] == [r["weights_sha256"] for r in rows], "重复权重记录不一致")
labels = numeric["labels"]["test"]
for row in rows:
chosen = min(row["curves"], key=lambda r: r["selection_nll"])["epoch"]
require(row["selected_epoch"] == chosen, "选中轮次与规则不一致")
for stage, temperature in [("before", 1), ("after", row["temperature"])]:
metric = reference.metrics(row["logits"]["test"], labels, temperature)
for key in ["nll", "accuracy", "ece"]:
close(metric[key], row["metrics"]["test"][stage][key], f"测试 {key} 不一致")
bootstrap = next(r for r in evidence["full"] if r["seed"] == row["seed"])["bootstrap"]
delta = [reference.nll([z], [y], row["temperature"]) - reference.nll([z], [y])
for z, y in zip(row["logits"]["test"], labels)]
require(len(delta) == len(bootstrap["per_item_delta"]), "逐条差值缺失")
for a, b in zip(delta, bootstrap["per_item_delta"]):
close(a, b, "逐条差值不一致")
close(sum(delta)/len(delta), bootstrap["mean"], "平均差值不一致")
require(len(bootstrap["resampled_means"]) == evidence["plan"]["bootstrap_repeats"],
"重采样次数不一致")
for p, end in zip([.025, .975], bootstrap["interval"]):
close(quantile(bootstrap["resampled_means"], p), end, "分位数区间不一致")
cal = numeric["splits"]["calibration"]
lookup = {value: i for i, value in enumerate(cal)}
plan = evidence["plan"]
expected_count = len(seeds) * (plan["subset_repeats"]*(len(plan["sizes"])-1)+1)
require(len(evidence["runs"]) == evidence["fit_count"] == expected_count, "扩展记录缺失")
combinations = set()
for run in evidence["runs"]:
key = run["seed"], run["size"], run["repeat"]
require(key not in combinations, "扩展记录重复")
combinations.add(key)
use = run["calibration_ids"]
require(len(use) == len(set(use)) == run["size"], "子集大小或唯一性不符")
require(set(use) <= set(cal), "子集出现非校准 ID")
row = next(r for r in rows if r["seed"] == run["seed"])
logits = [row["logits"]["calibration"][lookup[i]] for i in use]
y = [numeric["labels"]["calibration"][lookup[i]] for i in use]
errors = sum(max(range(len(z)), key=z.__getitem__) != label for z, label in zip(logits, y))
require(errors == run["calibration_errors"], "子集错误数不一致")
t = run["temperature"]
lo, hi = plan["temperature_bounds"]
require(lo <= t <= hi, "温度超出预定范围")
require(run["at_boundary"] == (t in [lo, hi]), "边界标记不一致")
close(reference.nll(row["logits"]["test"], labels, t), run["test_nll"], "扩展 NLL 不一致")
close(run["test_nll"] - row["metrics"]["test"]["before"]["nll"],
run["delta_nll"], "扩展差值不一致")
for seed in seeds:
for size in plan["sizes"]:
repeats = 1 if size == len(cal) else plan["subset_repeats"]
require(all((seed, size, r) in combinations for r in range(repeats)), "组合覆盖不足")
return {"training_runs": len(rows), "fits": expected_count,
"test_pairs": len(labels)*len(rows)}
def report_text(records):
training = records["calibration-reproduction.json"]
evidence = records["calibration-evidence.json"]
literature = records["calibration-literature.json"]
config, numeric = training["config"], training["numeric"]
env = training["environment"]
rows = numeric["results"]
lines = [
"### 一项小型温度校准研究:方法复现与样本量探索", "",
f"资料与实验日期:{literature['checked_on']}。研究类型:教学方法复现与探索性扩展。", "",
"#### 摘要", "",
"我们在真实手写数字数据上训练三个小型分类器,独立实现训练后的温度缩放。"
"完整校准集下,三个模型的测试 NLL 均下降,准确率不变;"
"条件性配对区间均跨零,不能据此宣称总体改善已得到确认。"
"减少校准样本后,多次零错误子集把温度推到搜索下界,反而使测试损失大幅上升。"
"本项目没有复制原论文的大规模基准,也没有提出已验证的新校准算法。", "",
"#### 研究问题与方法来源", "",
"问题一:在固定小型分类器上,能否正确实现并重复运行温度缩放?"
"问题二:在固定模型和测试池下,减少校准样本后,拟合温度与测试 NLL 怎样变化?", "",
"方法来自 Guo 等人的 ICML 2017 论文。核心计算是在冻结 logits 后除以共同正温度,"
"只按独立校准标签最小化平均 NLL。第 44 章另用手工例子核对解析温度与反例;"
"手工例子不参与下面的真实数据成绩。", "",
"#### 实验设置", "",
"数据为 E. Alpaydin 与 C. Kaynak(1998)的 "
"[Optical Recognition of Handwritten Digits](https://doi.org/10.24432/C50P49),"
"使用 scikit-learn 1.9.1 随包 1797 条子集,UCI 数据页许可为 CC BY 4.0。"
"像素除以 16;重新划分用于教学,不沿用原始 UCI 基准划分。", "",
"样本数:" + "、".join(f"{name} {len(numeric['splits'][key])}"
for key, name in [("train", "训练"), ("selection", "选模"),
("calibration", "校准"), ("test", "测试")]) + "。"
f"先后分出测试、校准、选模的分层种子为 {config['split_seeds']}。", "",
f"MLP 为 64→{config['hidden'][0]}→{config['hidden'][1]}→10,ReLU,"
f"共 {training['parameters']:,} 个参数。Adam 学习率 {config['learning_rate']},"
f"权重衰减 0,批量 {config['batch_size']},训练 {config['epochs']} 轮,种子 {config['seeds']}。"
"按选模 NLL 最低的轮次保存模型,并列时保留较早轮次。", "",
"校准采用正温度对数参数化和 SciPy 有界一维搜索,范围 "
f"{config['temperature_bounds']},并比较端点与 T=1。"
"训练为 CPU FP32、单线程、确定性算法;离线指标为 float64。"
f"环境:Python {env['python']}、PyTorch {env['torch']}、NumPy {env['numpy']}、"
f"SciPy {env['scipy']}、scikit-learn {env['sklearn']};{env['platform']}。"
"完整构建信息在原始报告中。", "",
"#### 核心实验结果", "",
"| 种子 | 选中轮次 | 温度 |", "| --- | ---: | ---: |",
]
for r in rows:
lines.append(f"| {r['seed']} | {r['selected_epoch']} | {r['temperature']:.4f} |")
lines += ["", "测试平均 NLL:", "", "| 种子 | 原始概率 | 校准后 |", "| --- | ---: | ---: |"]
for r in rows:
a, b = r["metrics"]["test"]["before"], r["metrics"]["test"]["after"]
lines.append(f"| {r['seed']} | {a['nll']:.4f} | {b['nll']:.4f} |")
lines += ["", "准确率与 ECE(15 个等宽区间,均为比例):", ""]
for r in rows:
a, b = r["metrics"]["test"]["before"], r["metrics"]["test"]["after"]
lines.append(f"- 种子 {r['seed']}:准确率 {a['accuracy']:.4f} → {b['accuracy']:.4f};"
f"ECE {a['ece']:.4f} → {b['ece']:.4f}。")
lines += ["", "两个独立训练进程的数值记录与权重内容一致;"
"第 45 章还在另一进程加载自己的权重,重新计算了逐条分数和指标。"
"这是固定环境中的执行证据,不是跨硬件一致性或总体有效性的证明。", "",
"固定模型与完整校准温度,逐张配对重采样 2000 次。测试 NLL 的“校准后减校准前”差值及名义 95% 百分位区间:", ""]
for r in evidence["full"]:
b = r["bootstrap"]
lines.append(f"- 种子 {r['seed']}:{b['mean']:.5f},"
f"[{b['interval'][0]:.5f}, {b['interval'][1]:.5f}]。")
lines += ["", "区间均跨零;这不证明两个方案等价。逐张独立仅为近似,"
"没有按书写者分组,也未严格复现原始分层抽样设计;"
"区间不包含重新训练、校准选择或任务分布变化。", "",
"#### 样本量扩展与负面结果", "",
"从同一 269 条校准池抽取 20/50/100 条,各 20 份。每次排列取前缀,"
"三个模型共用相同 ID;完整 269 条每模型只拟合一次。"
f"共 {evidence['fit_count']} 次拟合,所有结果保留。"
"这项方案是在看过核心实验测试结果之后制定的探索,不是新独立确认。", "",
"测试 NLL 比原始概率更高的次数:", "",
"| 种子 | 20 条 | 50 条 | 100 条 |", "| --- | ---: | ---: | ---: |"]
for seed in config["seeds"]:
values = []
for size in [20, 50, 100]:
group = [r for r in evidence["runs"] if r["seed"] == seed and r["size"] == size]
values.append(f"{sum(r['delta_nll'] > 0 for r in group)}/{len(group)}")
lines.append(f"| {seed} | " + " | ".join(values) + " |")
lines += ["", "20 条子集中的边界结果:", ""]
for seed in config["seeds"]:
group = [r for r in evidence["runs"] if r["seed"] == seed and r["size"] == 20]
zero = sum(r["calibration_errors"] == 0 for r in group)
boundary = sum(r["at_boundary"] for r in group)
lines.append(f"- 种子 {seed}:零错误子集 {zero}/20,边界解 {boundary}/20;"
f"该组最大测试 NLL 为 {max(r['test_nll'] for r in group):.4f}。")
lines += ["", "小集合没有错误时,降低温度会继续改善该集合的 NLL;"
"测试集中的错误却可能因此变得更自信。这个解释与记录相符,"
"但本项目没有把它当作所有失败的唯一机制。"
"固定温度后的 5/10/15/30 箱 ECE 敏感性结果也全部保留;"
"种子 22 在四种设置中均恶化。", "",
"#### 限制、开放问题与结论", "",
"仅一个重新划分的数据子集、三个 MLP、共享测试池;校准子集嵌套且重叠。"
"没有比较其他校准方法,没有检查未见书写者或真实照片,"
"也没有将同一个池里的重复抽样当作独立任务。"
"小样本失败不能推翻原论文在其他设置下的实测结果。", "",
"本项目完成了可重复的方法实现,并找到了值得后续验证的小校准集失败条件。"
"对数温度正则化、不同数据划分与按书写者评价,均可作为下一项研究,"
"但尚未在本项目得到独立验证,也不宣称这些想法新颖。", "",
"#### 资料与复现入口", "",
"资料包的 examples/data 保存原始报告、配置、扩展方案和文献记录;"
"examples 保存完整脚本与固定依赖。manifest.json 记录各文件的 SHA-256。"
"报告生成器复核已有记录和数值,不会重新训练。", "",
"~~~sh",
"python3 examples/research_project.py",
"uv run --python 3.12 --with-requirements examples/requirements.txt \\",
" python examples/reproduce_calibration.py --out runs/retrained",
"uv run --python 3.12 --with-requirements examples/requirements.txt \\",
" python examples/reproduce_calibration.py --verify runs/retrained",
"~~~", "",
"第二、三条命令用于另行训练与加载;默认研究报告仍引用随附的冻结记录,"
"不会把新训练输出自动冒充同一份历史数据。", "",
"参考文献:", ""]
for p in literature["papers"]:
lines.append(f"- {p['authors']}({p['year']}),[{p['title']}]({p['url']})。{p['version']}。")
return "\n".join(lines) + "\n"
def export(folder, records):
folder.mkdir(parents=True, exist_ok=False)
examples = folder / "examples"
(examples / "data").mkdir(parents=True)
paths = [Path(name) for name in SCRIPTS + ["requirements.txt"]]
paths += [Path("data") / name for name in INPUTS]
for relative in paths:
shutil.copyfile(HERE / relative, examples / relative)
(folder / "report.md").write_text(report_text(records))
files = {str(p.relative_to(folder)): file_hash(p)
for p in sorted(folder.rglob("*")) if p.is_file()}
manifest = {"kind": "frozen teaching research bundle; not a new training run",
"files": files}
(folder / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
return manifest
def verify_bundle(folder):
manifest = json.loads((folder / "manifest.json").read_text())
expected = {"report.md"}
expected.update("examples/" + name for name in SCRIPTS + ["requirements.txt"])
expected.update("examples/data/" + name for name in INPUTS)
require(set(manifest["files"]) == expected, "资料包清单不完整")
for name, sha in manifest["files"].items():
require(file_hash(folder / name) == sha, f"资料包文件变化:{name}")
return len(expected)
def main():
parser = ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group()
group.add_argument("--out", type=Path, help="导出到尚不存在的资料包目录")
group.add_argument("--verify-bundle", type=Path, help="核对资料包文件是否与清单一致")
args = parser.parse_args()
if args.verify_bundle:
count = verify_bundle(args.verify_bundle)
print(f"资料包文件核对通过:{count} 个文件与 SHA-256 清单一致。")
return
records = load()
result = audit(records)
if args.out:
export(args.out, records)
print(f"核对通过:{result['training_runs']} 次分类器训练记录,"
f"{result['fits']} 次温度拟合,{result['test_pairs']} 对测试损失。")
print("报告中的核心成绩与子集计数均从原始记录生成;未重新训练或拟合温度。")
print("项目结论:完成方法复现与探索性扩展;不宣称原论文基准复制或新方法有效。")
if __name__ == "__main__":
main()
7. 第十篇实践:交出一份完整的小型研究报告
本篇的实践要求是:选择一篇计算量可控的论文,完成阅读笔记、核心实验复现、一个扩展实验和研究报告。这里选定 Guo 等人的温度缩放,采用小型真实分类器复现方法流程,再做校准样本量扩展。
这份成品可以对应到四项材料:
- 论文阅读与笔记:问题、公式、原文表格、补充材料、作者代码及一个待研究问题。
- 核心方法复现:真实训练、四份数据划分、三个种子、保存加载和独立重跑;写明与原论文基准的差别。
- 扩展实验:固定模型的校准样本量实验、配对区间和分箱敏感性,包括全部失败。
- 下方完整报告与可下载文本:问题、设置、结果、限制、开放问题和复现入口。文本使用 Markdown 语法,保存后可改为
.md打开。
下面不是等待填写的模板,而是由实际记录生成的报告。阅读时可以检查:每个结论是否都找得到对应观察,观察是否又有足够完整的运行条件。
展开完整研究报告
一项小型温度校准研究:方法复现与样本量探索
资料与实验日期:2026-10-20。研究类型:教学方法复现与探索性扩展。
摘要
我们在真实手写数字数据上训练三个小型分类器,独立实现训练后的温度缩放。完整校准集下,三个模型的测试 NLL 均下降,准确率不变;条件性配对区间均跨零,不能据此宣称总体改善已得到确认。减少校准样本后,多次零错误子集把温度推到搜索下界,反而使测试损失大幅上升。本项目没有复制原论文的大规模基准,也没有提出已验证的新校准算法。
研究问题与方法来源
问题一:在固定小型分类器上,能否正确实现并重复运行温度缩放?问题二:在固定模型和测试池下,减少校准样本后,拟合温度与测试 NLL 怎样变化?
方法来自 Guo 等人的 ICML 2017 论文。核心计算是在冻结 logits 后除以共同正温度,只按独立校准标签最小化平均 NLL。第 44 章另用手工例子核对解析温度与反例;手工例子不参与下面的真实数据成绩。
实验设置
数据为 E. Alpaydin 与 C. Kaynak(1998)的 Optical Recognition of Handwritten Digits,使用 scikit-learn 1.9.1 随包 1797 条子集,UCI 数据页许可为 CC BY 4.0。像素除以 16;重新划分用于教学,不沿用原始 UCI 基准划分。
样本数:训练 899、选模 269、校准 269、测试 360。先后分出测试、校准、选模的分层种子为 [45, 46, 47]。
MLP 为 64→128→64→10,ReLU,共 17,226 个参数。Adam 学习率 0.003,权重衰减 0,批量 64,训练 80 轮,种子 [11, 22, 33]。按选模 NLL 最低的轮次保存模型,并列时保留较早轮次。
校准采用正温度对数参数化和 SciPy 有界一维搜索,范围 [0.05, 20.0],并比较端点与 T=1。训练为 CPU FP32、单线程、确定性算法;离线指标为 float64。环境:Python 3.12.12、PyTorch 2.14.1、NumPy 2.5.4、SciPy 1.18.1、scikit-learn 1.9.1;macOS-26.6.2-arm64-arm-64bit。完整构建信息在原始报告中。
核心实验结果
| 种子 | 选中轮次 | 温度 |
|---|---|---|
| 11 | 45 | 1.4727 |
| 22 | 51 | 1.6092 |
| 33 | 42 | 1.5368 |
测试平均 NLL:
| 种子 | 原始概率 | 校准后 |
|---|---|---|
| 11 | 0.1597 | 0.1441 |
| 22 | 0.1081 | 0.1077 |
| 33 | 0.1411 | 0.1296 |
准确率与 ECE(15 个等宽区间,均为比例):
- 种子 11:准确率 0.9583 → 0.9583;ECE 0.0212 → 0.0182。
- 种子 22:准确率 0.9667 → 0.9667;ECE 0.0130 → 0.0239。
- 种子 33:准确率 0.9611 → 0.9611;ECE 0.0284 → 0.0224。
两个独立训练进程的数值记录与权重内容一致;第 45 章还在另一进程加载自己的权重,重新计算了逐条分数和指标。这是固定环境中的执行证据,不是跨硬件一致性或总体有效性的证明。
固定模型与完整校准温度,逐张配对重采样 2000 次。测试 NLL 的“校准后减校准前”差值及名义 95% 百分位区间:
- 种子 11:-0.01558,[-0.04305, 0.00701]。
- 种子 22:-0.00035,[-0.02784, 0.02039]。
- 种子 33:-0.01151,[-0.03965, 0.01129]。
区间均跨零;这不证明两个方案等价。逐张独立仅为近似,没有按书写者分组,也未严格复现原始分层抽样设计;区间不包含重新训练、校准选择或任务分布变化。
样本量扩展与负面结果
从同一 269 条校准池抽取 20/50/100 条,各 20 份。每次排列取前缀,三个模型共用相同 ID;完整 269 条每模型只拟合一次。共 183 次拟合,所有结果保留。这项方案是在看过核心实验测试结果之后制定的探索,不是新独立确认。
测试 NLL 比原始概率更高的次数:
| 种子 | 20 条 | 50 条 | 100 条 |
|---|---|---|---|
| 11 | 12/20 | 8/20 | 3/20 |
| 22 | 20/20 | 16/20 | 9/20 |
| 33 | 18/20 | 8/20 | 0/20 |
20 条子集中的边界结果:
- 种子 11:零错误子集 7/20,边界解 7/20;该组最大测试 NLL 为 2.6033。
- 种子 22:零错误子集 10/20,边界解 10/20;该组最大测试 NLL 为 1.5386。
- 种子 33:零错误子集 9/20,边界解 9/20;该组最大测试 NLL 为 2.1782。
小集合没有错误时,降低温度会继续改善该集合的 NLL;测试集中的错误却可能因此变得更自信。这个解释与记录相符,但本项目没有把它当作所有失败的唯一机制。固定温度后的 5/10/15/30 箱 ECE 敏感性结果也全部保留;种子 22 在四种设置中均恶化。
限制、开放问题与结论
仅一个重新划分的数据子集、三个 MLP、共享测试池;校准子集嵌套且重叠。没有比较其他校准方法,没有检查未见书写者或真实照片,也没有将同一个池里的重复抽样当作独立任务。小样本失败不能推翻原论文在其他设置下的实测结果。
本项目完成了可重复的方法实现,并找到了值得后续验证的小校准集失败条件。对数温度正则化、不同数据划分与按书写者评价,均可作为下一项研究,但尚未在本项目得到独立验证,也不宣称这些想法新颖。
资料与复现入口
资料包的 examples/data 保存原始报告、配置、扩展方案和文献记录;examples 保存完整脚本与固定依赖。manifest.json 记录各文件的 SHA-256。报告生成器复核已有记录和数值,不会重新训练。
python3 examples/research_project.py
uv run --python 3.12 --with-requirements examples/requirements.txt \
python examples/reproduce_calibration.py --out runs/retrained
uv run --python 3.12 --with-requirements examples/requirements.txt \
python examples/reproduce_calibration.py --verify runs/retrained
第二、三条命令用于另行训练与加载;默认研究报告仍引用随附的冻结记录,不会把新训练输出自动冒充同一份历史数据。
参考文献:
- Chuan Guo; Geoff Pleiss; Yu Sun; Kilian Q. Weinberger(2017),On Calibration of Modern Neural Networks。ICML / PMLR 70:1321–1330,会议版正文与补充材料。
- Juozas Vaicenavicius; David Widmann; Carl Andersson; Fredrik Lindsten; Jacob Roll; Thomas Schön(2019),Evaluating model calibration in classification。AISTATS / PMLR 89:3459–3467,会议版。
- Adrien Le Coz; Stéphane Herbin; Faouzi Adjed(2024),Confidence Calibration of Classifiers with Many Classes。NeurIPS 37,会议版。
如果你换一篇论文来做,报告结构可以调整。真正需要保留的是研究问题与证据之间的联系,而不是把这里的标题逐个照抄。
8. 一轮研究结束,可以留下更清楚的下一问
本项目完成后,一个自然的候选想法是:能否让温度不要那么容易走向极端?
例如,把 作为参数,考虑给校准损失加一个惩罚:
是校准集平均 NLL; 控制惩罚强度。 对应 ,所以这个惩罚倾向于让温度靠近不改变原始概率的位置。
这只是一个尚未在本项目执行的候选方案。正则化并非新概念,类似校准约束也有相关研究;我们没有凭这个公式宣称新颖性或有效性。惩罚还可能妨碍本来需要较大温度调整的模型,不能只讲好处。
如果接着研究,可以先在开发数据上选择正则强度,冻结方法、主要指标、预算与失败处理,再在没有用于这些选择的新确认数据上比较。还应让原始概率、无惩罚温度与候选方案使用一致的评价条件。
图 2:这是后续研究设计,不是已经完成的实验结果。本篇已完成的扩展是第 46 章的样本量分析。
这里的“新”,是相对于选择过程而言,不是把同一批看过的测试图片重新命名。若目标是适应新的书写者,就应保留书写者信息,并按这一单位划分、评价和估计不确定性。样本量应根据要识别的变化幅度与先导实验波动来规划;不能因为算得出一个区间,就假定数据已经足够。
也可以走另一个方向:先不改算法,换数据、模型和分组方式,看看现有失败条件是否还能出现。研究不只有“加一个新模块”这一条路。
这一轮已经回答的问题,可以整理成可复查的结果;新的疑问则进入下一轮计划。让两者各就各位,项目才不会永远停在“再试最后一个参数”。
练习与自测
- 把“研究大模型的可靠性”改写成一个对象、干预和评价位置都明确的小问题。
- 检索到一篇标题很相似的论文后,应检查哪些内容,才能判断它与自己的问题是否相同?
- 为什么“扩展方案在扩展代码运行前写好”不等于“整个研究从未看过测试结果”?
- 若真实类别与另一个类别并列最高,上面“温度趋近零时真实类别概率趋近 1”的推导还成立吗?
- 第 46 章种子 22 的 20 条子集全部使测试 NLL 变差。能否据此写“温度缩放无效”?
- 报告生成器核对了 1080 对损失,为什么不能写成“在 1080 张独立测试图上验证”?
- 如果资料包中的报告被改动,清单核对应该怎样响应?如果报告和清单都被改动,哈希值是否还能证明它可信?
- 候选正则项中, 和很大的 分别倾向于怎样的行为?为什么不能直接按旧测试集选择最好的 ?
- 下载资料包,在一个新目录执行核对。任选一条报告结论,找到它在原始 JSON 中的字段,再写一句尚未被它支持的更宽结论。
参考答案与实践提示
- 例如:“固定一个本地问答模型和一组开发文档,仅改变是否加入检索;在独立题目上比较正确引用与无依据回答比例。”还需具体规定模型、数据、提示和评分,避免同时改变多个因素。
- 核对原始来源、版本、输入与任务、数据和类别范围、方法定义、评价指标、实验条件及作者实际支持的结论。标题中都有“校准”,也可能研究不同的校准对象。
- 核心实验的测试结果已经影响我们选择下一步问题与分析方向。扩展设置提前保存有助于避免临时挑结果,但不能消除这段历史;应称为探索,并把独立确认留给新数据。
- 不成立。若有两个并列最大项,且其他项严格更小,温度趋近零时两项各趋近一半。严格领先的条件不能漏掉。
- 不能。可以如实描述固定模型、固定测试池与这 20 份小子集下的失败;它不覆盖其他校准数据、模型或任务,也不推翻原论文已报告的其他实验。
- 三个模型使用相同的 360 张图,只是每张图产生三组比较。不同模型的结果共享样本,不是额外收集了 720 张图。
- 只改报告应触发文件指纹不一致。若报告与清单同时被改动,自洽的哈希不能证明来源真实性;本例没有数字签名,也没有声称解决可信发布问题。
- 为零时不施加该惩罚;很大时会强烈倾向于 接近 0、 接近 1,但具体解仍取决于损失。按已看过的测试集选强度会使测试信息参与开发,应在开发数据上选择,再用新确认数据评价。
- 例如,报告中的小样本变差次数来自
calibration-evidence.json的runs,按seed、size分组统计delta_nll>0。它不支持“所有任务只要用 100 条校准样本就安全”。
延伸阅读
- Guo 等,On Calibration of Modern Neural Networks:本篇的核心方法论文。
- Vaicenavicius 等,Evaluating model calibration in classification:与本项目评价选择直接相关的研究。
- Le Coz 等,Confidence Calibration of Classifiers with Many Classes:检索相关工作时如何比较研究条件的具体例子。
- Pineau 等,Improving Reproducibility in Machine Learning Research:代码、报告与研究流程如何共同帮助复核结果。
本章文献检索与资料核查日期:2026-10-20。研究项目会产生新的问题;继续阅读时,也请保留版本和日期,让旧观察与新证据能够清楚地接上。