万物|炼器:从零手搓工业级旋转目标检测网络·卷5 —— YAML蓝图与parse_model动态引擎(二)

1 阅读31分钟

阵图法则:YAML 蓝图与 parse_model 动态引擎(二)

5.2 斡旋造化 ── parse_model 反射引擎

阵图已经画好了,现在我们要造一台能读懂阵图、自动组装法宝的引擎。

这台引擎就是 parse_model() 函数。

5.2.1 反射:从字符串到类的跳变

在正式看 parse_model() 代码之前,我们先要理解一个关键的 Python “魔法”——反射(Reflection)。

反射机制是现代编程语言最重要的特点之一,有句很古早的话是这么说的:反射反射,程序员的快乐。

什么是反射?简单说:在运行时,用一段文字(字符串)去查找并调用对应的函数或类。

举个生活中的例子:你走进一家大型超市,手里拿着一张购物清单,上面写着“牛奶”两个字。你把清单交给店员,店员根据“牛奶”这个名字,在货架上找到真正的牛奶商品,递给你。

从“牛奶”两个字符,变成你手里真正的一瓶“牛奶”,这过程对应到编程领域,就是从一个字符串,变成与字符串对应的类或者函数,这就是语言的反射机制。

在 Python 中,这个过程有几种实现方式。

方式一:eval() —— 直接执行字符串
# eval("Conv") 就像对 Python 说:"请你帮我找到名叫 Conv 的东西"
cls = eval("Conv")        # cls 现在就是 Conv 类本身
layer = cls(3, 16, 3, 2) # 等价于 Conv(3, 16, 3, 2)

eval("Conv") 在当前环境中搜索名为 Conv 的对象——如果之前 from nn import Conv,那 Conv 就存在于当前命名空间,eval 找到后返回 Conv 类本身(不是字符串,是真正的类对象)。然后你可以像调用普通类一样,用 cls(...) 创建类的实例。

方式二:globals() —— 从全局命名空间字典查找
# globals() 返回当前模块所有全局变量的字典
# 类似于 {"Conv": <class Conv>, "C3k2": <class C3k2>, ...}
cls = globals()["Conv"]
layer = cls(3, 16, 3, 2)

globals() 返回一个字典,键是变量名(字符串),值是对应的 Python 对象。用 globals()["Conv"] 从字典中取出 Conv 类,在这个简单例子中可以达到和 eval("Conv") 相同的查找效果,但更加安全——eval() 可以执行任意表达式,谁也不知道传递给eval的是一个什么样的字符串,会不会是恶意构造的,而 globals() 只是查字典,不会额外执行字符串中的代码,相对于来说,就堵死了一个可能的风险点。

方式三:getattr() —— 从特定对象的属性中查找
import torch.nn as nn

# getattr(nn, "Upsample") 从 torch.nn 模块中查找 Upsample 类
cls = getattr(nn, "Upsample")
layer = cls(scale_factor=2, mode="nearest")

getattr(对象, "属性名") 从指定对象的属性中按名称查找。当我们需要引用 PyTorch 内置模块(如 nn.Upsample)时,用 getattr(nn, "Upsample") 最合适。

我们的 parse_model() 综合使用了方式二和方式三:自定义模块(Conv、C3k2、SPPF、Concat、SimpleHead 等)用 globals() 查找;PyTorch 内置模块(nn.Upsample 等)用 getattr() 查找。

这就是反射的威力:YAML 里写的是字符串 "Conv",通过反射变成真正的 Conv 类,然后传入参数就能创建王璐模块实例。

需要注意,这里反射解决的是:

“模块名称 → Python 类”这一层映射。

不同模块的参数组织方式和通道计算方式并不完全相同,后面的 parse_model() 仍然需要针对不同模块进行相应处理。

为什么用反射而不是 if-else?

你可能会问:直接写 if m_name == "Conv": m = Conv 不行吗?当然可以,但想象一下——Ultralytics YOLO 支持很多模块(Conv、DWConv、C2f、C3k2、SPPF、Detect、OBB、Segment、Pose……),如果每加一种模块,就要在“根据名称找到类”这一步增加一个 elif,代码会变成这样:

if m_name == "Conv":
    m = Conv
elif m_name == "C2f":
    m = C2f
elif m_name == "C3k2":
    m = C3k2
elif m_name == "SPPF":
    m = SPPF
elif m_name == "Detect":
    m = Detect
elif m_name == "OBB":
    m = OBB
# ... 继续增加 elif ...

而用 globals() 查找自定义模块,只需要:

m = globals()[m_name]

新增模块时,只需在文件顶部 import 一下,globals() 就自动包含它了。零修改、零维护。而使用if-else的话,新增一个模块,除了增加import外,还要修改一遍if判断代码,显然更加繁琐也更容易出错。


5.2.2 PyYAML库:YAML处理

反射机制解决了“字符串如何变成类”的问题。但在编写 parse_model() 之前,还有一个很自然的问题:我们上一节写好的 yolo-obb-mini.yaml,Python 是怎么把它读进来的?

这里需要使用一个新的第三方库 PyYAML,如果你的环境里没有安装的话,需要事先安装一下。

