卷4 · 庖丁解牛 模块化重构(一)
经过前3卷的历练,你的"乾坤袋"里已经有了拿得出手的阵法,手里的法器也隐隐有了"神器"的雏形。它的筋骨里有Conv+BN+SiLU的铁三角,有C3k2的残差高速公路,有SPPF的感受野放大器。虽然喂的仍是虚幻灵石(随机张量),但训练循环已经可以转得飞起,Loss也能像模像样的输出。
然而,一个新的问题也越来越明显------所有代码仍然挤在同一个 main.py 文件里。
最开始代码少,这样写没有问题。所有内容放在一起,甚至更方便我们沿着执行顺序理解模型到底是怎样工作的。
但到了第3卷,main.py 中已经同时出现了算子定义、数据集、模型、训练循环、验证函数等内容。如果后面继续加入真正的检测头、Loss、数据集、配置解析等代码,这个文件很快就会变得越来越难维护。
所以这一卷,我们先不急着继续增加新的网络结构,而是做一件看起来"没有增加功能",却非常重要的事情:
把代码按照职责拆开,放进不同的文件,让每个文件只负责自己该做的事情。
这就是软件工程中最基本、也最重要的思想之一------模块化。
模块化不会让模型的 mAP 凭空上涨,也不会让 C3k2 突然变强,但它会让你在后续漫长的旅途中不至于被自己的代码淹死,会让我们的代码从"能跑"进一步走向"能维护、能扩展、能继续长大"。
4.1 混沌的枷锁 ── 单体架构的工程危机
4.1.1 面条代码的诅咒:牵一发动全身
先回头看看第3卷最后完成的 main.py。
按照代码承担的职责,大致可以整理成下面这样:
text
ch03/main.py
├── import 语句
│
├── autopad
├── Conv
├── Bottleneck
├── C2f
├── C3k2
├── SPPF
│
├── MultiscaleFakeDataset
├── collate_fn
│
├── YOLOLikeDetector
│
├── evaluate
├── train_model
│
└── if __name__ == "__main__"
第3卷前面还做过梯度消失实验,那些代码主要用于帮助我们理解梯度为什么会消失,以及为什么后来要引入 BN、SiLU 和残差连接。到了这一卷,我们关注的是最终保留下来的模型训练主线。
看起来似乎还挺整齐,但问题在于:这些代码虽然都写在同一个文件里,却根本不是在做同一件事。
比如,我们想在另一个模型中复用 C3k2。
表面上看,好像只需要复制 C3k2 这一段代码,但第3卷已经分析过它内部的依赖关系:
text
C3k2
↓
C2f
↓
Bottleneck
↓
Conv
↓
autopad
你复制了 C3k2,很快就会发现还需要 C2f;复制了 C2f,又发现还需要 Bottleneck;继续往下追,最后 Conv 和 autopad 也得一起搬走。
这就像从一碗面条里抽出一根------你刚拽起来,发现下面还缠着好几根。
再比如多人协作。
一个人修改模型结构,另一个人修改数据处理,两个人都在改同一个 main.py。代码一多,Git 冲突的概率自然也会越来越高。即使只有自己一个人开发,文件越来越长以后,也经常会出现这种感觉: 我只是想改模型,为什么还要在数据集、训练循环和保存权重的代码之间来回翻? 这就是单文件脚本逐渐变大导致的核心问题:
代码挤在一个文件里,彼此的职责边界越来越模糊,修改一个地方时,也越来越容易影响其他部分。
需要注意的是,这并不代表我们前几卷把所有代码写进 main.py 是错的。
恰恰相反,在学习阶段,这样做反而更容易理解完整的计算流程。但现在模型准备继续往后扩展,就到了该"拆"的时候。Ultralytics 的 YOLO 工程有几万行代码,如果所有算子、模型、数据、训练逻辑都塞在一个文件里,项目很快就炸了。
4.1.2 架构分界线:高内聚与低耦合
庖丁解牛之所以能"手之所触,肩之所倚,足之所履,膝之所踦"都恰到好处,是因为他看到的不是一整头牛,而是筋骨之间的天然间隙。
代码也有天然间隙。我们要做的就是找到这些间隙,沿着间隙切开。
切分有一个核心原则,概括起来就是:
高内聚,低耦合。
先说高内聚(High Cohesion)。
所谓高内聚,可以简单理解为:同一个模块里的代码,应该尽量在做同一类事情。
例如:
autopad、Conv都属于基础卷积相关组件;Bottleneck、C2f、C3k2、SPPF都属于神经网络结构模块;MultiscaleFakeDataset、collate_fn都属于数据准备;evaluate、训练循环都属于训练与验证流程。
这些代码各自目标一致,放在一起就比较自然。
反过来,如果一个文件中同时出现 Conv、Dataset、Optimizer、权重保存等完全不同职责的代码,那么这个文件的内聚度就比较低。
再说低耦合(Low Coupling)。
低耦合指的是:模块之间应该尽量少了解彼此的内部细节,只通过清晰的接口进行调用。
例如,后面的 model.py 需要使用 Conv 和 C3k2,它只需要导入并使用这些模块:
python
from nn.conv import Conv
from nn.block import C3k2, SPPF
至于 Conv 内部到底怎样调用 autopad,C3k2 内部又经过了哪些 Bottleneck,model.py 不需要知道。同样,训练代码也不需要知道模型内部究竟有多少个 C3k2。它只需要写下 outputs = model(images)这行代码,能够正常拿到模型输出即可。
所以可以把"低耦合"简单理解成:
模块之间只关心对方能提供什么,不去干涉对方内部到底怎么实现。
按照这个思路,第3卷的 main.py 就可以拆成:
text
ch04/
├── main.py
├── model.py
├── dataset.py
├── trainer.py
│
└── nn/
├── __init__.py
├── conv.py
└── block.py
各个文件的职责也随之变得清晰:
| 文件 | 主要内容 | 职责 |
|---|---|---|
nn/conv.py |
autopad、Conv |
基础卷积组件 |
nn/block.py |
Bottleneck、C2f、C3k2、SPPF |
网络结构模块 |
model.py |
YOLOLikeDetector |
负责组装模型 |
dataset.py |
MultiscaleFakeDataset、collate_fn |
数据准备 |
trainer.py |
evaluate、训练流程 |
训练、验证和权重保存 |
main.py |
程序入口 | 负责启动整个程序 |
模块化之后,模型本身并不会突然变强。P3、P4、P5 还是原来的三个尺度,C3k2 和 SPPF 的计算逻辑也没有改变,训练时仍然使用第三卷的那套教学数据和训练流程。
变化的是代码结构。原来的 main.py 是"什么都管",而重构以后,它应该逐渐退回到真正的程序入口,只负责把模型、数据和训练流程组织起来。
接下来,我们就正式开始"下刀"。