4. 命令行参数与配置文件的协同

在科学计算实验中,参数来源往往不止一处:一份随代码提交的默认配置文件、一份针对具体实验的本地配置、以及临时在终端里追加的命令行参数。如果三者各自为政,复现时最常出现的问题就是“我明明改了参数,为什么结果没变”或者“上次跑出来的结果这次复现不了”。本节要解决的核心问题,是让命令行参数与配置文件形成明确的覆盖关系,并把最终真正生效的参数完整记录下来。

为什么需要“覆盖”而不是“替换”

配置文件适合承载大量稳定参数,例如求解器类型、网格规模、输出目录、随机种子;命令行适合承载少量临时变化,例如把迭代步数从 1000 调到 200、把学习率从 1e-3 改成 5e-4。二者不是二选一,而是分层叠加:配置文件提供基线,命令行在其上做局部覆盖。这样既保留了配置文件的完整性和可读性,又保留了命令行的高效与灵活。

因此需要一条明确的优先级规则。工程上最常用、也最符合直觉的规则是:命令行参数 > 实验专用配置文件 > 默认配置文件 > 代码内硬编码默认值。越靠左优先级越高,高优先级来源只覆盖它显式提供的键,未提供的键继续沿用低优先级来源的值。这条“逐键覆盖、而非整份替换”的规则,是协同机制的关键。

参数分层合并与最终生效记录 代码内默认值 优先级最低 默认配置文件 config/default.yaml 实验专用配置 config/exp_042.yaml 命令行参数 --lr 5e-4 --steps 200 逐键合并 高优先级覆盖 未提供则保留 低优先级值 最终生效参数 merged_config + 来源标注 + 时间戳 落盘 resolved _config .json 覆盖示例:default.yaml 中 lr=1e-3、steps=1000;exp_042.yaml 中 steps=500;命令行 --lr 5e-4 合并结果:lr=5e-4(命令行)、steps=500(实验配置)、其余键沿用默认配置

用 argparse 实现逐键覆盖

argparse 本身只负责解析命令行,不负责读配置文件,因此需要我们自己把“解析结果”与“配置文件内容”合并。一个稳健的做法是:先加载配置文件得到字典,再用 argparse 解析命令行,但把每个参数的默认值设为 None,这样就能区分“用户没传”和“用户传了与默认值相同的值”。只有非 None 的命令行参数才覆盖配置字典。

import argparse, yaml, json, datetime

def load_yaml(path):
    with open(path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f) or {}

def build_parser():
    p = argparse.ArgumentParser()
    p.add_argument("--config", default="config/default.yaml")
    p.add_argument("--exp-config", default=None)
    p.add_argument("--lr", type=float, default=None)
    p.add_argument("--steps", type=int, default=None)
    p.add_argument("--seed", type=int, default=None)
    p.add_argument("--out", default=None)
    return p

def resolve_config(args):
    cfg = load_yaml(args.config)
    if args.exp_config:
        cfg.update(load_yaml(args.exp_config))
    cli = {k: v for k, v in vars(args).items()
           if k not in ("config", "exp_config") and v is not None}
    cfg.update(cli)
    return cfg, cli

这里的关键细节是 default=None。如果写成 default=1e-3,那么用户不传 --lr 时,argparse 也会给出 1e-3,从而错误地覆盖配置文件里的值。用 None 作哨兵值,才能实现“只在显式提供时覆盖”。

用 click 实现同样的语义

click 的写法更简洁,但要注意 default=None 同样必要。click 还支持 multiple=True 等特性,适合需要重复传参的场景。下面给出等价实现:

import click, yaml

@click.command()
@click.option("--config", default="config/default.yaml")
@click.option("--exp-config", default=None)
@click.option("--lr", type=float, default=None)
@click.option("--steps", type=int, default=None)
@click.option("--seed", type=int, default=None)
def main(config, exp_config, lr, steps, seed):
    cfg = yaml.safe_load(open(config, encoding="utf-8")) or {}
    if exp_config:
        cfg.update(yaml.safe_load(open(exp_config, encoding="utf-8")) or {})
    cli = {k: v for k, v in dict(lr=lr, steps=steps, seed=seed).items()
           if v is not None}
    cfg.update(cli)
    print("最终生效参数:", cfg)

记录最终生效参数:复现的“证据链”

合并完成后,必须把最终生效的参数连同来源信息一起落盘。只记录合并后的值还不够,因为复现者需要知道每个值来自哪一层。建议在输出目录下生成 resolved_config.json,包含三部分:合并后的完整参数、每个键的来源标注、以及生成时间与命令行原文。这样即使配置文件后来被修改,也能凭这份记录还原当时的运行条件。

def dump_resolved(cfg, cli, out_dir):
    sources = {}
    for k in cfg:
        sources[k] = "cli" if k in cli else "config"
    record = {
        "resolved": cfg,
        "sources": sources,
        "cli_args": cli,
        "generated_at": datetime.datetime.now().isoformat(timespec="seconds"),
    }
    with open(f"{out_dir}/resolved_config.json", "w", encoding="utf-8") as f:
        json.dump(record, f, ensure_ascii=False, indent=2)

一个合理的示例结果是:lr 来自命令行、steps 来自实验配置、seed 与 out 来自默认配置。运行结束后,把 resolved_config.json 与日志、指标文件放在同一目录,就构成了一条可追溯的证据链。复现时只需读取这份文件,而不必猜测当时用了哪些参数。

建议在实验开始前打印一行摘要,例如“lr=5e-4(cli) steps=500(exp) seed=42(default)”,让运行者在终端里就能快速确认覆盖是否符合预期,避免跑完才发现参数没生效。
常见陷阱:把配置文件路径本身也当作可覆盖参数时,要确保先解析出路径再加载文件;若在合并之后才读取 --config,会导致加载了错误的基线。另外,布尔型参数用 store_true 时无法表达“显式设为 False”,此时应改用 --flag/--no-flag 形式,或使用三态字符串参数。

把优先级规则固定下来、把合并逻辑封装成一个函数、把最终生效参数与来源一起落盘,这三件事做完,命令行与配置文件就不再是互相干扰的两个入口,而是一套可解释、可审计、可复现的参数体系。