在代码中增加导入代码 import yaml,然后使用 PyYAML 库提供的 yaml.safe_load() 方法,把 YAML 文本转换为 Python 中的字典、列表、数字、字符串等对象。

例如 YAML:

nc: 2
ch: 3

经过 d = yaml.safe_load(f) 加载后,会得到:

{
    "nc": 2,
    "ch": 3
}

这里使用 safe_load(),而不是直接使用 load(),是因为 safe_load() 只解析常见的 YAML 基础数据类型,不会随意创建 YAML 中指定的 Python 对象,更适合读取模型配置文件。

到这里,两项准备工作就清楚了: yaml.safe_load() 把 YAML 文件读成 Python 数据;globals() / getattr() 把模块名称字符串找到对应的 Python 类。

接下来,就可以正式开始编写 parse_model() 引擎了。


5.2.3 依赖与辅助函数

5.2.3.1 引入依赖

首先在 ch05/model.py 文件中,按以下修改需要导入的依赖:

import ast
import math
import re
from pathlib import Path

import torch
import torch.nn as nn
import yaml

from nn import Conv, Bottleneck, C2f, C3k2, SPPF, Concat, SimpleHead

要确保上一节新增的 Concat 和 SimpleHead 模块已经从 nn/__init__.py 中导出,否则这里无法直接导入。

5.2.3.2 辅助函数:make_divisible()

在写 parse_model() 之前,先写一个小工具函数:

def make_divisible(x, divisor):
    """将 x 调整为 divisor 的倍数(向上取整)。"""
    if isinstance(divisor, torch.Tensor):
        divisor = int(divisor.max())
    return math.ceil(x / divisor) * divisor

make_divisible 函数把一个数字“对齐”到指定倍数。例如 make_divisible(14, 8)返回 16,因为:

ceil(14 / 8) × 8
= 2 × 8
= 16

为什么需要对齐到 8 的倍数?因为GPU的张量计算引擎(CUDA Cores / Tensor Cores)在通道数为8或16的倍数时效率更高。不对齐的通道数会导致内存访问浪费、计算效率下降。这是一个工程细节,但在实际训练中影响不小。


5.2.3.3 从文件到字典 :yaml_model_load()

接下来写读取yaml文档的函数yaml_model_load:

def yaml_model_load(path):
    """加载 YAML 模型配置文件。"""

    path = Path(path)

    with open(path, "r", encoding="utf-8") as f:
        d = yaml.safe_load(f)

    try:
        d["scale"] = re.search(r"yolo[v]?\d+([nslmx])", path.stem).group(1)
    except (AttributeError, TypeError):
        d["scale"] = "n"  # 默认 nano

    d["yaml_file"] = str(path)

    return d

yaml_model_load() 函数做三件事:

第一,读取 YAML 文件。通过 d = yaml.safe_load(f) 把 YAML 文本转换为 Python 字典d。

根据上一节最新的 yolo-obb-mini.yaml,转换后的字典大致是:

{
    "nc": 2,
    "ch": 3,
    "scales": {
        "n": [0.50, 0.25, 1024]
    },

    "backbone": [
        [-1, 1, "Conv", [64, 3, 2]],
        [-1, 1, "Conv", [128, 3, 2]],
        [-1, 1, "C3k2", [128, False, 0.5]],
        ...
    ],

    "head": [
        [-1, 1, "Conv", [256, 1, 1]],
        [-1, 1, "nn.Upsample", [None, 2, "nearest"]],
        ...
        [[17, 13, 9], 1, "SimpleHead", ["nc"]]
    ]
}

第二,猜测模型规模。从不带后缀的YAML文件的文件名中,通过正则表达式的匹配规则尝试提取 n/s/m/l/x。

python中的正则表达式的知识,我们这里就不详细介绍了,需要了解的请自行查阅相关资料。

我们的文件叫 yolo-obb-mini.yaml ,匹配不到标准的规模后缀,所以默认使用 d["scale"] = "n" ,也就是 Nano 版。

为什么匹配不到就能够默认是 n 呢?代码里为什么通过一个 try...except 能实现这个效果?

因为根据正则表达式的匹配规则,如果没有能够匹配上的话, re.search(r"yolo[v]?\d+([nslmx])", path.stem) 的输出是 None ,调用 None.group(1),显然会抛出一个 AttributeError类型的错误,这个错误被 catch捕获, 就有了 d["scale"] = "n" 。

第三,记录文件路径。通过代码 d["yaml_file"] = str(path) 将YAML文件的路径以字符串的形式保存在字典中,方便后续调试和追溯。


5.2.4 手搓 parse_model() 引擎

5.2.4.1 parse_model() 函数签名与初始化

请在model.py 文件继续敲下以下代码:

def parse_model(d, ch, verbose=True):
   """
    解析 YAML 字典,动态构建 PyTorch 模型。

    参数:
        d: YAML 配置字典
        ch: 输入通道数(通常为 3)

    返回:
        (nn.Sequential, list): 模型层序列和需要缓存输出的层索引列表
    """

    max_channels = float("inf")
    nc = d.get("nc", 2)
    scales = d.get("scales")
    depth, width = 1.0, 1.0

先来看这几个变量分别表示什么。

