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

F. 实验配置、日志与复现报告

几天前跑出的一个好结果,今天却找不到是哪次运行产生的,这种事并不罕见。文件夹里可能有 final、final2 和 really-final,但没有一个名字告诉我们:当时用了什么数据,为什么选中这组参数?

记录不必一开始就做成庞大的平台。先让一个很小的实验留下完整痕迹,往往更容易把习惯建立起来。

1. 从导读的打印任务开始

这次仍使用导读中的十二条人工打印记录:六条训练、三条验证、三条测试。它们不是实测数据,恰好适合在普通 Python 环境中迅速检查整个过程。

程序先遍历 11 种斜率与 41 种截距,用训练 MSE 选择线性参数;再用验证 MAE 比较均值基线和线性模型。候选顺序提前写定,遇到并列时保留最先出现者。最终方案固定以后,才读取测试目标并生成逐条预测。

这与导读的算法相同,新增的是可下载的配置、完整日志和核对入口。它不是一次新的真实打印机测量,也没有为得到更漂亮的记录而更改数据。

2. 配置是约定,日志是发生过的事

配置模板是一份可以直接运行的 JSON 文件。它写明实验名称、候选参数、训练指标、选择指标和并列规则。改候选范围,就是改变实验方案,应当生成新的运行目录。

配置里没有“最好结果应是多少”。结果属于执行之后的记录。

同样,日志也不该只保存最终获胜的一行。本例会留下全部 451 组训练候选,以及两项验证比较,共 453 条事件。即使某个候选很差,它也属于已经花过的搜索预算。

日志采用 JSONL:每行是一个独立 JSON 对象,便于逐条读取或追加。下面是实际第一条事件:

{"event": "candidate", "step": 0, "split": "train", "a": 0.0, "b": 0, "metric": "mse_seconds_squared", "value": 5917.166666666667}

字段里既写指标,也写数据部分与单位。这样以后看到一个 1.333,就不会猜不出它是秒、秒的平方,还是分类准确率。

运行最终会产生这些文件:

文件它回答什么
config.json事先采用了哪些设置?
data.json实际用了哪些人工样本和划分?
environment.json用什么 Python、系统和代码文件运行?
metrics.jsonl全部候选与验证比较发生了什么?
predictions.json最终模型对每条测试样本预测多少?
report.json选择结果、主要指标与局限是什么?
manifest.json前六份文件相对这份清单有没有改变?

这里的时间是保存记录时的 UTC 时间;本例没有测量运行耗时。代码指纹也保存在环境记录中。若实验存在随机性,还应分别记录数据划分、初始化、采样等种子;本例是确定顺序的穷举,没有为了“看起来完整”而编一个无用的随机种子。

图 F-1:报告是记录的汇总。一个文件名或一个成功状态,不能替代这条联系。

3. 实际生成一份记录

脚本只用 Python 标准库,Python 3.9 或更高版本即可运行。默认模式在临时目录实际计算并核对,结束后自动清理:

python3 src/examples/experiment_records.py

需要保留结果时,指定一个尚不存在的目录:

python3 src/examples/experiment_records.py --out runs/my-printing-record
python3 src/examples/experiment_records.py --verify runs/my-printing-record

两条命令在本例中都得到:

完成 451 组训练参数计算;日志包含全部候选和两项验证选择。
选定 linear:a=3.0,b=20.0。
测试 MAE:2.000 秒。
六份文件的指纹、逐条日志、模型选择与测试预测均已核对。

--verify 先比较文件指纹,再从保存的数据和配置重新计算这个小实验,逐条核对日志、选择和预测。它不会重新收集打印数据,也不验证人工例子能否代表真实设备。

如果要改搜索范围,复制配置模板,编辑后传入:

python3 src/examples/experiment_records.py \
  --config my-config.json --out runs/my-new-search

程序拒绝覆盖已有目录。这样,第二次尝试不会把第一次的失败抹掉。输入验证或计算失败时,不会生成新的实验目录;若在写文件途中被打断,可能留下不完整目录,应该保留状态并排查,不能只凭目录存在就称为成功。

随书提供实际生成的完整记录包。解压后进入 recorded-printing,运行 python3 experiment_records.py --verify run。包里有同一份脚本和七份运行文件,不需要安装额外依赖。

这份公开记录使用 Python 3.9.6、macOS ARM64 生成;另在本书 Python 3.12 环境中核对相同计算。不同代码版本会触发指纹检查,先确认修改原因,不要为了“通过”直接删掉检查。

展开完整记录脚本
"""附录 F:实际运行打印算例,生成配置、JSONL 日志和可复核报告;仅标准库。"""

from argparse import ArgumentParser
from datetime import datetime, timezone
import hashlib
import json
from math import isfinite
from pathlib import Path
import platform
import tempfile


