今天是“AI修行日记”开更的第43天。图省事儿,我把训练和测试脚本糊在了同一个文件里,改参数靠全局变量,验证集用完顺手又训两轮。结果很酸爽:训练 Loss 曲线一路向下,一换真实场景就崩,回头排查还根本搞不清是数据泄漏、代码写错还是模型过拟合。这篇不聊模型结构多玄,就踏踏实实聊聊训练和测试的规范写法——它有点“工程感”,但对于任何一个准备认真调模型的人来说,这是决定你做的实验能不能复现、能不能跟别人对比、能不能节省无效时间的关键底子。适合刚跑通最早代码但开始被“调参混乱”折磨的读者,也适合想把现有训练测试流程整理成稳定模块的团队参考。我会直接给出我这一路打磨到今天的落地写法和避坑清单。
1. 为什么训练和测试的写法要“较真”
1.1 我踩过的“野路子”坑
前一个月的状态基本是:数据处理、训练、验证全在一段脚本里,验证集指标高就把权重当宝贝存下来。问题在于,我没有给数据划分做固化,下次跑可能验证集完全变了,指标自然忽高忽低。更恼火的是测试时忘了给模型切eval()模式,Dropout 和 BatchNorm 还在用训练逻辑,跑出来的 mAP 虚高一截。当时真以为是模型效果不错,换了视频流测试才发现压根不是一回事。
这些坑让我意识到一件事:训练和测试不是“同一个代码写完拉倒”,而是两个阶段、两种目标、两套纪律的工程流程。训练阶段的目标是让模型在验证分布上学到稳定的映射;测试阶段的目标是尽量客观地估计模型在真实分布上的表现。你要是把这两件事搅在一起写,任何一个环节的标准不统一,最后得出的结论都是不可信的。
1.2 规范的核心目标:可复现、可对比、可回溯
规范写法要解决的核心就三件事:可复现、可对比、可回溯。
可复现是说,同一个人隔一个月重跑相同配置,或者在另一台机器上部署这套代码,拿到的是相同结果。它依赖数据集版本固定、随机种子固定、依赖库版本固定以及步骤顺序固定。
可对比是说,你换了模型 A/B,换了个数据增强,前后两次结果能真正比较。这要求验证集和测试集永远不变,指标计算代码统一,不能你这次算 mAP@0.5,下次又换成 mAP@0.5:0.95,还不写进实验记录。
可回溯是说,拿到一个新 idea,要能准确说清楚它是在哪个实验分支、哪份数据、哪个超参数组合下产出的结果。落不了地的话,哪怕指标刷到天上,对后续迭代也没有价值。
我现在的做法是,训练和测试工程上彻底分离,但共享一套数据划分和配置系统。两者通过配置文件和固定目录进行弱耦合,这样既保证了独立执行,又避免了各写各的分裂。后面每一节,我都会围绕这三件事展开。
2. 训练环节的规范拆解
2.1 数据集固定拆分:版本化是第一优先级
前期我反复吃过“隐性数据泄漏”和“非固定划分”的亏,所以现在不管什么任务,第一步永远是先做数据版本划分。这里的规范不是简单train_test_split跑一遍,而是要固定一套划分结果,并把它落盘成索引文件。
我习惯把数据划分做成独立步骤,产出三个文件:train.txt、val.txt、test.txt。每一行是一个样本 ID,而不是路径。这样后续不管你是做目标检测、图像分类还是跑分割模型,路径怎么换都不影响划分的稳定性。对于yolov8这类工具,可以在数据配置 YAML 里直接指向这三个清单文件,效果是一样的。
关键点是random_state必须固定,而且划分脚本本身也要固定记录。我原本总想用同一个脚本随机重跑,后来发现一旦新增数据,旧的划分就整体变了,以前跑过的实验结果全部失去可比性。所以我现在宁可多写一个split_dataset.py,每次跑完把生成的索引直接提交进项目仓库,作为不可变更的基准。数据变了就重新走一次划分流程,并把版本号递增。
划分比例方面,小数据集我会强调留足测试集。比如总共 1000 张,训练 700、验证 150、测试 150;但如果样本只有 300 张,我得更激进一点:训练 180、验证 60、测试 60,哪怕训练数据紧张也要保测试集独立性。因为拿验证集反复调超参,它已经“脏了”,最终客观评估必须靠从没见过模型的测试集。
2.2 配置化取代全局变量:所有超参进配置文件
以前我最爱的写法是:
lr = 0.001 batch_size = 16 epochs = 100全局变量一拉到底,看起来方便,实际上一旦实验多了你就会疯——到底哪一组参数跑出那个结果,全靠脑子记。所以我现在的做法是:所有训练相关超参全部放进配置文件。
# configs/train_exp001.yaml data: train_list: data/split/train.txt val_list: data/split/val.txt test_list: data/split/test.txt num_classes: 20 model: name: resnet50 pretrained: true freeze_backbone: false train: epochs: 100 batch_size: 32 lr: 0.001 lr_scheduler: cosine optimizer: adamw weight_decay: 0.05 seed: 42 num_workers: 8 amp: true mixed_precision: fp16 checkpoint_dir: checkpoints/exp001 test: batch_size: 32 eval_interval: 5 metric: map训练脚本里只负责读配置,任何参数都不再散落全局变量。每次实验的配置文件本身就是一份实验记录,跑完记录结果和备注,这就是最小可用的实验管理。我用过yaml加载配置,也能套到多数框架,比如mmdetection、mmsegmentation、yolov8也都有自己的 YAML 配置体系,思路一致。
2.3 随机种子、日志与 Checkpoint 的纪律
“随机种子固定”是新手最容易忽略的。很多人觉得训练结果本来就有波动,但对规范实验来说,同配置只能有一个结果。我固定随机种子的方式是这样:
import random import numpy as np import torch def set_seed(seed: int = 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False有个细节要提醒:开了cudnn.benchmark = False之后,训练速度可能略有下降,但换来的是卷积算法选择的确定性,在需要复现的场景下这是值得的。正常探索阶段我会保留benchmark = True,一旦定稿关键实验必须关闭。
Checkpoint 保存也要规范。不要只存模型权重,最好把优化器状态、学习率调度器状态、epoch、best metric 全部存起来:
torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), 'best_metric': best_metric, 'config': config, }, checkpoint_path)这样你才具备断点续训能力,也能从 checkpoint 准确回溯当初训练的环境状态。我每次都会给 checkpoint 命名加上实验名+epoch+metric,例如exp001_epoch80_map0.873.pth,避免后期看到一坨同名文件根本分不清谁是谁。
日志我直接用wandb或tensorboard,但这不是必须的。关键规范是:每个 epoch 或者每个固定 step,必须记录 train loss、lr、val mAP、显存占用。没有完整日志,等于没有训练过程。我后期追查 NaN、loss 震荡以及过拟合拐点时,全靠这些日志曲线找线索。
3. 测试环节的规范实践
3.1 测试集、验证集必须“物理隔离”
前面提到验证集不能当测试集用,这里再展开。验证集的核心用途是模型选择、超参调优和早停的参考,它会参与你的决策,因此它的信息会间接“泄漏”到模型里。你反反复复按验证集指标调参,本质上就是在拿验证集训练,过拟合验证集只是时间问题。而测试集必须只允许使用一次,甚至在完整流程没定稿前,最好连代码都不写测试部分,防止手滑提前读取。
我现在的物理隔离策略是:代码目录上区分val.py和test.py,数据清单上 val 和 test 完全不相交。训练阶段,val.py可以在每一轮结束或固定间隔自动跑;而test.py只在模型训练完整结束后运行一次,并且必须使用效果最好的 checkpoint,而不是最后一轮 checkpoint。这里推荐用best_metric来选 checkpoint,这个逻辑应该在训练脚本里自动完成。
3.2 指标计算口径统一
做目标检测的都知道,mAP@0.5和mAP@0.5:0.95数值差距明显,如果混用,实验对比就是笑话。所以我养成一个习惯:把指标计算抽成独立模块,训练阶段验证和最终测试调同一个函数。比如用pycocotools评估 COCO 指标,我在metrics/目录建一个coco_eval.py,两边统一调用。
from metrics.coco_eval import evaluate_coco # 验证阶段 val_metrics = evaluate_coco(model, val_loader, iou_thresholds=[0.5, 0.75, 0.5:0.95]) # 测试阶段 test_metrics = evaluate_coco(model, test_loader, iou_thresholds=[0.5, 0.75, 0.5:0.95])对于分类任务,要明确 top-1 和 top-5 是否都算;对语义分割,要明确 mIoU 的类别权重和忽略像素索引。这些指标口径最好直接写进模块函数注释里,并在实验记录里标注。如果哪天需要换评测标准,也应该新增一个函数,而不是在原函数里改,这样历史结果才能保持可比。
3.3 测试代码必须“安静且独立”
测试阶段最经典的一个坑:模型没切eval()模式。代码长这样:
model.eval() with torch.no_grad(): outputs = model(images)model.eval()会关闭 Dropout,让 BatchNorm 使用累积的全局统计量而不是当前 batch 统计量,这是必须的。没有torch.no_grad()的话,显存白白浪费,而且可能引入不必要的计算图,测试耗时翻倍。
我踩过最阴的一个问题是 BatchNorm 在测试阶段和训练阶段行为不一致。如果模型里存在自定义结构,比如在推理时有分支判断,或者测试输入分辨率不同导致 BatchNorm 统计量不一致,结果很容易虚高或虚低。所以测试脚本要固定test_batch_size、输入尺寸、归一化参数。测试环境要做到“安静”——不做数据增强、不做随机翻转、不使用任何涉及随机性的预处理,保证每次评估可复现。
很多刚入门的朋友会用训练时的 DataLoader 直接测,结果开了shuffle=True、drop_last=True,这都是在制造不可复现的评估结果。测试 DataLoader 应该是:
test_loader = DataLoader( test_dataset, batch_size=config.test.batch_size, shuffle=False, # 必须关闭 drop_last=False, # 必须保留全部样本 num_workers=config.test.num_workers, pin_memory=True )这里的逻辑是:测试的目的是覆盖全部测试样本,计算一个稳定的总体指标,而不是抽样估计,所以drop_last绝对不能开。
4. 从零搭建一套规范训练测试工程
4.1 项目目录的推荐结构
这 43 天里我逐步把项目目录收敛成了下面的样子,虽然不是标准答案,但对中小型视觉任务比较省心:
project/ ├── configs/ │ ├── train_exp001.yaml │ └── test_exp001.yaml ├── data/ │ ├── split/ │ │ ├── train.txt │ │ ├── val.txt │ │ └── test.txt │ └── raw/ ├── datasets/ │ ├── __init__.py │ ├── base_dataset.py │ └── detection_dataset.py ├── metrics/ │ ├── __init__.py │ └── coco_eval.py ├── models/ │ ├── __init__.py │ └── resnet_fpn.py ├── utils/ │ ├── logger.py │ ├── seed.py │ └── checkpoint.py ├── train.py ├── val.py ├── test.py ├── split_dataset.py └── requirements.txtconfigs/存放实验配置,每一个实验对应一个 YAML 文件;data/split/存放版本化的划分索引;datasets/定义数据集类;models/定义模型结构;utils/放通用工具函数;根目录四个核心脚本分别承担数据划分、训练、验证、最终测试。这既适合自定义模型的 PyTorch 项目,也适合你拿yolov8、mmrotate、mmsegmentation这类成熟工具箱时套用——工具箱通常已经内置了 config 和 checkpoint 规范,你要补的往往是独立的数据版本化与最终测试流程。
4.2 训练脚本的规范骨架
我分享一段简化但落地的train.py逻辑,核心是把配置加载、模型构建、训练循环、验证调用按职责拆开。
import argparse import yaml from utils.seed import set_seed from utils.logger import setup_logger from utils.checkpoint import save_checkpoint, load_checkpoint def main(cfg_path): cfg = yaml.safe_load(open(cfg_path)) set_seed(cfg['train']['seed']) logger = setup_logger(cfg['train']['checkpoint_dir']) logger.info(f"Loaded config: {cfg_path}") train_loader = build_dataloader(cfg['data']['train_list'], cfg['train']) val_loader = build_dataloader(cfg['data']['val_list'], cfg['test']) model = build_model(cfg['model']) optimizer = build_optimizer(model, cfg['train']) scheduler = build_scheduler(optimizer, cfg['train']) best_val_metric = 0.0 start_epoch = 0 if cfg['train']['resume']: start_epoch, best_val_metric = load_checkpoint(model, optimizer, scheduler, cfg['train']['resume']) for epoch in range(start_epoch, cfg['train']['epochs']): train_one_epoch(model, train_loader, optimizer, scheduler, epoch, cfg['train']) if epoch % cfg['test']['eval_interval'] == 0 or epoch == cfg['train']['epochs'] - 1: val_metric = validate(model, val_loader, cfg) if val_metric > best_val_metric: best_val_metric = val_metric save_checkpoint(cfg['train']['checkpoint_dir'], epoch, model, optimizer, scheduler, val_metric, cfg, best=True) save_checkpoint(cfg['train']['checkpoint_dir'], epoch, model, optimizer, scheduler, val_metric, cfg, best=False) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--config', type=str, required=True) args = parser.parse_args() main(args.config)这里必须强调两点规范。第一,validate函数只负责验证集计算指标,不参与任何参数更新。第二,save checkpoint 的best=True和best=False要生成两个文件,一个是最优权重供最终测试用,一个是最近 epoch 权重供断点续训用。不要混在一起,否则续训时容易把历史最优权重覆盖掉。
训练中有个管理蛮力但有效的心得:每一个 epoch 结束完,我强制在日志里打印一行类似Epoch 42 | lr 0.00032 | train_loss 0.2134 | val_mAP 0.873的摘要。这一行虽然简短,但配合完整日志文件,几乎能解决所有“又训崩了”的定位问题。
4.3 测试脚本的规范写法
test.py的写法比train.py更精简,但这几行恰恰决定了你对外宣称的“效果好”是否可信。核心逻辑是:加载最优 checkpoint、切评估模式、跑完整测试集、算指标、输出并保存结果。
import torch from utils.checkpoint import load_checkpoint from metrics.coco_eval import evaluate_coco def main(cfg_path): cfg = yaml.safe_load(open(cfg_path)) model = build_model(cfg['model']) model.eval() ckpt = torch.load(cfg['test']['checkpoint_path']) model.load_state_dict(ckpt['model_state_dict']) test_loader = build_test_dataloader(cfg['data']['test_list'], cfg['test']) with torch.no_grad(): test_metrics = evaluate_coco(model, test_loader) print(f"Test mAP@0.5:0.95 = {test_metrics['mAP_50_95']:.4f}") print(f"Test mAP@0.5 = {test_metrics['mAP_50']:.4f}")需要用到的关键纪律有三个。第一个:测试脚本没有随机种子相关的操作,它只加载一个确切模型,评估一次,结果是确定的。第二个:测试输出要落盘保存,比如存成results/exp001_test_metrics.json,把当时的配置、checkpoint 路径、指标、时间都记下来。第三个:测试脚本禁止包含任何训练相关逻辑,比如数据增强里不要有RandomResizedCrop这类随机操作,预处理只用固定缩放和归一化。
我还会在测试阶段做一件事:打印所有类别的 AP 表格。这个信息对于定位模型在哪些类别上“拉胯”特别重要。如果只看整体 mAP,很多局部失效会被平均掉,这个小习惯帮我在做检测模型时省下了大量找问题的精力。
5. 这 43 天里最常见的五个问题和排查技巧
5.1 数据泄漏是怎么悄悄发生的
数据泄漏是所有“训练和测试规范”问题的第一杀手,表现形式又很隐蔽。典型场景是:我做数据预处理时,先用全部样本计算归一化均值方差,再把样本划分成训练和测试集。这导致测试集的统计信息已经混入模型可见的数据分布,测试指标自然虚高。正确做法是先划分,后只在训练集上计算归一化参数,再应用到验证和测试集。
另一个隐蔽场景是多尺度增强或数据扩增时,有些增强操作读取了全局状态,或者划分索引文件里同一个样本 ID 同时出现在训练和测试清单里。排查方式很直接:写个小脚本统计 train.txt、val.txt、test.txt 三个文件的交集,一旦交集非空立刻整批重新划分。我习惯在每次训练启动时自动执行这个校验,防止数据版本更新后引入错误。
5.2 模型“看起来很好”却泛化差
这类问题的典型表现是:训练集 Loss 逼近 0,验证集指标也不错,但部署到实际场景就崩。常见的规范层面原因有三个。第一个,验证集和训练集来自同一个视频序列或同一个采集批次的相似场景,样本高度相关,验证集起不到泛化验证作用。解决思路是按场景或采集批次划分,而不是随机划分。第二个,数据增强太弱,模型把噪声特征当成有效信号。第三个,测试时图像预处理和训练时不一致,比如训练用了RandomResizedCrop,测试时没做中心裁剪,直接缩放,输入分布差太多。
碰到这种情况,我会先把测试脚本中的图像预处理步骤打印出来,和训练脚本里的非随机部分逐一对照,通常问题立刻暴露。
5.3 断点续训看起来没问题但结果复现不了
断点续训的规范不仅涉及“能继续跑”,还要保证“继续跑不会让之前的结果作废”。我踩过的坑是:加载完 checkpoint 之后,忘记把optimizer的param_groups里的 lr 更新到 scheduler 状态,导致续训时学习率跳变,后续指标和从头训练的曲线对不上。更隐蔽的是torch.backends.cudnn.benchmark在续训环境中由于 GPU 型号不同产生计算差异。
我的解决办法是,在 checkpoint 里同时保存环境信息,包括torch.__version__、cuda版本、gpu_name和全部超参。一旦发现需要复现某个历史实验,我会用固定容器环境去跑,保证软件依赖一致。如果你在用mmdetection或yolov8这类框架,也要注意工具库版本冻结,最好用requirements.txt锁定版本。
5.4 指标脚本改了到处跑
很多团队或者个人会养成一个坏习惯:验证阶段为了方便直接在主循环里写个快速指标计算,测试阶段又写一个复杂的完整指标脚本。两边工具脚本没有任何共享关系,得到的数字自然对不上。这个问题的唯一解法就是把指标模块变成唯一样式。我在metrics/里的coco_eval.py只维护一份代码,训练验证和最终测试都引用它。如果需要对指标逻辑做修改,修改后必须把历史实验用新代码重新评估一遍,并在实验记录里说明指标版本变化。
5.5 一个 epoch 的测试时机和早停问题
验证评估的频率也是隐藏变量。我常用eval_interval=5来平衡时间和信息量,但这也意味着有些精度高峰会在 epoch 之间被错过。真正严谨的做法是在训练过程中每个 epoch 都做一次验证,训练结束后用历史最佳val_metric对应的 checkpoint 做测试,而不是用最后一个 checkpoint。很多工具框架默认保存最后一个 checkpoint,这会导致最终测试指标不是模型的最优表现。所以无论用什么框架,我都建议显式配置“保存最佳 checkpoint”的选项,并确认最终测试加载的是best_前缀文件。
6. 一个额外的彩蛋:测试报告模板
前面规范更多是针对代码的,最后我分享一个让训练测试规范真正闭环的细节:每次测试结束,我会生成一个极简但完整的测试报告文件。它的作用是,三个月后不用重新跑任何代码,只读这些报告就能回顾每个实验的最终结论。
# Experiment Report - 实验名: exp001_resnet50_fpn - 配置路径: configs/train_exp001.yaml - 最优 checkpoint: checkpoints/exp001_best.pth - 测试集版本: 2025-04-01_cleaned_v2 - 测试时间: 2025-04-08 14:23 ## 测试指标 | 模型 | mAP@0.5:0.95 | mAP@0.5 | 参数量 | 备注 | | --- | --- | --- | --- | --- | | exp001 | 0.873 | 0.921 | 41M | baseline | ## 各类别 AP | 类别 | AP | | --- | --- | | car | 0.902 | | person | 0.768 | | ... | ... | ## 结论 当前模型在 person 类别上偏弱,下一步尝试增加该类别样本量和强数据增强。写报告看起来不会直接提升模型精度,但它会把训练测试规范的最后一块拼图补齐。你的实验体系一旦形成“配置 -> 代码 -> Checkpoint -> 测试报告”的闭环,后续换模型、换数据、换超参都会轻松很多。
说回今天,从把失败实验完整复盘到形成这套规范,我只花了一个晚上重构代码,但之后每一次实验的可信度都明显不一样了。训练和测试的规范不是束缚,是给自己省下的未来时间。下一篇,我准备继续沿着这个工程化方向,把数据增强阶段的标准化做法单独拿出来拆一拆。