max_channels = float("inf"):先把最大通道数初始化为无穷大,也就是默认不限制通道数。后面如果 YAML 中定义了 scales,再用其中的实际值替换它。

nc = d.get("nc", 2):从 YAML 字典中读取类别数 nc。如果没有找到,就默认使用 2。

scales = d.get("scales"):读取 YAML 中的模型缩放配置,并保存到 scales 中。

depth, width = 1.0, 1.0:先把深度倍率和宽度倍率都设置为 1.0,也就是默认不进行缩放。

由于 parse_model方法的代码比较长,为了更好的显示代码缩进关系,除了本部分需要录入的代码外,我会将之前已经录入的代码以简写的方式显示出来,请大家在录入代码的时候务必注意。

接下来,根据模型规模读取真正的缩放参数,请继续写入以下代码:

def parse_model(d, ch, verbose=True):
    """
    …………
    """
    …………
    depth, width = 1.0, 1.0
    
    if scales:
        scale = d.get("scale", "n")
        depth, width, max_channels = scales[scale]

如果 YAML 中定义了 scales,首先通过:

scale = d.get("scale", "n")

确定当前使用哪个模型规模。这里 d.get("scale", "n")表示如果字典d中有 scale字段,就取这个字段对应的值(还记得吗,这个值来自于对yaml文件名的推测),如果没有,就默认为 n,相当于这里再次设置了一个默认值保险。

然后通过 scales[scale] 得到对应的缩放系数,我们这里就是 [0.50, 0.25, 1024],最后按照顺序,把其中三个值分别赋给 depth, width, max_channels:

depth = 0.50
width = 0.25
max_channels = 1024

然后打印表头,请继续输入以下代码,务必注意代码的缩进:

def parse_model(d, ch, verbose=True):
    ……
    
    if scales:
        ……
        
    if verbose:
       print(f"\n{'':>3}{'from':>20}{'n':>3}{'params':>10}"
             f"{'module':<40}{'arguments':<30}")

如果用户传入verbose参数为True,也就是要求打印模型结构表的表头,这段代码输出一个表格头,后面每解析一层就打印一行,方便用户直观看到整个模型结构。

继续写入以下代码,初始化 parse_model() 引擎的几个核心变量:

def parse_model(d, ch, verbose=True):
    ……
        
    if verbose:
         ……
         
    ch = [ch] # 每层输出通道数的列表, ch[i] = 第i层的输出通道
    layers, save, c2 = [], [], ch[-1]
    
    # 合并 backbone + head 的所有层定义
    all_layers = d["backbone"] + d["head"]

ch = [ch]:把初始通道数包装成列表。

后面每解析一层,就把该层的输出通道数追加到这个列表里。

layers:存放所有实例化后的模块。

save:存放需要被缓存的层索引,也就是后面会被其他层通过 from 再次引用的层。

c2:当前层的输出通道数,初始化为输入通道数。

d["backbone"] + d["head"] 通过字典叠加的方式,把 Backbone 和 Head 的层列表拼接起来,统一编号,为后续逐行遍历做好准备。


5.2.4.2 引擎主循环

请继续输入以下代码,要特别注意代码缩进:

def parse_model(d, ch, verbose=True):
    ……
    
    if verbose:
       ……
    ……

    for i, (f, n, m_name, args) in enumerate(all_layers):
        # === 步骤1: 字符串 → Python 类 ===
        # "nn.Upsample" → torch.nn.Upsample
        # "Conv"        → 我们 nn 包中的 Conv 类
        if m_name.startswith("nn."):
            m = getattr(nn, m_name[3:])
        else:
            # 从当前模块的全局命名空间查找(即上面 import 的类)
            m = globals()[m_name]
            
        args = list(args)

引擎的主循环for i, (f, n, m_name, args) in enumerate(all_layers) 中:

i      → 层索引
f      → from
n      → repeats
m_name → module
args   → args

分别对应 YAML 中的 [from, repeats, module, args]。

步骤 1:字符串 → Python 类(反射)

主循环中的以下代码,通过反射机制获取对应的类:

        if m_name.startswith("nn."):
            m = getattr(nn, m_name[3:])
        else:
            m = globals()[m_name]

如果模块名以 "nn." 开头,例如 nn.Upsample ,说明它是 PyTorch 内置模块。通过 m_name[3:] 去掉前缀 "nn.",得到 Upsample,然后使用 getattr(nn, "Upsample")获取真正的 nn.Upsample 类。

否则,模块名不是以 "nn." 开头,说明它是我们自定义的模块,比如 Conv、C3k2、SPPF、Concat、SimpleHead 这些,就用 globals()[m_name]从当前文件的全局命名空间中获取对应类。

这完全就是 5.2.1 节讲的反射机制的实际应用。

此时,变量 m 已经不是字符串,而是真正的 Python 类对象了。


步骤 2:解析参数中的字符串表达式

继续录入以下代码:

def parse_model(d, ch, verbose=True):
    ……    

    for i, (f, n, m_name, args) in enumerate(all_layers):
        # === 步骤1: 字符串 → Python 类 ===           
        ……
        
        # === 步骤2: 解析参数中的字符串表达式 ===
        for j, a in enumerate(args):
            if isinstance(a, str):
                try:
                    args[j] = locals()[a] if a in locals() else ast.literal_eval(a)
                except (ValueError, SyntaxError):
                    pass

