今天是“AI修行日记”开更的第43天。图省事儿我把训练和测试脚本糊在了同一个文件里改参数靠全局变量验证集用完顺手又训两轮。结果很酸爽训练 Loss 曲线一路向下一换真实场景就崩回头排查还根本搞不清是数据泄漏、代码写错还是模型过拟合。这篇不聊模型结构多玄就踏踏实实聊聊训练和测试的规范写法——它有点“工程感”但对于任何一个准备认真调模型的人来说这是决定你做的实验能不能复现、能不能跟别人对比、能不能节省无效时间的关键底子。适合刚跑通最早代码但开始被“调参混乱”折磨的读者也适合想把现有训练测试流程整理成稳定模块的团队参考。我会直接给出我这一路打磨到今天的落地写法和避坑清单。1. 为什么训练和测试的写法要“较真”1.1 我踩过的“野路子”坑前一个月的状态基本是数据处理、训练、验证全在一段脚本里验证集指标高就把权重当宝贝存下来。问题在于我没有给数据划分做固化下次跑可能验证集完全变了指标自然忽高忽低。更恼火的是测试时忘了给模型切eval()模式Dropout 和 BatchNorm 还在用训练逻辑跑出来的 mAP 虚高一截。当时真以为是模型效果不错换了视频流测试才发现压根不是一回事。这些坑让我意识到一件事训练和测试不是“同一个代码写完拉倒”而是两个阶段、两种目标、两套纪律的工程流程。训练阶段的目标是让模型在验证分布上学到稳定的映射测试阶段的目标是尽量客观地估计模型在真实分布上的表现。你要是把这两件事搅在一起写任何一个环节的标准不统一最后得出的结论都是不可信的。1.2 规范的核心目标可复现、可对比、可回溯规范写法要解决的核心就三件事可复现、可对比、可回溯。可复现是说同一个人隔一个月重跑相同配置或者在另一台机器上部署这套代码拿到的是相同结果。它依赖数据集版本固定、随机种子固定、依赖库版本固定以及步骤顺序固定。可对比是说你换了模型 A/B换了个数据增强前后两次结果能真正比较。这要求验证集和测试集永远不变指标计算代码统一不能你这次算 mAP0.5下次又换成 mAP0.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 命名加上实验名epochmetric例如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 指标计算口径统一做目标检测的都知道mAP0.5和mAP0.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 直接测结果开了shuffleTrue、drop_lastTrue这都是在制造不可复现的评估结果。测试 DataLoader 应该是test_loader DataLoader( test_dataset, batch_sizeconfig.test.batch_size, shuffleFalse, # 必须关闭 drop_lastFalse, # 必须保留全部样本 num_workersconfig.test.num_workers, pin_memoryTrue )这里的逻辑是测试的目的是覆盖全部测试样本计算一个稳定的总体指标而不是抽样估计所以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(fLoaded 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, bestTrue) save_checkpoint(cfg[train][checkpoint_dir], epoch, model, optimizer, scheduler, val_metric, cfg, bestFalse) if __name__ __main__: parser argparse.ArgumentParser() parser.add_argument(--config, typestr, requiredTrue) args parser.parse_args() main(args.config)这里必须强调两点规范。第一validate函数只负责验证集计算指标不参与任何参数更新。第二save checkpoint 的bestTrue和bestFalse要生成两个文件一个是最优权重供最终测试用一个是最近 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(fTest mAP0.5:0.95 {test_metrics[mAP_50_95]:.4f}) print(fTest mAP0.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_interval5来平衡时间和信息量但这也意味着有些精度高峰会在 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 ## 测试指标 | 模型 | mAP0.5:0.95 | mAP0.5 | 参数量 | 备注 | | --- | --- | --- | --- | --- | | exp001 | 0.873 | 0.921 | 41M | baseline | ## 各类别 AP | 类别 | AP | | --- | --- | | car | 0.902 | | person | 0.768 | | ... | ... | ## 结论 当前模型在 person 类别上偏弱下一步尝试增加该类别样本量和强数据增强。写报告看起来不会直接提升模型精度但它会把训练测试规范的最后一块拼图补齐。你的实验体系一旦形成“配置 - 代码 - Checkpoint - 测试报告”的闭环后续换模型、换数据、换超参都会轻松很多。说回今天从把失败实验完整复盘到形成这套规范我只花了一个晚上重构代码但之后每一次实验的可信度都明显不一样了。训练和测试的规范不是束缚是给自己省下的未来时间。下一篇我准备继续沿着这个工程化方向把数据增强阶段的标准化做法单独拿出来拆一拆。