1. 从一次模型重构翻车说起
前阵子帮一位做模型部署的朋友排查一个诡异问题:他训练好的一个多分支网络,推理阶段输出和训练阶段对不上,误差不大但稳定存在,排查了数据预处理、权重加载、归一化层,全都没问题。最后定位到罪魁祸首——他把一个本该用ModuleList存的多分支子模块,图省事塞进了Sequential,结果前向传播时模块被顺序执行,分支结构直接退化成了一条链,训练时靠额外的旁路逻辑勉强跑通,推理时旁路没接上,输出自然就偏了。
这个坑其实非常典型。torch.nn.ModuleList和torch.nn.Sequential在 PyTorch 里长得像、用起来也像,很多刚上手的人会随手混用,但只要涉及多分支、残差、动态层数、条件执行这些场景,两者的行为差异就会立刻暴露。这篇文章就把这两个容器从设计动机、内部机制、参数注册、前向行为到实际选型,掰开揉碎讲清楚,顺带把踩过的坑和排查技巧一并交出来。不管你是刚学 PyTorch 的新手,还是已经写过几个模型但没深究过容器差异的老手,看完应该都能对“什么时候该用哪个”有个明确的判断标准。
核心关键词先摆出来:ModuleList、Sequential、参数注册、前向传播、子模块管理、动态网络结构。这几个词基本覆盖了这两个容器所有需要理解的点,后面每一节都会围绕它们展开。
2. 两个容器到底在解决什么问题
2.1 为什么不能直接用 Python list 存层
先说一个很多人踩过的坑:用普通 Python list 装子模块。
import torch.nn as nn class BadNet(nn.Module): def __init__(self): super().__init__() self.layers = [nn.Linear(10, 10) for _ in range(3)] def forward(self, x): for layer in self.layers: x = layer(x) return x这段代码能跑,前向也没问题,但有个致命缺陷:self.layers里的nn.Linear没有被注册为子模块。你调用model.parameters()会发现返回的是空列表,model.to(device)也不会把这些层的权重搬到 GPU 上,state_dict()里同样找不到它们。训练时优化器拿不到这些参数,模型根本学不动。
原因在于nn.Module的__setattr__做了特殊处理:只有当你把一个nn.Module实例直接赋值给self.xxx时,它才会被登记进_modules字典;而赋值一个 list,list 本身不是 Module,里面的元素就被忽略了。ModuleList和Sequential存在的第一层意义,就是让容器本身成为一个 Module,从而让内部的子模块被正确注册。
2.2 两者的定位差异
虽然都解决了注册问题,但设计目标完全不同:
Sequential是一个“执行器”。它假设你放进来的模块要按顺序依次执行,输入从第一个流到最后一个,中间不需要你插手。它的forward是写死的:遍历内部模块,逐个调用。ModuleList是一个“存储器”。它只负责把模块注册好、管理好,至于怎么用、按什么顺序用、用不用,完全由你在自己的forward里决定。它本身没有forward方法。
打个比方:Sequential像一条已经接好的流水线,原料从一头进、成品从另一头出;ModuleList像一排零件架,零件都登记在册、随时可取,但怎么组装是你的事。
这个定位差异决定了后面所有的行为区别。理解了这一点,很多“为什么这里不能用 Sequential”的问题就迎刃而解了。
2.3 参数注册机制的统一与分歧
两者在参数注册上是一致的——都继承自nn.Module,内部子模块都会被正确登记到_modules里,parameters()、named_parameters()、state_dict()、to()、cuda()这些方法都能正常覆盖到内部模块。
但分歧出现在命名规则上,这个后面第 4 节会详细展开,因为它直接影响到权重加载和迁移学习时的 key 匹配。
3. Sequential 的内部机制与适用边界
3.1 前向传播是怎么写死的
Sequential的forward实现非常直白,简化后大致是这样:
def forward(self, input): for module in self: input = module(input) return input它就是一个 for 循环,把输入依次喂给每个子模块。这意味着:
- 你无法在中间插入自定义逻辑,比如某两层之间做一次特征拼接、加一个残差连接、或者根据输入动态选择走哪条路。
- 每个子模块的输入必须是上一个子模块的输出,接口要能对接上。
- 所有子模块都会被无条件执行,没有跳过、没有分支。
所以Sequential的适用场景非常明确:一条直线的、无分支的、无中间干预的层堆叠。典型的比如一个简单的卷积特征提取器:
self.features = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), )这种结构用Sequential是最舒服的,代码紧凑、意图清晰,一眼就能看出数据流向。
3.2 命名规则:数字索引的利与弊
Sequential里子模块的默认名字是字符串化的数字索引:"0"、"1"、"2"……对应的state_dictkey 就是features.0.weight、features.1.weight这样。
这个命名方式有两个后果:
好处是简洁,写起来不用起名字。坏处是可读性差,而且一旦你调整了层的顺序或增删了层,所有后续索引都会平移,之前保存的权重 key 就对不上了。迁移学习加载预训练权重时,如果结构有细微改动,load_state_dict会直接报 missing keys 和 unexpected keys。
解决办法是给每层起名字,用OrderedDict或者直接传命名参数:
from collections import OrderedDict self.features = nn.Sequential(OrderedDict([ ("conv1", nn.Conv2d(3, 64, 3, padding=1)), ("bn1", nn.BatchNorm2d(64)), ("relu1", nn.ReLU(inplace=True)), ("pool1", nn.MaxPool2d(2)), ]))这样 key 就变成了features.conv1.weight,语义清晰,增删层也不容易出错。我在实际项目里,只要这个Sequential有可能被单独保存或加载权重,都会习惯性起名字,省得后面迁移时抓瞎。
3.3 什么时候 Sequential 会坑你
除了开头提到的多分支场景,还有几个容易翻车的地方:
动态层数。如果你需要根据配置决定堆几层,Sequential可以用循环构造,但构造完就固定了,前向时无法改变执行路径。而有些网络(比如某些动态深度的结构)需要在运行时决定走几层,这时候Sequential就无能为力。
需要访问中间输出。Sequential只返回最后一层的输出,中间层的激活值拿不到。如果你要做特征金字塔、多尺度输出、或者中间层监督,就得改用ModuleList手动管理。
条件执行。比如某些层只在训练时启用(类似 DropPath 的随机深度),Sequential没法在 forward 里做条件判断,只能把这些逻辑塞进子模块内部,破坏了模块的纯粹性。
提示:判断能不能用
Sequential,就问自己一句——这些层是不是“一条道走到黑”,中间不需要我插手?是就用,不是就换ModuleList。
4. ModuleList 的灵活性与正确用法
4.1 它只是一个被正确注册的 list
ModuleList可以理解为一个“被nn.Module认领的 list”。它支持索引、切片、append、extend、insert等 list 操作,同时内部元素会被正确注册为子模块。
class MultiBranchNet(nn.Module): def __init__(self, num_branches=3): super().__init__() self.branches = nn.ModuleList([ nn.Sequential( nn.Linear(128, 128), nn.ReLU(), ) for _ in range(num_branches) ]) self.fusion = nn.Linear(128 * num_branches, 128) def forward(self, x): branch_outs = [branch(x) for branch in self.branches] x = torch.cat(branch_outs, dim=-1) return self.fusion(x)这里每个分支结构相同但参数独立,用ModuleList存起来,前向时手动遍历、拼接。这种多分支结构用Sequential是绝对做不出来的。
4.2 命名规则:索引同样存在,但可控性更强
ModuleList的默认命名也是数字索引,state_dictkey 形如branches.0.0.weight。注意这里有两层索引:外层是ModuleList的索引,内层是每个分支里Sequential的索引。
如果你希望命名更清晰,可以在构造时用add_module指定名字,或者干脆用nn.ModuleDict替代——后者用字符串 key 管理子模块,语义更明确,适合按名字访问的场景。
self.branches = nn.ModuleDict({ "text": nn.Linear(128, 128), "image": nn.Linear(256, 128), "audio": nn.Linear(64, 128), })ModuleDict和ModuleList是同一类东西,都是“被注册的容器”,区别只在于索引用整数还是字符串。选哪个取决于你的访问方式:按顺序遍历用ModuleList,按名字取用ModuleDict。
4.3 前向传播完全由你掌控
ModuleList没有forward,这是它最大的特点也是最大的优势。你必须在自己的forward里显式地使用它,这带来了完全的自由度:
- 可以只执行部分层(比如随机深度)。
- 可以改变执行顺序(比如双向处理)。
- 可以在层之间插入任意操作(拼接、相加、注意力)。
- 可以复用同一层多次(权重共享)。
def forward(self, x): # 随机深度:训练时以一定概率跳过某些层 for i, layer in enumerate(self.layers): if self.training and torch.rand(1) < self.drop_prob[i]: continue x = layer(x) return x这种逻辑用Sequential根本写不出来。所以我的经验是:只要前向逻辑里出现了 if、for 之外的任何控制流,或者需要访问中间结果,就果断用ModuleList。
5. 关键差异对照与选型决策
5.1 一张表看清所有区别
| 维度 | Sequential | ModuleList |
|---|---|---|
| 是否有 forward | 有,顺序执行 | 无,需自己实现 |
| 子模块注册 | 是 | 是 |
| 默认命名 | 数字索引 | 数字索引 |
| 支持索引访问 | 是 | 是 |
| 支持 append/insert | 有限支持 | 完全支持 |
| 中间输出可见 | 否 | 是 |
| 支持条件执行 | 否 | 是 |
| 支持多分支 | 否 | 是 |
| 适用场景 | 直线堆叠 | 复杂结构 |
5.2 选型的三条判断准则
结合我自己的使用习惯,总结三条准则:
第一,看前向是否需要干预。如果数据从第一层流到最后一层,中间不需要任何额外操作,用Sequential。只要需要在层之间做点什么,就换ModuleList。
第二,看结构是否固定。结构在构造时就完全确定、运行时不变,Sequential更省事。结构需要动态调整、条件执行、或者层数依赖输入,ModuleList更合适。
第三,看是否需要中间结果。需要拿中间层输出做辅助损失、特征融合、可视化,必须用ModuleList,因为Sequential只给你最后一层。
5.3 一个常见的混合用法
实际项目里两者经常混用,外层用ModuleList管理多个阶段,每个阶段内部用Sequential堆叠直线层。这种分层设计既保证了灵活性,又让代码保持紧凑。
class StageNet(nn.Module): def __init__(self, num_stages=4): super().__init__() self.stages = nn.ModuleList() for i in range(num_stages): self.stages.append(nn.Sequential( nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), )) self.head = nn.Linear(64, 10) def forward(self, x): for stage in self.stages: x = stage(x) + x # 每个阶段带残差 x = x.mean(dim=[2, 3]) return self.head(x)这里每个 stage 内部是直线结构,用Sequential;stage 之间需要做残差相加,所以外层用ModuleList手动遍历。这种写法在 ResNet 类结构里非常常见。
6. 实操中踩过的坑与排查技巧
6.1 参数没注册:最隐蔽的坑
前面提过用普通 list 存模块会导致参数丢失,但还有一种更隐蔽的情况:在ModuleList构造之后,用普通 list 操作替换元素。
self.layers = nn.ModuleList([nn.Linear(10, 10) for _ in range(3)]) # 下面这行不会触发注册 self.layers[0] = nn.Linear(10, 20)直接对ModuleList做索引赋值,新模块不会被自动注册,旧模块的注册也不会被清除。正确做法是用__setitem__之外的方式,或者干脆重新构造。实测下来,最稳妥的是用append、extend、insert这些方法,它们内部会正确处理注册。
排查方法很简单:构造完模型后打印len(list(model.parameters())),和预期对比。如果数量对不上,八成是注册出了问题。
6.2 权重加载 key 不匹配
迁移学习时最常见的报错就是Missing key(s) in state_dict和Unexpected key(s)。根源通常是命名规则变了。
比如你原来用Sequential存了三层,key 是features.0.weight;后来改成ModuleList加手动 forward,key 可能变成features.0.weight看起来一样,但如果中间插了一层,索引就全乱了。
解决办法有两个:一是加载时用strict=False先看看到底哪些 key 对不上,再决定是重命名还是重新映射;二是从一开始就用有意义的命名,别依赖数字索引。
missing, unexpected = model.load_state_dict(ckpt, strict=False) print("missing:", missing) print("unexpected:", unexpected)这个打印出来的信息比直接报错有用得多,能帮你快速定位是哪几层的命名对不上。
6.3 设备迁移不彻底
model.to(device)会递归处理所有注册的子模块,但如果你有没注册的模块(比如存在普通 list 里),它们不会被迁移。表现就是训练时报 “expected all tensors on same device”。
排查思路:训练前打印一下每个参数的 device,看看有没有漏网的。
for name, param in model.named_parameters(): if param.device != torch.device("cuda:0"): print("未迁移:", name, param.device)这个检查在混合精度训练、多卡训练时尤其值得做,能省下大量排查时间。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 参数数量为 0 | 用了普通 list | 检查是否用 ModuleList/Sequential |
| 输出与预期不符 | Sequential 顺序执行了分支 | 检查是否该用 ModuleList |
| 权重加载报 missing keys | 命名规则变化 | 打印 missing/unexpected |
| 设备不一致报错 | 模块未注册 | 遍历 named_parameters 查 device |
| 中间层输出拿不到 | 用了 Sequential | 改用 ModuleList 手动 forward |
| 动态层数失效 | Sequential 结构固定 | 改用 ModuleList 条件执行 |
7. 几个容易被忽略的细节
7.1 ModuleList 也能被遍历,但别指望它 forward
ModuleList支持迭代,所以你可以写for m in self.layers,但如果你直接self.layers(x)会报错,因为它没有forward。这个错误信息有时候不太直观,新手容易懵。记住一点:能迭代不等于能调用。
7.2 Sequential 里也能塞 ModuleList 吗
可以,但不推荐。Sequential的 forward 会把ModuleList当成一个模块调用,而ModuleList没有 forward,直接报错。所以别把ModuleList放进Sequential,反过来把Sequential放进ModuleList是完全没问题的,这也是最常见的组合方式。
7.3 参数初始化要覆盖到所有子模块
用ModuleList手动管理时,如果你在__init__里对某些层做了特殊初始化,记得遍历所有分支,别漏掉。Sequential因为结构固定,初始化时用apply一次性处理比较方便;ModuleList里如果每个分支结构不同,初始化逻辑也要分别写。
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight) nn.init.zeros_(m.bias) model.apply(init_weights)apply会递归所有注册的子模块,不管你是用Sequential还是ModuleList存的,都能覆盖到。这也是为什么一定要用这两个容器而不是普通 list 的又一个理由。
7.4 保存和加载的粒度选择
有时候你只想保存某个分支的权重,而不是整个模型。用ModuleList时,因为每个分支是独立注册的,可以单独取出来保存:
torch.save(model.branches[0].state_dict(), "branch0.pth")用Sequential时,如果没起名字,取子模块只能靠索引,可读性差一些。所以涉及分模块保存的场景,命名规范就显得格外重要。
8. 我个人的使用习惯
写了这么多模型,我现在基本形成一个固定套路:能用 Sequential 就用 Sequential,一旦发现前向需要任何干预,立刻换成 ModuleList,绝不硬凑。硬凑的代价往往是代码里塞满了各种 hack,过两个月自己都看不懂。
另外一个小习惯是,只要一个容器里的模块数量超过三个,或者有可能被单独加载权重,我一定给每层起名字。数字索引在调试时看着还行,一旦涉及权重迁移和版本迭代,就是灾难。
最后分享一个判断技巧:写完__init__后,先在脑子里过一遍forward的数据流。如果数据流是一条直线,用Sequential;如果中间有任何分叉、合并、跳过、重复,用ModuleList。这个判断花不了十秒钟,但能帮你避开后面几小时的调试。