YAML 中的某些参数可能是字符串形式的变量名或表达式。比如检测头 [[17, 13, 9], 1, SimpleHead, [nc]], 其中的 nc 被读取后是字符串 "nc",但我们真正需要的是数值 2。

locals()[a]:在当前函数的局部变量中查找变量 a ,如果 a确实是当前函数的局部变量。例如:

a = "nc"
nc = 2

locals()[a]就返回 2。

如果它不是局部变量名(a in locals() 返回false),就通过 ast.literal_eval(a)尝试把字符串解析为 Python 字面量,例如:

"True"     → True
"[1,2,3]"  → [1,2,3]

这比 eval() 安全,因为 ast.literal_eval() 只允许解析有限的 Python 字面量,不能执行任意代码。

如果解析不了,例如 "nearest" 本身就是普通字符串,就保持原样。


步骤 3:应用 depth 缩放

继续写入以下代码,注意缩进:

def parse_model(d, ch, verbose=True):
    ……

    for i, (f, n, m_name, args) in enumerate(all_layers):
        # === 步骤1: 字符串 → Python 类 ===           
        ……
        
        # === 步骤2: 解析参数中的字符串表达式 ===
        for j, a in enumerate(args):
            ……

        # === 步骤3: 应用 depth 缩放(控制模块重复次数)===            
        n = n_ = max(round(n * depth), 1) if n > 1 else n

这一行有点密集,但意思是非常清晰的。

这里的 n 表示 YAML 中的 repeats,也就是模块的基础重复次数。

如果 YAML 写的 repeats=1,就直接保持 1。只有 repeats > 1 时,才会根据 depth 对重复次数进行缩放。

为什么要有个 n_ 呢?

这是因为后面处理 C3k2 这类模块时,n 还会被修改。

C3k2 的构造函数本身就有一个 n 参数 C3k2(c1, c2, n=1, ...),它用来控制模块内部子结构的重复次数。

因此,如果经过深度缩放以后得到 n = 2,后面会把这个 2 作为参数传给 C3k2,相当于 C3k2(c1, c2, n=2, ...),既然重复次数已经交给 C3k2 内部处理,外层就只需要创建一个 C3k2 模块,因此后面还会把 n 设置 1,避免在外面再重复创建多个完整的 C3k2。

而 n_ 不再修改,始终保存深度缩放后的重复次数,主要用于后面打印模型结构。


步骤 4:计算模块输入输出通道数

前面我们已经知道了当前这一层要创建哪个模块,接下来还需要解决另一个核心问题这个模块需要哪些参数?它的输入通道数和输出通道数分别是多少?

这也是 parse_model() 中比较关键的一部分。

不同模块处理通道的方式并不一样。例如:

  • Conv、C3k2 等模块需要计算输入、输出通道;
  • Concat 会把多个输入的通道数相加;
  • Upsample 只改变特征图大小,不改变通道数;
  • SimpleHead 则需要同时知道 P3、P4、P5 三个尺度的输入通道数。

所以这里要根据模块类型分别处理。

请继续输入下面这段代码,注意这些代码仍然位于 for 循环内部,缩进不要改变:

def parse_model(d, ch, verbose=True):
    ……
   
    for i, (f, n, m_name, args) in enumerate(all_layers):
        # === 步骤1: 字符串 → Python 类 ===           
        ……
        
        # === 步骤2: 解析参数中的字符串表达式 ===
        for j, a in enumerate(args):
            ……

        # === 步骤3: 应用 depth 缩放(控制模块重复次数)===            
        ……
        
        # === 步骤4: 根据模块类型计算通道数 ===
        if m in {Conv, Bottleneck, SPPF, C2f, C3k2}:
            c1 = ch[f] if isinstance(f, int) else ch[f[0]] # 输入通道
            # YAML中指定的输出通道
            c2 = args[0]
            # 应用 width 缩放 + max_channels 限制
            c2 = make_divisible(min(c2, max_channels) * width,8)

            args = [c1, c2, *args[1:]]
            
            # 对 C2f/C3k2 等块,将 repeats 插入构造参数
            if m in {C2f, C3k2}:
                args.insert(2, n)
                n = 1 # 不再外层重复,改为内部重复
                
        elif m is Concat:
            # Concat 的输出通道 = 所有输入层通道数之和
            c2 = sum(ch[x] for x in f)

        elif m is SimpleHead:
            # 检测头: 需要知道各输入层的通道数
            args.append([ch[x] for x in f])
            c2 = nc + 8

        elif m is nn.Upsample:
            # Upsample 不改变通道数
            c2 = ch[f] if isinstance(f, int) else ch[f[0]]

        else:
            c2 = ch[f] if isinstance(f, int) else ch[f[0]]

从整体上看,这一大段代码其实只是在做一件事:根据当前模块的类型,计算它需要的通道数,并把 YAML 中的参数整理成真正创建模块时需要的参数。 下面再分别来看。


1、Conv、C3k2 等特征模块