CONFIG = {
    "experiment_id": "printing-grid-v1",
    "slopes": [i / 2 for i in range(11)],
    "intercepts": list(range(41)),
    "training_metric": "mse_seconds_squared",
    "selection_metric": "validation_mae_seconds",
    "tie_break": "first_in_declared_order",
}
DATA = {
    "train": [[5, 37], [10, 47], [15, 66], [20, 81], [25, 92], [30, 112]],
    "validation": [[8, 43], [18, 76], [28, 103]],
    "test": [[12, 58], [22, 85], [26, 101]],
}
HERE = Path(__file__).resolve()


def encode(value):
    return json.dumps(value, ensure_ascii=False, sort_keys=True,
                      indent=2, allow_nan=False) + "\n"


def sha(data):
    return hashlib.sha256(data).hexdigest()


def score(rows, a, b, squared=False):
    errors = [a * x + b - y for x, y in rows]
    return sum(e * e if squared else abs(e) for e in errors) / len(errors)


def calculate(config, data):
    if set(config) != set(CONFIG):
        raise ValueError("配置字段不完整或包含未知字段")
    for field in ["training_metric", "selection_metric", "tie_break"]:
        if config[field] != CONFIG[field]:
            raise ValueError("本示例只实现固定的指标与并列规则")
    if not config["slopes"] or not config["intercepts"]:
        raise ValueError("候选参数不能为空")
    for name in ["slopes", "intercepts"]:
        if not isinstance(config[name], list) or any(
            isinstance(v, bool) or not isinstance(v, (int, float)) or not isfinite(v)
            for v in config[name]
        ):
            raise ValueError("候选参数必须是有限数值的列表")
    # 先完成训练与验证选择,再读取测试行。
    train, validation = data["train"], data["validation"]
    logs, best, best_loss = [], None, float("inf")
    for a in config["slopes"]:
        for b in config["intercepts"]:
            loss = score(train, a, b, squared=True)
            logs.append(dict(event="candidate", step=len(logs), split="train",
                             a=a, b=b, metric="mse_seconds_squared", value=loss))
            if loss < best_loss:
                best, best_loss = (a, b), loss
    baseline = (0., sum(y for _, y in train) / len(train))
    candidates = [("mean", baseline), ("linear", best)]
    selected, selected_score = None, float("inf")
    for name, (a, b) in candidates:
        value = score(validation, a, b)
        logs.append(dict(event="selection", split="validation", model=name,
                         a=a, b=b, metric="mae_seconds", value=value))
        if value < selected_score:
            selected, selected_score = (name, a, b), value
    name, a, b = selected
    predictions = [dict(sample_id=f"test-{i}", pages=x, target_seconds=y,
                        prediction_seconds=a * x + b)
                   for i, (x, y) in enumerate(data["test"])]
    report = dict(status="completed", data_kind="synthetic_teaching_example",
                  search_count=len(config["slopes"]) * len(config["intercepts"]),
                  selected_model=name, a=a, b=b,
                  validation_mae_seconds=selected_score,
                  test_mae_seconds=score(data["test"], a, b),
                  limitations=["人工教学数据,不是打印机实测",
                               "无随机训练,未提供人群泛化或区间保证",
                               "记录与重算不等于独立研究复现"])
    return logs, predictions, report


def write_run(directory, config):
    # 所有输入验证与计算成功后才创建输出,不覆盖旧实验。
    logs, predictions, report = calculate(config, DATA)
    directory.mkdir(parents=True, exist_ok=False)
    payload = {"config.json": config, "data.json": DATA,
               "predictions.json": predictions, "report.json": report,
               "environment.json": {
                   "python": platform.python_version(), "platform": platform.platform(),
                   "recorded_at_utc": datetime.now(timezone.utc).isoformat(),
                   "code_sha256": sha(HERE.read_bytes()),
                   "randomness": "none: exhaustive grid search",
               }}
    for name, value in payload.items():
        (directory / name).write_text(encode(value), encoding="utf-8")
    # 每行一条完整 JSON,按事件顺序保存,末尾保留换行。
    (directory / "metrics.jsonl").write_text(
        "".join(json.dumps(row, ensure_ascii=False, allow_nan=False) + "\n" for row in logs),
        encoding="utf-8",
    )
    files = {p.name: sha(p.read_bytes()) for p in sorted(directory.iterdir())}
    (directory / "manifest.json").write_text(encode(files), encoding="utf-8")
    return report


