深度学习项目模板与机器学习软件工程(MLSE)实践笔记
主题:机器学习软件工程(Machine Learning Software Engineering, MLSE)
素材来源:豆包对话总结(标准目录结构、Config 与 Dependency 辨析)+ 个人整理补充
整理日期:2026-10-11
适用场景:深度学习项目复现、模型缝合、消融改造
目录
- 摘要
- 一、研究背景:复现为什么总在重复造轮子
- [二、MLSE 与常规软件工程的分野](#二、MLSE 与常规软件工程的分野)
- 三、标准项目骨架
- 四、逐文件职责与设计意图
- 五、轻量版骨架
- 六、配置与依赖:两个必须分开的概念
- 七、可复现性工程实践
- 八、面向"缝合与改造"的落点
- 九、常见误区
- 十、结论与延伸
- [附录 A:术语速查](#附录 A:术语速查)
- [附录 B:项目骨架自查清单](#附录 B:项目骨架自查清单)
摘要
本文把深度学习项目复现的通用骨架归纳为可复用模板,厘清"依赖决定能不能跑、配置决定跑成什么样"这一核心区分,给出标准版与轻量版目录结构及逐文件职责,对比机器学习软件工程与常规软件工程的差异,并整理可复现性实践与模型缝合、消融改造的落点清单。
一、研究背景:复现为什么总在重复造轮子
复现一个深度学习项目,表面上是"读论文---找代码---跑通---改一改",实际动手后会发现流程高度雷同:找配置、改数据读取、换骨干网络、加一个模块、跑消融、对齐指标。既然流程同构,就说明其中存在一个稳定的工程骨架。
把最近多次复现的经验摊开来看,痛点集中在四处:
- 目录无共识 :同样是训练脚本,有人叫
main.py,有人叫train.py,还有人藏在tools/里;权重、日志、输出散落各处。 - 参数硬编码:学习率、batch size、数据路径直接写在代码里,改一次超参就要动一次源码,改动与实验无法一一对应。
- 职责耦合:数据读取、模型组装、训练循环、指标计算挤在一个文件里,想单独替换其中一层必须大改。
- 不可复现:只留下权重,没留下配置、随机种子与数据划分,几周后自己都还原不出当时的实验条件。
针对这些问题,可以把日常动作抽象为三个:
| 动作 | 工程含义 | 对模板的要求 |
|---|---|---|
| 复现 | 使他人代码在本机可跑通、指标可对齐 | 依赖可装、路径可配、入口统一 |
| 缝合 | 把 A 项目的模块搬到 B 项目的流水线 | 模块边界清晰、可插拔、有开关 |
| 改造 | 替换骨干、损失、数据管线做消融 | 变更点可定位、实验可对照 |
模板的价值正在于此:让改动可定位、实验可对照、结果可复现。
二、MLSE 与常规软件工程的分野
"机器学习软件工程"(MLSE)之所以被单列,是因为它的交付物、正确性判据与迭代方式都与常规软件工程不同。常规软件工程的交付物是代码,功能正确即视为完成;而 MLSE 项目的交付物是"代码 + 配置 + 权重 + 数据版本"四件套,只有实验结果能被别人复现,才算真正交付。
| 对比维度 | 常规软件工程 | 机器学习软件工程(MLSE) |
|---|---|---|
| 交付物 | 源码(可执行、可发布) | 源码 + 配置 + 权重 + 数据版本 |
| 正确性判据 | 单元测试/断言,确定性 | 指标(mAP、PSNR、SSIM、Acc 等),带随机性 |
| 主要变更入口 | 修改代码 | 修改配置(多数实验不改代码) |
| 状态特征 | 以无状态为主 | 强状态:权重、优化器状态、随机数状态 |
| 版本管理对象 | 代码版本 | 代码/配置/数据/权重四重版本 |
| 失败表现 | 崩溃、抛异常,容易发现 | 能跑通但结果不对(静默错误、指标悄悄下降) |
| 迭代单元 | 一次发布 | 一次实验(训练 → 评估 → 决策) |
| 调试手段 | 断点、日志、单测 | 日志 + TensorBoard 曲线 + 指标复盘 + 可视化 |
由此得到两条贯穿全文的不变式:
- 依赖(Dependency) 是运行时基础设施,决定"代码能不能跑起来"。依赖缺失或版本冲突,程序根本启动不了。
- 配置(Config) 是实验参数,决定"实验怎么跑、跑成什么样"。配置写错,程序照样跑,但结果不对------这是最危险的一类错误,因为它静默。
换句话说:依赖出错会报错,配置出错只会给你一个错误的结果。
三、标准项目骨架
适合论文复现、正式实验、多人协作的中大型项目:
plaintext
dl_project/
├── configs/ # 配置文件:超参、数据集路径、模型参数
│ └── default.yaml # yaml 统一管理,避免代码里硬写参数
├── data/ # 数据相关
│ ├── dataset.py # torch.utils.data.Dataset 自定义数据集
│ └── dataloader.py # 构建 train/val/test DataLoader、数据增强
├── models/ # 网络结构
│ ├── backbone.py # 骨干网络
│ ├── head.py # 检测/分类头
│ └── model.py # 组装完整模型
├── trainers/
│ └── trainer.py # 训练循环、优化器、lr scheduler、保存 ckpt、日志
├── utils/
│ ├── metrics.py # 指标计算:mAP、IoU、PSNR、SSIM、Acc 等
│ ├── logger.py # 日志打印、TensorBoard
│ └── tools.py # 通用小函数:文件操作、可视化、时间
├── inference.py # 推理脚本:加载权重,单图/批量预测
├── train.py # 项目入口:解析配置,启动训练
├── test.py # 在 val/test 集上评估指标
├── requirements.txt # 依赖包
└── README.md # 环境、数据集准备、训练/推理命令
骨架的设计遵循一条主线:配置在最外层(configs),数据在中间层(data),模型在核心层(models),训练逻辑在驱动层(trainers),入口在最顶层(train/test/inference)。层与层之间通过配置而非硬编码耦合。
四、逐文件职责与设计意图
| 文件 | 职责 | 设计意图 |
|---|---|---|
configs/default.yaml |
存放 batch_size、epochs、学习率、数据集 root、保存路径、模型类型 | 唯一真源。train.py 读取 yaml,所有参数统一在此修改,杜绝硬编码 |
data/dataset.py |
继承 torch.utils.data.Dataset,实现 __getitem__,读取图片+标注并做预处理 |
只关心单个样本 (x, y) 的读取与变换,不关心 batch |
data/dataloader.py |
接收 dataset,返回 train_loader、val_loader |
只关心批:batch_size、shuffle、num_workers、collate_fn、sampler |
models/ |
拆分 backbone 与 head,model.py 组合返回完整网络 |
骨架与任务头解耦,便于后续替换骨干网络 |
trainers/trainer.py |
封装"训练一轮+验证一轮"的逻辑 | 训练逻辑与入口代码分离,换实验只动 trainer,入口保持稳定 |
utils/ |
纯工具函数,不含模型与训练逻辑 | 保证工具层无副作用,可被任意模块复用 |
train.py |
入口:一行命令启动训练 python train.py --cfg configs/default.yaml |
只负责初始化与调用 trainer |
test.py |
加载 ckpt,跑测试集,输出指标 | 评估与训练解耦,可对任意权重复测 |
inference.py |
离线推理,可视化预测结果 | 面向部署与定性展示,不参与指标统计 |
三点值得强调的边界:
- Dataset 与 DataLoader 的分工 ,本质是"样本级"与"批次级"的分工。把 batch 逻辑写进
dataset.py是最常见的越界,会导致 DataLoader 的多进程与 sampler 机制失效。 - trainer 的厚度 。经验法则是:训练循环、优化器、学习率调度、混合精度、梯度累积、checkpoint 保存都应落在 trainer 内部;
train.py的代码量应控制在几十行,只做参数解析、随机种子固定、日志初始化、调用 trainer。 - 工程完备性 。除上述文件外,建议增设
configs/下的多份实验配置(如exp_base.yaml、exp_ablation.yaml)、根目录的.gitignore,以及weights/、logs/、outputs/三个运行期目录。
五、轻量版骨架
适用于小实验、快速验证 idea,很多 GitHub 小型 CV 项目都采用这种形式:
plaintext
light_project/
├── config.yaml
├── dataset.py
├── model.py
├── trainer.py
├── utils.py
├── train.py
├── test.py
├── inference.py
├── requirements.txt
└── README.md
轻量版的取舍原则是:保留 config 与 trainer 两条生命线,砍掉目录层级 。因为只要配置与训练逻辑还在,日后需要扩展成标准版时,只需把 dataset.py 拆成 data/ 两个文件、model.py 拆成 models/ 三个文件即可,成本极低。
六、配置与依赖:两个必须分开的概念
6.1 一句话区分
- 配置(Config):控制"你的实验怎么跑"(超参、路径、模型选项),是项目的参数,与代码逻辑本身无关。
- 依赖(Dependency):控制"代码能不能跑起来"(需要安装哪些软件包),是运行环境。
6.2 对比表
| 项目 | 配置 Config | 依赖 Dependency |
|---|---|---|
| 作用 | 设定实验参数,改变实验结果 | 声明运行环境,决定代码能否启动 |
| 文件例子 | config.yaml、args.py |
requirements.txt、environment.yml、pyproject.toml |
| 内容举例 | batch_size、学习率、数据集路径、类别数、img_size、device | torch、opencv-python、numpy、PyYAML |
| 修改后果 | 改 yaml → 训练效果变化,代码逻辑不变 | 换依赖版本 → 可能直接报错或行为漂移 |
| 归属层次 | 实验层(科学问题) | 运行时层(基础设施) |
| 版本管理方式 | 与代码同仓库、可按实验分文件 | 声明用 requirements.txt,锁定用 pip freeze 生成 lock 文件 |
| 典型错误 | 参数写错 → 能跑,但结果不对 | 包缺失/版本冲突 → 根本起不来 |
| 排查手段 | 对比两份 yaml、复现指标曲线 | 打印 torch.__version__、清空环境重装 |
6.3 举例:AOI 缺陷检测
配置 config.yaml:
yaml
batch_size: 16
lr: 0.0005
num_classes: 2
dataset_path: "./data/aoi"
device: cuda
依赖 requirements.txt:
txt
torch==2.4.0
torchvision==0.19.0
numpy>=1.24
pyyaml
opencv-python
前者是实验参数:代码不动,把 lr 从 0.0005 改成 0.001,训出来的模型效果就变了;后者是环境库:电脑必须先装上这些包,代码才有运行的前提。
6.4 极简记忆口诀
依赖 = 要装什么包,让代码能跑;
配置 = 跑的时候用什么参数,决定实验怎么做。
6.5 三个容易混淆的灰色地带
- 数据集路径写在 yaml 里:属于配置。它不改变运行环境,只改变读哪份数据。
- 代码里
import torch:torch 属于依赖。写在 import 里就是声明"我需要这个包",与参数无关。 device: cuda写在 yaml 里:属于配置。它决定这次实验用 GPU 还是 CPU 跑,是可选项而非前置条件。
判断标准可以归结为一句话:"缺了它程序会不会直接崩?"会崩的是依赖,不会崩但要重训的是配置。
七、可复现性工程实践
模板解决"结构",可复现性解决"结果能不能被信任"。
- 运行期产物不进版本库 。新建
weights/、logs/、outputs/三个目录,并在.gitignore中忽略;checkpoint、日志、预测输出自动写入对应目录。 - 路径尽量写在 yaml 中,不留死绝对路径。统一以项目根为基准做相对路径拼接,跨机器迁移时只改一处。
- 固定随机种子 。同时设置 Python
random、NumPy、PyTorch 三方种子;是否开启cudnn.deterministic需权衡速度与可复现性,但至少要在文档中写明。 - checkpoint 要存全状态。除模型权重外,至少保存优化器状态、学习率调度器状态、当前 epoch/global_step、以及随机数状态,否则无法断点续训。
- 一次实验一份配置快照 。训练启动时把当前 yaml 复制到
outputs/<时间戳>_<实验名>/config.yaml,让输出目录自带实验记录。 - 配置分层复用。基础配置放公共项(数据路径、模型类型),实验配置只写差异项,用工具(Hydra/OmegaConf)做合并覆盖,避免复制粘贴导致的多份配置各自漂移。
- 依赖声明与锁定分开 。
requirements.txt写宽松区间用于声明,另导出一份固定版本的 lock 文件用于复现旧实验。
八、面向"缝合与改造"的落点
复现他人项目时,改造点其实就那么几处,对应关系如下:
| 改造目标 | 主要落点 | 是否改代码 |
|---|---|---|
| 换骨干网络 | models/backbone.py + config 中的模型类型 |
少量 |
| 换检测/分类头、换损失 | models/head.py、trainers/trainer.py |
是 |
| 换数据集或划分 | data/dataset.py、data/dataloader.py、config 中的数据路径 |
是 |
| 缝合新模块(注意力、频域模块、状态空间模块等) | 在 models/ 下新增模块文件,在 model.py 中组装,并在 config 中加开关 |
是 |
| 跑消融实验 | config 中加布尔开关 + trainer 内做条件分支 | 极少 |
| 复测已有权重 | test.py + 新的评估配置 |
否 |
配合几条实操经验:
- 消融用配置开关,不要注释代码 。用
use_module_x: true/false这类开关控制分支,既保证主分支始终可跑,也让消融条件写在实验配置里、可追溯。 - 改造前先留一份基线配置。任何改动都应有可对照的 baseline,避免"改了以后指标涨了,但说不清是模块的功劳还是超参的功劳"。
- 新增模块保持纯函数式的接口约定。输入输出张量形状契约写进 docstring,缝合时不必通读整个项目即可接入。
- 训练与评估共用同一份数据划分。把划分清单(list 文件)固化成文件并纳入版本管理,避免每次运行重新随机划分,导致横向对比失效。
九、常见误区
- 把超参硬编码在代码里,导致一份代码只能对应一次实验。
- 在
dataset.py里做 batch 组装,破坏 DataLoader 的多进程与 sampler 机制。 - 训练逻辑全部塞进
train.py,无法被其他脚本复用,也无法单独测试。 - 混淆配置与依赖:往
requirements.txt里写超参,或往 yaml 里写 pip 包。 - 只保存权重,不保存配置与随机种子,实验无法还原。
- 用"注释掉代码"的方式做消融,主分支被污染,无法复现历史结果。
- 项目内写死绝对路径,换机器即失效。
- 权重、日志、输出直接提交版本库,仓库迅速膨胀。
十、结论与延伸
把深度学习项目抽象成一套固定骨架后,复现、缝合、改造三个动作都变成了"在确定位置做确定改动":配置决定实验,依赖决定环境,训练逻辑与入口分离,模块之间通过配置耦合。这正是 MLSE 与常规软件工程最本质的差别------前者的正确性由实验结果定义,因此工程上必须优先保证可复现、可对照、可替换。
可继续延伸的方向:
- 配置管理:Hydra/OmegaConf 实现配置分层、命令行覆盖与实验矩阵扫描。
- 实验追踪:TensorBoard 做曲线,Weights & Biases 做多实验对比与超参记录。
- 数据版本管理:DVC 或数据清单文件(manifest),把"数据"纳入与代码同等的版本管理。
- 环境固化:Docker 镜像或 conda 环境导出,解决"换机器就跑不起来"的问题。
附录 A:术语速查
| 术语 | 含义 |
|---|---|
| MLSE | Machine Learning Software Engineering,机器学习软件工程 |
| Config | 配置,控制实验参数的声明性文件 |
| Dependency | 依赖,运行时所需的外部库及其版本 |
| Backbone | 骨干网络,负责特征提取的主体结构 |
| Head | 任务头,将特征映射为任务输出(分类/检测/分割) |
| Trainer | 训练器,封装训练与验证循环逻辑的对象 |
| Checkpoint(ckpt) | 检查点,保存的模型权重与训练状态 |
| Ablation | 消融实验,通过移除/替换组件验证其贡献 |
| Hybrid 缝合 | 将不同来源的模块接入同一流水线的做法 |
附录 B:项目骨架自查清单
- 所有超参、路径、模型选项是否都能在配置文件中找到?
- 换一台机器、只按
README.md操作,项目能否跑通? - 随机种子是否固定?是否在文档中声明?
- checkpoint 是否包含优化器、调度器与步数信息,能否断点续训?
- 每个实验的配置快照是否随输出一同保存?
- 消融是否通过配置开关实现,主分支是否保持干净可跑?
-
weights/、logs/、outputs/是否已被.gitignore忽略? -
requirements.txt与train.py的 import 是否一致,有无遗漏?