首先是 if m in {Conv, Bottleneck, SPPF, C2f, C3k2}:,这些模块有一个共同特点:它们都有明确的输入通道数 c1 和输出通道数 c2 ,可以用同一套逻辑来处理。

对于这几个模块,我们先把这两个值计算出来。

输入通道数c1 看代码:c1 = ch[f] if isinstance(f, int) else ch[f[0]]。

这里的 ch 保存着前面每一层的输出通道数,例如:

ch[0] → 第0层输出通道数,比如16
ch[1] → 第1层输出通道数,比如64
ch[2] → 第2层输出通道数,比如128
...

而 f 就是 YAML 中的 from,它常见的是一个整数,比如“-1”,表示输入来自上一层,或者是一个列表,比如“[-1, 6]”,表示输入来自于还几个不同层。

当 f 是整数时,c1 就等于 ch[f] ,如果 f 是 -1,那 c1 = ch[-1],表示其输入通道数是上一层的输出通道数。也就是说上一层输出多少通道,当前层就接收多少通道。

当前 YAML 中,Conv、C3k2、SPPF 这些模块都是单输入模块,因此 f 实际上都是一个整数。

代码中保留了 else ch[f[0]], 是为了增加代码的通用性。如果以后遇到列表形式的 from,至少能够取得其中第一个来源的通道数。

真正像 Concat 这样需要同时处理多个输入的模块,后面会单独处理。


输出通道数 c2 看代码 c2 = args[0]。

上一节已经讲过,YAML 中 args 是指定模块的参数,其顺序是对应模块构造函数的参数顺序。输入通道 c1 刚刚已经自动计算出来了,所以args中的第一个参数 args[0],对应的就是模块的基准输出通道数 c2。

c2 = args[0]就是先把 YAML 中定义的基准输出通道数取出来。

注意,此时的 c2 还不是 Nano 模型真正使用的输出通道数,因为还有个缩放系数。

接下来,我们要调用make_divisible()方法:

c2 = make_divisible(
    min(c2, max_channels) * width,
    8
)

这一行就是把 YAML 中的基准通道数转换成当前模型真正使用的通道数。这段代码的计算逻辑很清晰,就不再详细介绍了。

现在,当前模块 c1 、c2 具体的值已经计算出来了。接下来自然是把它们加入到 args 中,形成真正的模块构造函数的参数:

args = [c1, c2, *args[1:]]

举例例子,原来YAML中的 args = [64, 3, 2],其中:

64 → 基准输出通道
3  → 卷积核
2  → stride

经过处理后变成 args = [3, 16, 3, 2],也就是“输入通道”、“输出通道”、“卷积核大小”、“stride”,就可以直接用于模块的构建了:

Conv(*args)

等价于 Conv(3, 16, 3, 2),这里体现了 YAML 动态建模中一个很重要的设计:

YAML 不需要手工填写输入通道数 c1 ,因为 parse_model() 可以根据 from 自动推算。

我们刚刚在讲 n 、 n_ 的时候说,C2f、C3k2两个模块有点特殊的,这就要我们特殊情况特殊处理一下:

if m in {C2f, C3k2}:
    args.insert(2, n)
    n = 1

C2f、C3k2 和普通 Conv 有一点不同。以 C3k2 为例,它的构造函数本身就有:

C3k2(c1, c2, n=1, c3k=False, e=0.5, ...)

其中的 n 就是模块内部子结构的重复次数。因此,前面经过 depth 缩放后得到的 n,需要作为参数传进 C3k2。例如此时:

args = [32, 32, False, 0.5]
n = 2

就需要执行:

args.insert(2, n)

注意,索引 2 表示的是第 3 个元素的位置,也就是插在 c1、c2 后面。于是得到 [32, 32, 2, False, 0.5] ,最终构建的模块相当于:

C3k2(
    32,
    32,
    n=2,
    c3k=False,
    e=0.5
)

既然重复次数已经交给 C3k2 内部处理,外面就只需要创建一个 C3k2 模块,所以 n 要重新设置为 1,表示外层不再重复创建整个 C3k2 。


2、Concat:多个输入通道直接相加

接下来处理Concat 模块:

elif m is Concat:
    c2 = sum(ch[x] for x in f)

Concat 和前面的模块不同,它不会重新生成一组新的特征通道,而是把多个特征沿通道维拼接起来,所以Concat 的输出通道数 c2 = 所有输入特征的通道数之和。 我们通过一个循环依次取出指定通道的输出通道数然后加起来。

3、 SimpleHead:三个尺度的输入通道

接下来处理SimpleHead模块:

elif m is SimpleHead:
    args.append([ch[x] for x in f])
    c2 = nc + 8

SimpleHead 同样不是普通的单输入模块。YAML 最后一层 [[17, 13, 9], 1, SimpleHead, [nc]] 表示它同时接收三层的输入,因此 [ch[x] for x in f] 依次取得这三层的通道数,然后通过 args.append() 方法把这个列表插入到args中,形成新的args,比如原来 args = [2], args.append() 之后就变成:

args = [2, [32, 64, 128]]

最终相当于:

SimpleHead(
    nc=2,
    ch=[32, 64, 128]
)

正好对应 SimpleHead 的三个临时预测头。另外 c2 = nc + 8 表示当前每个预测分支的输出通道数。

