3. 参数配置文件的设计与组织

科学计算实验的可复现性,很大程度上取决于参数是否被“显式地、结构化地”记录下来。把参数硬编码在脚本里,或散落在多个 .py 文件中的常量里,会导致同一份代码在不同机器、不同数据规模下产生难以追溯的差异。本节讨论如何用 YAML、JSON、TOML 三种主流配置格式设计层次化配置结构,并处理默认值、覆盖机制与多环境分离,最后完成一份可用的实验配置文件。

三种格式的定位与选择

三者都能表达嵌套的键值结构,但设计取向不同。JSON 语法最严格、无注释、无多行字符串,适合作为程序间交换的中间格式,也便于机器生成与校验;YAML 支持注释、锚点与引用、多行文本,可读性最好,是实验配置最常用的手写格式,但缩进敏感、隐式类型转换(如 on、yes 被解析为布尔值)容易埋坑;TOML 用显式的 [section] 表头组织层次,类型明确、无缩进歧义,适合参数较多、需要严格类型语义的场景,在 Python 生态中(pyproject.toml)已被广泛接受。

对科学计算实验,推荐以 YAML 作为主配置格式,理由是注释能力对记录“为什么取这个值”至关重要,而实验参数恰恰需要这类上下文。若团队已有强类型校验流程,可让 YAML 负责人工编辑、JSON 负责运行时快照,二者通过同一份 schema 约束。

层次化配置结构的设计

层次化配置的核心是把参数按“变化频率”和“作用域”分层,而不是按代码模块随意堆放。一个实用的分层原则是:越靠上层越稳定、越通用;越靠下层越具体、越易变。典型四层结构为:

  • 运行环境层:设备、随机种子、日志级别、输出目录,随机器而变。
  • 数据层:数据路径、划分比例、预处理开关、缓存策略。
  • 模型/算法层:结构超参数、求解器类型、收敛阈值、迭代上限。
  • 实验层:本次实验的标识、扫描变量、重复次数、评估指标。

这样分层后,多环境差异只影响第一层,算法调参只影响第三层,覆盖关系清晰可推理。下面这张图展示了一个实验配置的层次结构,以及默认值、环境覆盖、实验覆盖三条来源如何汇聚成最终生效配置。

实验配置的层次结构与覆盖汇聚 default.yaml 全量默认值,纳入版本库 保证任何键都有兜底 env/gpu.yaml 环境覆盖:设备、路径 按机器/集群选择 exp/run_042.yaml 实验覆盖:扫描变量 仅记录与默认不同项 深度合并 后加载者优先 字典递归合并 列表整体替换 最终生效配置(只读快照) runtime device: cuda:1 · seed: 20240517 data split: [0.7,0.15,0.15] · cache: true model lr: 3e-4 · epochs: 120 · tol: 1e-6 experiment id: run_042 · repeats: 5 schema 校验 类型/范围/必填 resolved_config.json 随实验产物归档,复现时直接加载

默认值、覆盖机制与合并语义

默认值的作用不是“省事”,而是保证配置的完备性:任何未被显式指定的键都能取到确定值,从而避免运行时因缺键而崩溃或产生隐式行为。实践中应把默认值集中放在 default.yaml 中并纳入版本控制,实验文件只写与默认不同的项,这样差异一目了然。

覆盖机制的关键在于合并语义必须明确且一致。推荐规则是:按“默认 → 环境 → 实验”的顺序依次加载,字典递归合并、标量后加载者覆盖、列表整体替换而非逐元素拼接。列表整体替换这一条尤其重要——若对列表做拼接,扫描变量列表会在多次覆盖后不断增长,产生难以察觉的错误。下面给出一个最小可用的合并实现:

import copy, yaml

def deep_merge(base, override):
    """字典递归合并;列表与标量整体替换。"""
    if not isinstance(base, dict) or not isinstance(override, dict):
        return copy.deepcopy(override)
    merged = copy.deepcopy(base)
    for k, v in override.items():
        merged[k] = deep_merge(merged[k], v) if k in merged else copy.deepcopy(v)
    return merged

def load_config(*paths):
    cfg = {}
    for p in paths:                      # 顺序即优先级,后者覆盖前者
        with open(p, encoding="utf-8") as f:
            cfg = deep_merge(cfg, yaml.safe_load(f) or {})
    return cfg

cfg = load_config("configs/default.yaml",
                  "configs/env/gpu.yaml",
                  "configs/exp/run_042.yaml")

注意 yaml.safe_load 而非 load,避免反序列化任意对象带来的安全风险;空文件返回 None,用 or {} 兜底。

多环境配置分离

多环境分离的目标是让“同一份实验定义”能在笔记本、GPU 服务器、集群上运行,而差异只体现在环境文件中。常见做法是目录约定:configs/default.yaml、configs/env/{local,gpu,cluster}.yaml、configs/exp/*.yaml,通过命令行参数或环境变量 EXP_ENV 选择环境文件。环境文件只应包含真正随环境变化的键,例如:

# configs/env/gpu.yaml
runtime:
  device: cuda:1
  num_workers: 8
data:
  root: /data1/datasets/spectra
  cache: true
output:
  dir: /data1/runs

要避免的两种反模式:一是把算法超参数写进环境文件,导致换机器就换了模型;二是为每个环境复制一份完整配置,最终三份文件各自演化、无法对齐。前者破坏分层,后者破坏单一事实来源。

编写任务:一份完整的实验配置

下面是一份用于光谱回归实验的配置示例,参数为教学示例值,非实测结果。它体现了前述分层、默认值完备与实验覆盖最小化的原则。

# configs/default.yaml —— 全量默认值
runtime:
  device: cpu
  seed: 20240517
  num_workers: 0
  log_level: INFO
data:
  root: ./data/spectra
  split: [0.7, 0.15, 0.15]
  normalize: true
  cache: false
model:
  name: pls
  n_components: 12
  solver: svd
train:
  lr: 3.0e-4
  epochs: 120
  batch_size: 64
  tol: 1.0e-6
  early_stop_patience: 15
experiment:
  id: baseline
  repeats: 1
  metrics: [rmse, r2]
output:
  dir: ./runs
  save_resolved: true
# configs/exp/run_042.yaml —— 只写与默认不同项
experiment:
  id: run_042
  repeats: 5
model:
  n_components: 20
train:
  lr: 1.0e-4
  epochs: 200

加载后应做三件事:其一,用 schema(如 pydantic 或 jsonschema)校验类型与取值范围,例如 split 三项之和须为 1、n_components 为正整数;其二,把合并结果序列化为 resolved_config.json 与实验产物一同归档,复现时直接加载该快照而非重新合并,从而排除“配置文件后来被改动”的干扰;其三,在日志开头打印配置摘要与各来源文件的哈希,便于事后比对。

判断配置设计是否合格,可以用一个简单测试:把 default.yaml 单独加载,程序能否完整跑通?把环境文件换成另一台机器的,算法行为是否完全不变?把实验文件删掉,是否退化为基线而非报错?三个问题都通过,说明默认值、覆盖与分离三层机制是自洽的。

最后提醒一个易错点:YAML 中 1e-4 会被解析为字符串而非浮点数,应写作 1.0e-4;同理 on、off、yes、no 会被解析为布尔值,作为字符串使用时必须加引号。这类隐式类型问题在 JSON 与 TOML 中不存在,也是选择格式时需要权衡的实际代价。