def verify_run(directory):
    manifest = json.loads((directory / "manifest.json").read_text())
    expected = {"config.json", "data.json", "environment.json", "metrics.jsonl",
                "predictions.json", "report.json"}
    if set(manifest) != expected:
        raise ValueError("资料清单不完整")
    for name in expected:
        if sha((directory / name).read_bytes()) != manifest[name]:
            raise ValueError(f"文件指纹不符:{name}")
    env = json.loads((directory / "environment.json").read_text())
    if env["code_sha256"] != sha(HERE.read_bytes()):
        raise ValueError("核对程序与记录中的版本不同")
    config = json.loads((directory / "config.json").read_text())
    data = json.loads((directory / "data.json").read_text())
    logs, predictions, report = calculate(config, data)
    saved_logs = [json.loads(line) for line in (directory / "metrics.jsonl").read_text().splitlines()]
    if logs != saved_logs or predictions != json.loads((directory / "predictions.json").read_text()):
        raise ValueError("逐条日志或预测与重算不一致")
    if report != json.loads((directory / "report.json").read_text()):
        raise ValueError("报告与重算不一致")
    return report


def main():
    parser = ArgumentParser(description=__doc__)
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--out", type=Path, help="创建新的实验目录")
    group.add_argument("--verify", type=Path, help="检查现有目录,重新计算小实验")
    parser.add_argument("--config", type=Path, help="新实验的 JSON 配置")
    args = parser.parse_args()
    if args.verify and args.config:
        parser.error("--verify 使用记录自己的配置,不能另传 --config")
    config = json.loads(args.config.read_text()) if args.config else CONFIG
    if args.verify:
        report = verify_run(args.verify)
    elif args.out:
        write_run(args.out, config)
        report = verify_run(args.out)
    else:
        with tempfile.TemporaryDirectory() as temporary:
            output = Path(temporary) / "example"
            write_run(output, config)
            report = verify_run(output)
    print(f'完成 {report["search_count"]} 组训练参数计算;日志包含全部候选和两项验证选择。')
    print(f'选定 {report["selected_model"]}:a={report["a"]:.1f},b={report["b"]:.1f}。')
    print(f'测试 MAE:{report["test_mae_seconds"]:.3f} 秒。')
    print("六份文件的指纹、逐条日志、模型选择与测试预测均已核对。")


if __name__ == "__main__":
    main()

4. 有一份好日志,还需要写清楚结论

把本例压缩成一份小报告,可以写成:

我们在十二条人工打印记录上比较均值基线与线性模型。线性模型的参数只按六条训练样本上的 MSE 从 451 组候选中选取;两种模型再按三条验证样本的 MAE 比较。验证 MAE 分别为 21.167 秒和 1.333 秒,选择了斜率 3、截距 20 的线性模型。三条保留测试记录上的 MAE 为 2 秒。所有候选、验证比较和测试预测均有记录。这说明程序在当前算例中完成了约定的选择流程,不能证明真实打印设备上也有同样误差。

这段话没有把训练参数选择与方案选择混在一起,也没有把三条测试记录扩大成一类真实用户。

对于自己的项目,可以下载复现报告模板,另存为 Markdown 后填写。它包含问题、事先约定、数据与实现、全部运行、结果、范围和复核入口。模板明确标为“尚未填写、尚未执行”,空白处不是已经完成的实验。

更大的完成实例在第十篇研究报告:真实训练、三种子、样本量扩展与失败分析都有各自记录。这里的简单格式可以作为起点,但不必把所有项目硬塞进同一组字段。

5. 记录哪些内容,取决于你准备支持哪一句话

如果说“恢复后继续训练与不中断一致”,只保存权重不够,还需要优化器、调度器、随机数与数据位置等状态。如果说“服务延迟下降”,就需要请求、预热、并发、时间边界和逐次测量,而不只是某次截图。

反过来,保存很多文件,也不意味着证据已经充分。本例的文件指纹只能相对清单发现变化。有人同时改了数据、报告和清单,仍可能制造彼此一致的文件。来源、实验设计和结论范围仍需要另外判断。

日志中也不要随手塞进访问凭据或与实验无关的个人资料。记录输入来源、受控 ID 和必要统计,通常比整份复制生产环境更容易分享和复核。

练习

  1. 为什么不能把验证集最好的参数直接叫作“测试最优参数”?
  2. 把配置中的候选范围改窄,应该覆盖旧目录还是创建新目录?为什么?
  3. 修改公开记录包中报告的一个数字,运行核对,观察它在哪一步拒绝。
  4. 如果报告和指纹清单都被改动,核对通过是否证明研究结论真实?
  5. 给自己的一个小实验填写模板,保留一条不支持预期的结果,并写明它限制了哪一句结论。
参考答案
  1. 选择依据来自验证集。测试集用于评价已经确定的方案,不能倒过来重新命名选择过程。
  2. 创建新目录,并记录与旧配置的区别。否则无法分清究竟搜索了多少方案,也容易只留下最好结果。
  3. 单独改报告会先触发文件指纹不符。若另行构造与清单一致但数值错误的报告,重新计算也会发现矛盾;这仍不是防伪或身份认证系统。
  4. 不证明。内部一致性、来源真实性和结论是否能够外推,是不同问题。
  5. 例如报告一个验证改善但测试恶化的结果,并说明当前样本不足以支持稳定改善的结论。不要把“失败”改写成从未运行。