4、nn.Upsample:通道数保持不变

对于上采样模块nn.Upsample:

elif m is nn.Upsample:
    c2 = ch[f] if isinstance(f, int) else ch[f[0]]

Upsample 只改变特征图的空间尺寸,不改变通道数。所以直接让 输出通道数 = 输入通道数即可。

5、其他模块:暂时保持通道数不变

最后 c2 = ch[f] if isinstance(f, int) else ch[f[0]] 是一个兜底处理。对于当前没有单独定义通道计算规则的模块,暂时认为 输出通道数 = 输入通道数,

我们目前 YAML 中真正使用到的主要模块,都已经在前面分别进行了处理。

到这里,模块对应的构建参数,就都已经处理完成了。看起来代码分支很多,但核心目标始终只有一个:

在真正创建模块之前,先根据 YAML 中的网络连接关系,把这一层需要的参数准备完整。

步骤 5:完成当前层的构建

经过前面的处理,我们已经知道了:

  • 当前这一层是什么模块;
  • 它的输入来自哪里;
  • 输入、输出通道数是多少;
  • 最终需要传入哪些参数;
  • 是否需要重复。

接下来,就可以真正创建这一层,并把这一层相关的信息记录下来备用了。

这一部分代码仍然位于 for 循环内部。为了避免被缩进弄乱,建议先把完整代码输入,再逐段理解:

def parse_model(d, ch, verbose=True):
    ……
    
    for i, (f, n, m_name, args) in enumerate(all_layers):
        # === 步骤1: 字符串 → Python 类 ===
        ……        
        # === 步骤2: 解析参数中的字符串表达式 ===
        for j, a in enumerate(args):
            ……
        # === 步骤3: 应用 depth 缩放(控制模块重复次数)===           
        ……        
        # === 步骤4: 根据模块类型计算通道数 ===
        if m in {Conv, Bottleneck, SPPF, C2f, C3k2}:
            ……                
        elif m is Concat:
            ……
        else:
            ……
            
        # === 步骤5: 实例化模块 ===
        m_ = nn.Sequential(*(m(*args) for _ in range(n))) if n > 1 else m(*args)

        # 记录当前层的信息
        t = m.__name__ # 类型名

        m_.np = sum(x.numel() for x in m_.parameters()) # 参数量
        m_.i = i     # 层索引
        m_.f = f     # 输入来源
        m_.type = t  # 类型字符串

        # 打印当前层信息
        if verbose:
            print(f"{i:>3}{str(f):>20}{n_:>3}{m_.np:10.0f} "
                  f"{t:<40}{str(args):<30}")

        # 如果某层被其他层引用 (from != -1),则需要缓存其输出
        save.extend( x % i for x in ([f] if isinstance(f, int) else f) if x != -1)

        # 保存当前模块
        layers.append(m_)

        # 记录当前层输出通道数
        if i == 0:
            ch = []

        ch.append(c2)

这段代码做的事情,可以概括成:

创建当前模块
    ↓
记录当前层的信息
    ↓
检查哪些层以后还要继续使用
    ↓
把当前层加入模型
    ↓
记录当前层的输出通道数

下面分别来看。


1、实例化当前模块
m_ = (
    nn.Sequential(*(m(*args) for _ in range(n)))
    if n > 1
    else m(*args)
)

终于到了“实例化”这一步。

m(*args) 表示用反射得到的类 m 和计算好的参数 args 创建模块实例。

例如:

m = Conv
args = [3, 16, 3, 2]

那么 m(*args) 就等价于:

Conv(3, 16, 3, 2)

如果 n > 1,说明需要在外部重复多个相同模块,就用 nn.Sequential 串联起来。

不过在我们当前的 Nano 教学模型中,经过深度缩放和 C3k2 的特殊处理后,实际外部 n 基本都是 1。

2、记录当前层的信息

模块创建完成以后,继续:

t = m.__name__

m_.np = sum(x.numel() for x in m_.parameters())
m_.i = i
m_.f = f
m_.type = t

这几行代码是在给每个模块打上“身份标签”。

  • m_.i = i:这是第几层。

  • m_.f = f:这一层的输入来自哪里。

  • m_.np:参数量,用于打印。

  • m_.type:模块类型名称,用于打印。

后面的动态 forward() 会使用 i 和 f 来决定从哪里取输入、哪些层需要缓存。

m_.parameters() 可以取得模块中的所有参数,x.numel()表示某个参数张量中一共有多少个数值。把它们全部加起来,就是当前模块的总参数量。

3、打印当前层信息
if verbose:
    print(
        f"{i:>3}{str(f):>20}{n_:>3}{m_.np:10.0f}  "
        f"{t:<40}{str(args):<30}"
    )

把当前这一层的重要信息打印出来。

主要包括:

i       → 层号
f       → 输入来自哪里
n_      → 深度缩放后的 repeats
m_.np   → 参数量
t       → 模块名称
args    → 最终创建模块时使用的参数

这样整个模型解析完成以后,就可以在终端中看到一张完整的网络结构表。

4、构建 save list
save.extend(
    x % i
    for x in ([f] if isinstance(f, int) else f)
    if x != -1
)

