4. 命令行参数与配置文件的协同
在科学计算实验中,参数来源往往不止一处:一份随代码提交的默认配置文件、一份针对具体实验的本地配置、以及临时在终端里追加的命令行参数。如果三者各自为政,复现时最常出现的问题就是“我明明改了参数,为什么结果没变”或者“上次跑出来的结果这次复现不了”。本节要解决的核心问题,是让命令行参数与配置文件形成明确的覆盖关系,并把最终真正生效的参数完整记录下来。
为什么需要“覆盖”而不是“替换”
配置文件适合承载大量稳定参数,例如求解器类型、网格规模、输出目录、随机种子;命令行适合承载少量临时变化,例如把迭代步数从 1000 调到 200、把学习率从 1e-3 改成 5e-4。二者不是二选一,而是分层叠加:配置文件提供基线,命令行在其上做局部覆盖。这样既保留了配置文件的完整性和可读性,又保留了命令行的高效与灵活。
因此需要一条明确的优先级规则。工程上最常用、也最符合直觉的规则是:命令行参数 > 实验专用配置文件 > 默认配置文件 > 代码内硬编码默认值。越靠左优先级越高,高优先级来源只覆盖它显式提供的键,未提供的键继续沿用低优先级来源的值。这条“逐键覆盖、而非整份替换”的规则,是协同机制的关键。
用 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 与日志、指标文件放在同一目录,就构成了一条可追溯的证据链。复现时只需读取这份文件,而不必猜测当时用了哪些参数。
--config,会导致加载了错误的基线。另外,布尔型参数用 store_true 时无法表达“显式设为 False”,此时应改用 --flag/--no-flag 形式,或使用三态字符串参数。把优先级规则固定下来、把合并逻辑封装成一个函数、把最终生效参数与来源一起落盘,这三件事做完,命令行与配置文件就不再是互相干扰的两个入口,而是一套可解释、可审计、可复现的参数体系。