1. 从一次训练中断说起:为什么值得花时间读train.py
很多人用 MMDetection 跑通第一个 demo 之后,就再也没打开过tools/train.py。配置文件改一改,命令行敲一下python tools/train.py configs/xxx.py,看到日志开始刷 iteration 就觉得自己已经会了。直到某天遇到下面这些情况,才发现绕不过去:
- 训练到一半中断,想恢复却不知道
--resume到底从哪里读状态; - 想改一下验证频率,翻遍 config 找不到对应字段,最后发现是 runner 在管;
- 多卡训练时日志只在一个进程里打印,想加个自定义输出完全不知道往哪插;
- 想接自己的分布式后端或者换一个更细粒度的 checkpoint 策略,发现 config 里根本没有对应开关。
这些问题的答案,几乎全部藏在tools/train.py这个看起来只有一两百行的入口脚本里。它本身不实现任何模型、不写任何 loss,但它决定了配置怎么被解析、模型怎么被构建、训练循环怎么被驱动、日志和权重怎么被保存。换句话说,train.py是 MMDetection 整个训练体系的“总调度台”。
这篇内容面向的是已经能跑通 MMDetection 基础训练、但想真正搞懂训练入口的读者。我会按“一个训练脚本从启动到收敛”的真实执行顺序,把train.py拆成几块讲清楚:参数解析与配置加载、环境与随机性设置、模型与数据集构建、runner 的组装逻辑、以及那些只有踩过坑才会注意到的细节。读完之后,你应该能自己动手改造这个脚本,而不是只能被动接受默认行为。
需要先说明一点:MMDetection 的版本迭代比较快,train.py在不同大版本之间结构有差异。下面讲的是以 MMEngine 为训练引擎的 3.x 体系为主,同时会点出和旧版 2.x 的关键区别,因为现在还有大量项目跑在 2.x 上,两边都懂才不至于看别人的代码时一脸懵。
2. 启动前的第一道关卡:参数解析与配置加载链路
2.1 argparse 里那几个参数到底在控制什么
打开train.py,最先看到的一定是parse_args()。别看参数不多,每一个都对应着训练流程里一个明确的开关。我把它整理成一张表,方便对照:
| 参数 | 作用 | 容易踩的坑 |
|---|---|---|
config | 指定配置文件路径 | 相对路径是相对于当前工作目录,不是脚本目录 |
--work-dir | 覆盖配置里的工作目录 | 不指定时用 config 里的work_dir,两者都没有会报错 |
--resume | 从最近一次 checkpoint 恢复 | 传auto和传具体路径行为不同 |
--no-validate | 训练中关闭验证 | 只在训练期生效,不影响最终测试 |
--seed | 设置随机种子 | 不设置时结果不可复现 |
--deterministic | 开启确定性训练 | 会牺牲一点速度,且部分算子不支持 |
--cfg-options | 命令行覆盖配置字段 | 嵌套字段用点号连接,写错不报错只是不生效 |
--launcher | 指定分布式启动方式 | 单卡时一般用 none |
这里面最值得展开的是--resume和--cfg-options。
--resume的行为逻辑是这样的:如果不传,就是从头训练;如果传auto,脚本会去work_dir里找最新的last_checkpoint文件,读出里面记录的 checkpoint 路径再加载;如果传一个具体路径,就直接加载那个文件。很多人以为--resume是个布尔开关,其实它接受字符串值,auto是它的一个特殊取值。这个设计的好处是,你不需要手动去记上一次存到哪个 epoch 了。
--cfg-options则是调试利器。比如你想临时把学习率调小验证一下,不用去改配置文件,直接:
python tools/train.py configs/xxx.py --cfg-options optimizer.lr=0.001它的解析规则是把key=value按点号拆成嵌套字典,然后深度合并到原配置上。这里有个坑:值会被当成字符串解析,如果你写optimizer.lr=0.001,它会被转成浮点数,但如果你写一个列表或者更复杂的结构,就得用类似 JSON 的写法。而且如果 key 路径写错了,它不会报错,只是默默加了一个没人用的字段,你会纳闷为什么改了没效果。
2.2 Config 从文件到对象的完整旅程
参数解析完之后,核心动作是Config.fromfile(args.config)。这一步看起来简单,实际上做了不少事。
MMDetection 的配置文件支持继承,一个典型的 config 会长这样:
_base_ = [ '../_base_/models/faster-rcnn_r50_fpn.py', '../_base_/datasets/coco_detection.py', '../_base_/schedules/schedule_1x.py', '../_base_/default_runtime.py' ]fromfile会递归地把_base_里的文件先加载进来,然后再用当前文件的字段去覆盖。覆盖是深度合并,不是简单替换。举个例子,base 里model.backbone.depth=50,你在当前文件写model=dict(backbone=dict(depth=101)),最终depth会变成 101,而 backbone 里其他没写的字段保持不变。这个机制是 MMDetection 配置体系好用的根本原因,但也是新手最容易困惑的地方——你看到的最终配置和你写的文件往往长得不一样。
加载完之后,脚本还会做几件事:把args.work_dir覆盖进去、把--cfg-options合并进去、处理--resume相关的路径。这里有个细节值得注意:work_dir 的确定时机。如果你既没在命令行传--work-dir,config 里也没写work_dir,脚本会直接报错退出。这是有意为之的,因为训练日志和权重必须有个明确的落盘位置,不能靠默认值蒙混过关。
提示:调试配置合并结果最直接的办法,是在
train.py里cfg构建完成之后加一行print(cfg.pretty_text),把最终配置完整打印出来。比对着看,比猜哪里被覆盖了高效得多。
2.3 一个被忽视的环节:配置里的变量引用
MMDetection 的 config 支持在文件内部定义变量并被引用,比如:
num_classes = 80 model = dict(bbox_head=dict(num_classes=num_classes))这个能力来自Config类对 Python 执行环境的封装。它本质上是用exec执行配置文件,所以配置文件里能写 Python 表达式。这带来灵活性的同时也有代价:配置文件不是纯数据,它会被执行。所以不要在里面写有副作用的代码,也不要指望它能被静态解析工具完美处理。理解这一点,你就能明白为什么有些 config 里能写if判断、能引用外部变量,而有些工具却读不懂它。
3. 环境、随机性与分布式:训练开始前的隐形准备
3.1 随机种子与确定性:复现性的地基
在模型构建之前,train.py会调用init_random_seed()之类的逻辑来设置随机种子。这一步的重要性怎么强调都不过分。目标检测训练里涉及大量随机操作:数据增强的随机裁剪、随机翻转、anchor 的随机采样、dataloader 的 shuffle。任何一个环节的随机源没固定住,两次训练的结果就对不上。
MMDetection 的做法是:如果命令行传了--seed,就用它;否则从系统时间或者某个默认值生成一个种子,然后把这个种子同步到random、numpy、torch三个库,并且在分布式场景下保证所有进程用同一个种子。这里有个容易忽略的点:dataloader 的 worker 进程也需要设置种子,否则每个 worker 的增强行为会不一致。框架通过 worker_init_fn 处理了这件事,但如果你自己写了自定义 dataset 并且用了额外的随机源,就得自己保证它也被固定。
--deterministic则是更进一步,它会设置 cuDNN 为确定性模式,并关闭一些非确定性的算子。代价是速度可能下降,而且某些操作在确定性模式下会直接报错。我的建议是:日常训练不开,需要严格复现实验结论时再开。而且开了之后要接受“同样的代码在不同硬件上仍可能有微小差异”这个现实,确定性不等于跨平台完全一致。
3.2 分布式初始化:单卡和多卡的分叉点
接下来是分布式环境的初始化。MMDetection 3.x 把这部分交给了 MMEngine 的init_dist,但逻辑本身没变:根据--launcher参数决定用哪种后端。
none:单进程,不初始化分布式;pytorch:使用 PyTorch 原生的分布式启动;slurm:在集群调度环境下使用;mpi:使用 MPI 后端。
初始化完成之后,会拿到当前进程的 rank、world_size、local_rank 等信息。这些信息后续会决定:每个进程读数据的哪一部分、日志在哪个 rank 上打印、checkpoint 在哪个 rank 上保存。
这里有个非常经典的坑:日志重复打印。如果你在自定义代码里直接print,多卡时每个进程都会打印一遍,日志会乱成一团。正确做法是用 MMEngine 提供的MMLogger,它内部会根据 rank 判断是否输出。同理,任何写文件的操作都要考虑是不是只有主进程该做。
还有一个隐蔽的问题:分布式采样器的行为。多卡训练时,dataloader 会用DistributedSampler,它会把数据集切分到各个进程。如果你自己改了 dataset 的长度或者顺序,要确保 sampler 的逻辑还成立,否则会出现某些样本被重复采样、某些样本永远采不到的情况。
3.3 一个实操建议:先用单卡小数据跑通
我见过太多人一上来就多卡 + 全量数据,结果报错之后根本分不清是配置问题、分布式问题还是数据问题。稳妥的做法是:先用单卡、把数据集截取一小部分、把训练轮数调到 1,完整跑一遍。确认整个链路通了,再逐步放大。train.py的很多报错信息在单卡下更清晰,多卡下会被进程间的日志淹没。
4. 模型、数据集与 runner:训练主体的组装逻辑
4.1 模型构建:从 config 字典到 nn.Module
配置准备好之后,脚本会调用类似build_detector(cfg.model)的逻辑来构建模型。这一步背后是注册器机制:config 里的type字段对应注册器里注册的类名,构建函数根据type找到对应的类,再把剩余字段作为参数传进去。
这个机制的好处是解耦:配置文件不需要 import 任何类,只需要写类名字符串。代价是类名写错时的报错不够直观,通常是一句“xxx is not in the registry”,你得自己去确认是拼写问题还是没 import 对应的模块。
模型构建完之后,还会做几件事:加载预训练权重(如果 config 里指定了load_from或pretrained)、把模型搬到 GPU、如果是分布式则用MMDistributedDataParallel包装。这里要注意load_from和resume_from的区别:前者是加载预训练权重但重新开始训练(epoch 从 0 算),后者是恢复训练(epoch 接着上次算)。这两个概念混用是新手常犯的错误。
4.2 数据集构建与 dataloader 的细节
数据集部分由build_dataset(cfg.data.train)完成。MMDetection 的数据集构建有个特点:它支持“数据集包装”的嵌套结构,比如RepeatDataset、ClassBalancedDataset、ConcatDataset。这些包装器会改变数据集的长度和采样行为,理解它们的嵌套顺序很重要。
dataloader 的构建则涉及几个关键参数:samples_per_gpu(每张卡的 batch size)、workers_per_gpu(每张卡的数据加载进程数)、persistent_workers等。workers_per_gpu的设置是个经验活:设太小数据加载跟不上 GPU 计算,设太大内存和 CPU 开销上去了。一般来说,每个 GPU 配 2 到 4 个 worker 是常见起点,具体要看你的数据增强复杂度和磁盘 IO 速度。
注意:
samples_per_gpu是每张卡的 batch size,不是全局 batch size。全局 batch size 等于samples_per_gpu × num_gpus。学习率的设置通常要跟着全局 batch size 走,改了卡数不改学习率,收敛行为会变。
4.3 runner 的组装:训练循环的真正驱动者
这是train.py里最核心也最容易被略过的一段。在 MMEngine 体系下,脚本会构建一个Runner对象,把模型、数据集、优化器、学习率调度器、日志配置、checkpoint 配置全部塞进去,然后调用runner.train()。
Runner 内部封装了训练循环:什么时候做前向、什么时候反向、什么时候更新学习率、什么时候验证、什么时候保存 checkpoint。config 里的train_cfg、val_cfg、test_cfg以及default_hooks都是在配置这个 runner。
以 checkpoint 保存为例,CheckpointHook控制保存频率:
default_hooks = dict( checkpoint=dict( type='CheckpointHook', interval=1, max_keep_ckpts=3, save_best='coco/bbox_mAP' ) )interval=1表示每个 epoch 存一次,max_keep_ckpts=3表示只保留最近 3 个,save_best表示额外保存指标最好的那个。这些字段在旧版 2.x 里是写在checkpoint_config里的,位置变了但语义基本一致。
日志方面,LoggerHook控制打印频率,interval=50表示每 50 个 iteration 打印一次。如果你觉得日志刷得太快或太慢,改的就是这里,而不是去改train.py本身。
理解 runner 的意义在于:大部分训练行为的定制,应该通过配置 hook 来实现,而不是改 train.py。train.py 负责组装,hook 负责具体行为。这个分工想清楚了,你就能判断一个问题该在哪里解决。
5. 那些只有踩过才知道的坑与定制技巧
5.1 恢复训练时学习率为什么“跳”了
这是被问得最多的问题之一。你从第 10 个 epoch 恢复训练,发现学习率不是接着第 10 个 epoch 的值,而是从头开始或者跳到了别的地方。原因通常有两个:一是--resume没有正确加载优化器状态,二是学习率调度器的配置和恢复逻辑不匹配。
正确的恢复训练,需要同时恢复模型权重、优化器状态、学习率调度器状态、以及当前的 epoch 和 iteration。MMEngine 的 resume 机制会把这些都存进 checkpoint,恢复时一起读回来。如果你用的是自定义的学习率策略,要确保它实现了state_dict和load_state_dict,否则恢复时状态丢失,学习率就会从头算。
另一个相关坑:warmup 在恢复时会被重新触发。如果你的配置里有 warmup,恢复训练时要注意 warmup 的 iteration 计数是不是被重置了。这个在跨版本时行为有差异,建议恢复后先观察几十个 iteration 的日志确认学习率曲线正常。
5.2 自定义 hook 的插入位置
想加一个自定义行为,比如每个 epoch 结束后把某些中间特征可视化出来,最规范的做法是写一个自定义 hook,然后注册到 runner 里。hook 的执行时机由priority和注册的 stage 决定,比如before_train_epoch、after_train_iter。
写 hook 的时候有个原则:不要在 hook 里做阻塞时间过长的操作,因为它会卡住整个训练循环。如果确实要做重活,考虑异步或者放到单独的进程里。另外,hook 里访问 runner 的状态要小心,比如runner.iter、runner.epoch这些值在分布式下每个进程都有一份,但只有主进程该做写操作。
5.3 从 2.x 迁移到 3.x 时 train.py 的变化
如果你手上有旧项目,迁移时train.py层面主要注意这几点:
| 2.x 写法 | 3.x 写法 |
|---|---|
cfg.optimizer_config | 合并进optim_wrapper |
checkpoint_config | default_hooks.checkpoint |
log_config | default_hooks.logger |
lr_config | param_scheduler |
runner = dict(type='EpochBasedRunner') | train_cfg = dict(type='EpochBasedTrainLoop') |
结构变了,但概念是一一对应的。迁移时不要试图逐行翻译,而是理解每个配置块在管什么,然后用新体系的写法重新表达。这样迁移出来的配置更干净,也更容易维护。
5.4 一个实用技巧:把 train.py 当模板复制出来改
最后分享一个我常用的做法。当你需要做比较深的定制时,不要直接改框架自带的tools/train.py,而是把它复制到你的项目目录下,改成一个自己的入口脚本。这样做的好处是:框架升级时不会覆盖你的改动,而且你的定制逻辑集中在一个文件里,别人接手时一眼能看懂。
复制出来之后,你可以放心地加日志、加断点、加自定义的构建逻辑。等定制稳定了,再考虑把通用部分抽成 hook 或工具函数。这个“先复制再抽象”的路径,比一上来就追求优雅架构要务实得多。
我在实际项目里就是这么干的:一开始只是想在训练开始时打印一下数据集的类别分布,后来慢慢加上了自定义的模型初始化、特殊的权重加载逻辑、以及训练中途的指标快照。如果一开始就去改框架源码,这些改动早就随着版本升级丢光了。