当某一层的 from 引用了之前的某一层(不是 -1),那被引用层的输出后面还要再次使用,所以我们需要提前记录哪些层的输出以后还会再次使用。 这就是 save 列表的作用。

例如 YAML 第 12 层是 [[-1, 6], 1, Concat, [1]],所以第 6 层需要缓存。同理 [[-1, 4], 1, Concat, [1]],需要缓存第 4 层, [[17, 13, 9], 1, SimpleHead, [nc]],还会引用第 17、13、9 层。因此我们之前写的 YAML 最终需要缓存的关键层为: [4, 6, 9, 13, 17]。

[f] if isinstance(f, int) else f 这一行代码的作用是:如果 f 是整数,就先把它包装成列表,如果本来就是列表,那就直接使用。

接下来 if x != -1 表示不处理-1 ,因为-1 表示“上一层”,不用额外缓存,当前变量天然保存着上一层输出。

x % i 主要用于处理负数形式的相对层索引。例如当前正在构建第10层,也就是 i = 10,如果 YAML 中写:from = -3,这表示 从当前层向前数3层,也就是第 7 层。

而 Python 中 -3 % 10 结果正好是7。因此 x % i 可以把这种负数形式的相对引用转换成真正的层索引。如果本来就是正数,则不会改变。

5、保存当前模块

接下来 layers.append(m_) 把前面创建好的网络层保存到 layers 列表中。

整个 YAML 全部解析结束后,layers中就保存了所有网络模块。

6、记录当前层的输出通道数

最后还要把当前层的输出通道数记录下来:

if i == 0:
    ch = []

ch.append(c2)

为什么需要不断维护 ch 呢?

因为后面的层还要通过 ch[f] 查询前面某一层的输出通道数。所以每创建完成一层,就必须把这一层的输出通道数 c2 加入 ch。

这里有一个看起来有些奇怪的处理:

if i == 0:
    ch = []

为什么第 0 层构建完成以后,要把 ch 清空一次?

回忆 parse_model() 刚开始时,我们写了: ch = [ch] ,如果输入是 RGB 图像:ch = [3],这里的3表示原始输入图像的通道数。

在构建第 0 层时,这个 3 非常重要。因为 c1 = ch[-1],于是第 0 层知道自己的输入是 3 通道。

但是,第 0 层构建完成以后,我们希望:

ch[0] → 第0层输出通道
ch[1] → 第1层输出通道
ch[2] → 第2层输出通道
...

如果不把最开始的 [3] 清除,就会变成:

ch[0] → 原始输入
ch[1] → 第0层输出
ch[2] → 第1层输出
...

这样层号就全部错开了一位。

所以第 0 层构建完成后需要执行:

if i == 0:
    ch = []

这实际上是在完成一次 ch 列表索引与模型层号的对齐。

步骤 6:循环结束:返回完整模型

前面的代码会在 for 循环中不断执行:

读取一层 YAML
    ↓
整理参数
    ↓
创建模块
    ↓
记录结构信息
    ↓
更新 save
    ↓
保存输出通道
    ↓
继续解析下一层

直到 YAML 中的所有层都构建完成。这时退出 for 循环,最后执行:

def parse_model(d, ch, verbose=True):
    ……
    
    for i, (f, n, m_name, args) in enumerate(all_layers):
        # === 步骤1: 字符串 → Python 类 ===
        ……
       
        # === 步骤5: 实例化模块 ===
        ……
       
    return nn.Sequential(*layers), sorted(set(save))

请把最后一行代码加入文件中,注意这一行已经不在 for 循环内部,请注意他的缩进。

nn.Sequential(*layers) 把 layers 中保存的所有模块组织成一个完整的模型层序列。

sorted(set(save)) 完成对所有层的排序。

其中:set(save)去掉可能重复出现的层号;然后使用 sorted(...)方法按照从小到大的顺序排列。

因此,parse_model()引擎最终返回两个结果:

model, save = parse_model(...)

其中: model 表示根据 YAML 自动构建出来的模型层序列;save 表示动态 forward 时需要保留的中间层。

到这里,YAML 中写下的网络结构,已经真正变成了 PyTorch 模块。

parse_model()引擎构建完成。


5.2.5 通道推导的完整流程:以前三层为例

让我们用实际数据走一遍前三层的解析过程,帮你建立直觉。

初始状态:
ch = [3]
depth = 0.50
width = 0.25
第 0 层
[-1, 1, Conv, [64, 3, 2]]

解析:

反射:
"Conv" → Conv 类

depth缩放:
n = 1,不缩放

通道计算:
c1 = ch[-1] = 3

c2 = 64
   → min(64, 1024) × 0.25
   → 16
   → make_divisible(16, 8)
   → 16

args = [3, 16, 3, 2]

实例化 Conv(3, 16, 3, 2)

此时 ch = [16]

第 1 层
[-1, 1, Conv, [128, 3, 2]]

解析:

c1 = ch[-1] = 16

c2 = 128
   → min(128, 1024) × 0.25
   → 32

args = [16, 32, 3, 2]

实例化 Conv(16, 32, 3, 2)

此时 ch = [16, 32]。

第 2 层
[-1, 1, C3k2, [128, False, 0.5]]

解析:

depth缩放:
n = 1,不缩放

c1 = ch[-1] = 32

c2 = 128
   → min(128, 1024) × 0.25
   → 32

原始 args:
[128, False, 0.5]

补入 c1、实际 c2:
[32, 32, False, 0.5]

插入 C3k2 内部 repeats:
[32, 32, 1, False, 0.5]

实例化:

C3k2(
    32,
    32,
    n=1,
    c3k=False,
    e=0.5
)

此时 ch = [16, 32, 32]

看到了吗?通道数开始一层层自动传递:

3 → 16 → 32 → 32 → 64 → 64 → 128 → ...

这就是“通道推导”的核心思想。

下一节,我们就要启动这个“引擎”,从 yaml 文件自动构建模型。


GitHub地址:The-Art-of-Forging-Object-Detectors

欢迎:

  • ⭐ Star
  • 提交 Issue
  • 参与讨论
  • 帮助纠错

附录

parse_model 函数的代码比较长,中间缩进很多,为了方便理解,讲解得时候代码切分的比较少碎,为了方便大家录入代码,把parse_model 函数以附录的形式放在最后,供大家参考。

parse_model 函数完整代码:

def parse_model(d, ch, verbose=True):
    """
    解析 YAML 字典,动态构建 PyTorch 模型。

    参数:
        d: YAML 配置字典
        ch: 输入通道数(通常为 3)

    返回:
        (nn.Sequential, list): 模型层序列和需要缓存输出的层索引列表
    """

    max_channels = float("inf")
    nc = d.get("nc", 2)
    scales = d.get("scales")
    depth, width = 1.0, 1.0
    
    if scales:
        scale = d.get("scale", "n")
        depth, width, max_channels = scales[scale]
        
    if verbose:
       print(f"\n{'':>3}{'from':>20}{'n':>3}{'params':>10}"
             f"{'module':<40}{'arguments':<30}")
    ch = [ch] # 每层输出通道数的列表, ch[i] = 第i层的输出通道
    layers, save, c2 = [], [], ch[-1]
    
    # 合并 backbone + head 的所有层定义
    all_layers = d["backbone"] + d["head"]

    for i, (f, n, m_name, args) in enumerate(all_layers):
        # === 步骤1: 字符串 → Python 类 ===
        # "nn.Upsample" → torch.nn.Upsample
        # "Conv"        → 我们 nn 包中的 Conv 类
        if m_name.startswith("nn."):
            m = getattr(nn, m_name[3:])
        else:
            # 从当前模块的全局命名空间查找(即上面 import 的类)
            m = globals()[m_name]
            
        args = list(args)
        
        # === 步骤2: 解析参数中的字符串表达式 ===
        for j, a in enumerate(args):
            if isinstance(a, str):
                try:
                    args[j] = locals()[a] if a in locals() else ast.literal_eval(a)
                except (ValueError, SyntaxError):
                    pass
        # === 步骤3: 应用 depth 缩放(控制模块重复次数)===            
        n = n_ = max(round(n * depth), 1) if n > 1 else n
        
        # === 步骤4: 根据模块类型计算通道数 ===
        if m in {Conv, Bottleneck, SPPF, C2f, C3k2}:
            c1 = ch[f] if isinstance(f, int) else ch[f[0]] # 输入通道
            # YAML中指定的输出通道
            c2 = args[0]
            # 应用 width 缩放 + max_channels 限制
            c2 = make_divisible(min(c2, max_channels) * width,8)

            args = [c1, c2, *args[1:]]
            
            # 对 C2f/C3k2 等块,将 repeats 插入构造参数
            if m in {C2f, C3k2}:
                args.insert(2, n)
                n = 1 # 不再外层重复,改为内部重复
                
        elif m is Concat:
            # Concat 的输出通道 = 所有输入层通道数之和
            c2 = sum(ch[x] for x in f)

        elif m is SimpleHead:
            # 检测头: 需要知道各输入层的通道数
            args.append([ch[x] for x in f])
            c2 = nc + 8

        elif m is nn.Upsample:
            # Upsample 不改变通道数
            c2 = ch[f] if isinstance(f, int) else ch[f[0]]

        else:
            c2 = ch[f] if isinstance(f, int) else ch[f[0]]
            
        # === 步骤5: 实例化模块 ===
        m_ = nn.Sequential(*(m(*args) for _ in range(n))) if n > 1 else m(*args)

        # 记录当前层的信息
        t = m.__name__ # 类型名

        m_.np = sum(x.numel() for x in m_.parameters()) # 参数量
        m_.i = i     # 层索引
        m_.f = f     # 输入来源
        m_.type = t  # 类型字符串

        # 打印当前层信息
        if verbose:
            print(f"{i:>3}{str(f):>20}{n_:>3}{m_.np:10.0f} "
                  f"{t:<40}{str(args):<30}")

        # 如果某层被其他层引用 (from != -1),则需要缓存其输出
        save.extend( x % i for x in ([f] if isinstance(f, int) else f) if x != -1)

        # 保存当前模块
        layers.append(m_)

        # 记录当前层输出通道数
        if i == 0:
            ch = []

        ch.append(c2)    
    return nn.Sequential(*layers), sorted(set(save))