手上有个模型,forward里连着十几行x = self.fc1(x)、x = self.act1(x),改一次结构要动三个地方,删一层得从头对一遍——这种代码我写过,也维护过别人写的。torch.nn.Sequential就是在这种场景下被想起来的:把"按顺序执行"这件事交给容器,forward缩成一行return self.body(x)。但我后来发现,很多人对它的认知停在"少写几行代码",用着用着就撞上state_dict键名对不上、load_state_dict静默跳过、想加一个残差连接发现根本塞不进去。这篇就把nn.Sequential从头拆一遍:它到底替你做了哪些事、四种装填方式分别适合什么场合、名字为什么比结构还重要、哪些模型结构它天生装不下,最后把我自己踩过的几个坑按现场特征列出来。不管你是刚写完第一个 MLP 的新手,还是天天和 checkpoint 打交道的老手,应该都能从里面找到点有用的东西。
1. 手写 forward 里的重复劳动,才是 Sequential 存在的理由
1.1 一个最朴素的重构对照
先看一段很典型的代码,一个三层的 MLP,带激活和 dropout:
import torch.nn as nn class MLP(nn.Module): def __init__(self, in_dim=784, hidden=256, num_classes=10): super().__init__() self.fc1 = nn.Linear(in_dim, hidden) self.act1 = nn.ReLU(inplace=True) self.drop = nn.Dropout(0.3) self.fc2 = nn.Linear(hidden, hidden) self.act2 = nn.ReLU(inplace=True) self.head = nn.Linear(hidden, num_classes) def forward(self, x): x = self.fc1(x) x = self.act1(x) x = self.drop(x) x = self.fc2(x) x = self.act2(x) return self.head(x)这段代码没毛病,问题在于它的三个关注点被绑在了一起:模块定义、注册流程、执行顺序。你想在最前面再加一层,得同时改__init__和forward;你想临时去掉 dropout 做对比实验,得改forward后又改回来;你想把前四层单独拎出来做特征提取,得重新拼一遍forward里的调用链。中间任何一步漏掉,代码可能还能跑,只是结果悄悄不对了。
换成容器写法:
class MLP(nn.Module): def __init__(self, in_dim=784, hidden=256, num_classes=10): super().__init__() self.body = nn.Sequential( nn.Linear(in_dim, hidden), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(hidden, hidden), nn.ReLU(inplace=True), nn.Linear(hidden, num_classes), ) def forward(self, x): return self.body(x)差别不只是少了几行。批归一化、dropout 这些带状态的层,model.eval()能不能正确切到推理模式,取决于它们有没有被注册为子模块。上面这版里,Dropout是body的子模块,body又是MLP的子模块,model.eval()一调用,training标志会递归传播下去。如果你图省事在forward里写F.dropout(x, 0.3),model.eval()就管不着它了,训练时评估指标会莫名其妙偏低。这个坑我在第 6 节还会再展开。
1.2 把顺序执行交给容器之后,谁在真正干活
nn.Sequential的源码很短,核心就三件事:继承nn.Module、内部维护一个_modules有序字典、forward里按顺序把上一步输出喂给下一个模块。它本身没有参数,真正的权重全在子模块里,这也是为什么print(model)能打出层级结构、.parameters()能一次拿到全部参数、.to(device)能一口气搬完。
理解这一点很重要,因为它决定了两件事。第一,Sequential不是一层,它是一个容器,你没法给它单独设学习率、也没法给它挂激活函数;第二,它的能力边界完全来自"顺序执行"这个语义——除了第一个模块接收外部输入、最后一个模块产出输出,中间任何一步都只能有"一个输入、一个输出"。后面第 4 节讲的残差连接、多分支结构装不进去,根源就在这句话里。
还有一个容易忽略的点:Sequential的__getitem__是按位置取的,不是按参数层取的。这一点在下一节讲state_dict键名时会变成一个很具体的问题——中间夹着的 ReLU、Dropout 不产生参数,但它们占据索引位置,所以权重键里的数字是跳跃的。
1.3 什么时候别用它
有两种情况我会刻意不用Sequential。第一种是模块之间的数据流不是纯串联的,哪怕只是一个小小的跳跃连接,硬塞进去只会写出一堆索引黑魔法,可读性反而比手写更差。第二种是同一批层需要在不同输入上复用,比如 Siamese 结构的双塔共享权重,这时候Sequential的"一次性顺序执行"语义帮不上忙,用普通的属性赋值或者ModuleList更清楚。
还有一种介于两者之间的情况:你只是想临时打个补丁,比如把预训练模型的分类头换掉。这种一次性操作直接赋值就行,model.classifier = nn.Sequential(...),不需要为此重构整个模型。判断标准很简单——如果这个容器将来会被整体保存、整体加载、整体移动设备,用Sequential是合适的;如果它只是代码组织上的临时手段,那手写forward也没人会怪你。
2. 四种装填方式和它们各自擅长的场景
2.1 位置参数:最短,也最容易变更无辜
最直接的写法就是把模块实例依次传进去,容器会按顺序给它们分配"0"、"1"、"2"这样的数字名字。原型阶段我基本都用这种,改起来快,print出来也清爽。它的问题在后期才暴露:数字名字没有任何语义,你打开一个 checkpoint 看到body.3.weight,得回头数一遍第 3 个位置到底是什么层;更麻烦的是插入一层就会让后面所有层的名字整体后移,旧权重全部错位。
注意一个细节:数字编号是"子模块位置",不是"有参数的层序号"。上面那个 MLP 里,Linear在索引 0、3、5 上,所以state_dict的键是body.0.weight、body.3.weight、body.5.weight。很多人第一次看到 0、3、5 这个跳跃序列会以为是 bug,其实就是 ReLU 和 Dropout 占了 1、2、4 的位置。理解了这点,后面看任何Sequential的权重键都不会再懵。
2.2 OrderedDict:名字从第一天就该定下来
第二种写法用有序字典显式指定名字:
from collections import OrderedDict body = nn.Sequential(OrderedDict([ ("fc1", nn.Linear(784, 256)), ("act1", nn.ReLU(inplace=True)), ("drop", nn.Dropout(0.3)), ("fc2", nn.Linear(256, 256)), ("act2", nn.ReLU(inplace=True)), ("head", nn.Linear(256, 10)), ]))这时候state_dict的键变成fc1.weight、fc2.weight、head.weight。代价是代码长了一截,收益是可读性和可维护性。我现在的习惯是:只要这个模型的权重将来会被保存、被别人加载、或者我自己隔两周还要回来看,一律用命名写法。原因很实际——排查问题时你会频繁地按名字取层,dict(model.body.named_children())["fc2"]比model.body[3]稳得多,后者在你某天给fc1后面加了个LayerNorm之后就指错地方了。
还有一个隐藏的收益:命名之后,权重文件本身携带了结构信息。别人拿到你的 checkpoint,光看键名就能反推出模型大致长什么样,这在交接场景里价值很高。用数字索引的权重文件,除了原作者,谁也不知道body.7是什么。
2.3 add_module 与 append:边跑边长的模型
有时候层的数量和宽度是运行时才确定的,比如按配置表自动生成网络。这就是add_module的主场:
net = nn.Sequential() in_dim = 784 for i, width in enumerate([256, 128, 64]): net.add_module(f"fc{i}", nn.Linear(in_dim, width)) net.add_module(f"act{i}", nn.ReLU(inplace=True)) in_dim = width net.add_module("head", nn.Linear(in_dim, 10))add_module(name, module)的第一个参数就是键名前缀,所以即使是动态构建,权重键名依然是可读的fc0.weight、fc1.weight。另外较新版本里Sequential也提供了append(module),它会自动分配下一个数字名字,写起来更短,但和位置参数写法一样丢掉了语义。如果你的运行环境版本较老,append可能不存在,用add_module一定不会错——我一般直接写add_module,省得为一个语法糖去查版本。
这里有个经验:动态构建的那段循环里,in_dim的更新千万别漏。漏掉的话,第二个Linear的输入维度就对不上,报错信息会指向mat1 and mat2 shapes cannot be multiplied,一眼看不出是哪一层出的问题。我的习惯是循环体最后立刻写in_dim = width,紧贴在使用它的地方,减少中间插入代码的机会。
2.4 索引、切片、替换:容器要能开箱
Sequential支持按索引访问和切片,这一点在改造模型时非常好用:
net = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10)) net[0] # 第一个子模块 net[-1] # 最后一个 net[:2] # 返回一个新的 Sequential,包含前两个模块 net[0] = nn.Linear(784, 512) # 原地替换第 0 个注意net[:2]返回的是新的容器,但它里面装的是同一批模块对象,不是副本。这个语义差别在第 5 节会引出一个很隐蔽的副作用:你改新容器里某个层,原容器里那个层跟着一起变。做实验时如果不注意,很容易出现"明明只改了特征提取部分,怎么主模型也变了"的困惑。
按字符串名字取层这件事,不同版本行为不完全一致,我不建议依赖net["fc1"]这种写法。更稳妥的是dict(net.named_children())["fc1"],或者直接属性访问net.fc1——后者走的是nn.Module.__getattr__,只要名字合法就一定能拿到,兼容性最好。
3. 名字写错一次,权重就白训了:state_dict 的键名账本
3.1 数字索引键名长什么样
把一个Sequential装进自定义模块,键名会自动带上前缀。假设主模块里写self.body = nn.Sequential(...),那么权重键就是body.0.weight、body.3.weight这种形式;如果这个模块又被装进更外层的Sequential,前缀会继续叠加,变成blocks.0.body.3.weight。层级有多深,键名就有多长,这是嵌套结构必须接受的代价。
这张表可以把几种写法下的键名对照清楚:
| 写法 | 典型键名 | 插入一层后的影响 |
|---|---|---|
| 位置参数 | body.0.weight、body.3.weight | 后续所有索引整体后移,旧权重错位 |
| OrderedDict | body.fc1.weight、body.fc2.weight | 新增层只要另起名字,旧权重不受影响 |
| add_module | body.fc0.weight | 同 OrderedDict,但名字由循环变量生成 |
| 嵌在外层 Sequential 里 | blocks.0.body.fc1.weight | 前缀随嵌套层数增长 |
看到这张表,选择就很清楚了:会被反复迭代的模型,不要用位置参数写法。前期省下的那点字符,后期会以"重新训练一遍"的形式还回来。
3.2 strict=False 掩盖的静默失败
load_state_dict默认strict=True,键名对不上会直接抛错:Missing key(s) in state_dict或者Unexpected key(s) in state_dict。这个错误虽然烦,但它至少是显式的,你马上知道哪里不对。
真正危险的是有人为了"先跑起来",随手加上strict=False。这时候不匹配的权重会被默默跳过:模型结构里那些层保持随机初始化,训练照常进行,loss 照常下降——因为你可能是在微调,冻结了大部分层,那些随机初始化的层刚好在后面几层,损失曲线看起来还算正常。等到评估指标比预期低一大截,回头查权重,才发现加载其实只成功了一半。
我遇到过一次很典型的现场:微调一个图像分类模型,训练了三个 epoch,准确率一直在基线附近晃。检查发现classifier被换成了nn.Sequential的位置参数写法,键名从classifier.weight变成了classifier.0.weight,加载时全部被判为 unexpected,strict=False一声不吭地吞掉了。修复只花了五分钟,排查花了半天。所以我的建议是:调试阶段永远用strict=True,只有在做明确的、已知的键名迁移时才用strict=False,并且加载完立刻打印一下没能匹配的键:
result = model.load_state_dict(ckpt, strict=False) print("missing:", result.missing_keys) print("unexpected:", result.unexpected_keys)哪怕只加这两行,也能省下后面几个小时。
3.3 权重对齐的三个手法:改名、重映射、按块加载
真的遇到键名对不上,也不用重新训练。最直接的路子是给旧权重改名:读进来,按映射表批量替换键名前缀,再加载。
from collections import OrderedDict old = torch.load("ckpt.pt", map_location="cpu") mapping = {"0": "fc1", "3": "fc2", "5": "head"} new = OrderedDict() for k, v in old.items(): head, _, rest = k.partition(".") if head in mapping: head = mapping[head] new[f"{head}.{rest}" if rest else head] = v model.load_state_dict(new, strict=True)第二个手法是按块加载:如果你只想要 backbone 的权重,可以先把整个Sequential匹配上,分类头单独处理。用named_parameters过滤出前缀,构造子字典再load_state_dict(strict=False),配合打印缺失键确认。第三个手法是在模型定义侧兼容:把分类头改成单层属性而不是Sequential,键名就回到classifier.weight,和大多数预训练权重对得上——这个改动最小,代价只是分类头内部不能再用容器组织。
我个人偏好第一种,因为它把"迁移"这件事收敛到一个函数里,可复现、可留档。写一次脚本,以后同类问题直接改映射表就行。
4. Sequential 装不下的结构,硬装会出什么事
4.1 分支、残差与"跳过一层"的需求
残差连接是第一个撞墙的地方。Sequential的forward是"上一个的输出就是下一个的输入",它没有地方保存中间结果,所以y = x + f(x)这种结构根本表达不出来。有人试过用索引硬绕:先手动跑前几层,再跑后面几层,把Sequential拆成两半用。这样确实能跑,但你已经把forward写回去了,容器的意义也就没了,还多了两个只用了半截的容器对象。
正确的做法是老老实实写一个自定义Module,内部用Sequential组织不需要分支的部分:
class ResidualBlock(nn.Module): def __init__(self, dim): super().__init__() self.path = nn.Sequential( nn.Linear(dim, dim), nn.ReLU(inplace=True), nn.Linear(dim, dim), ) def forward(self, x): return x + self.path(x)这是Sequential最舒服的用法:它负责无分支的子路径,自定义Module负责数据流的编排。两者分工明确,代码也干净。同样的思路适用于 Inception 式的多分支、注意力里的 QKV 投影——容器管一条路,主干逻辑手写。
4.2 多输入、多输出和中间特征提取
Sequential的forward签名只接受一个参数,所以多输入结构(比如同时吃图像和文本)它接不住。勉强绕的办法是传一个 tuple 进去,让第一层的forward自己拆——但这样一来第一层就已经是自定义模块了,后面的顺序执行收益有限,不如直接写。
多输出同理:容器只会返回最后一层的输出,前面的中间结果全部丢弃。但"要中间特征"这个需求在实际工作里非常常见,比如做特征蒸馏、做可视化、做多尺度融合。解决办法有两个:一是按需切片,把前 k 层单独包一个新的Sequential;二是挂前向 hook。
# 方案一:切片 feat_extractor = nn.Sequential(*list(model.body.children())[:4]) with torch.no_grad(): mid = feat_extractor(x) # 方案二:hook cache = {} model.body[3].register_forward_hook( lambda m, inp, out: cache.__setitem__("mid", out) )方案一简单直接,缺点是只能取"某一层之后的输出",不适合同时取多层。方案二灵活,一次注册可以拿到任意层的输出,代价是要记得在推理结束后把 hook 摘掉,否则长时间循环会积累引用。做可视化脚本我一般用方案一,做训练期的中间层监控用方案二。
4.3 ModuleList、ModuleDict 与 Sequential 各自的位置
这三个容器经常被混着用,其实分工很清楚:
| 容器 | 是否实现 forward | 典型用途 |
|---|---|---|
nn.Sequential | 是,按顺序串联 | 无分支的层堆叠 |
nn.ModuleList | 否,必须自己写 | 需要循环或按条件选层的堆叠,比如重复 N 个 block |
nn.ModuleDict | 否 | 按名字选分支,比如多任务头 |
关键差异在于参数注册。三者都能让.parameters()找到内部模块,所以都能被优化器覆盖、被.to(device)搬运。区别只在执行语义上:Sequential帮你把执行顺序写死了,另外两个把执行权交回给你。理解了这一点,选型就不用纠结了——数据流是线性的就用Sequential,不是线性的就别硬凑。
5. 把 Sequential 当积木:嵌套、切片、冻结与初始化
5.1 嵌套之后的键名前缀
把Sequential当积木嵌进另一个Sequential是常见操作,比如用循环堆叠同样的 block。这时候键名会变成blocks.0.0.weight、blocks.0.1.weight这种形式——外层是 block 序号,内层是这个 block 内部的层序号。层级一深,名字很快就失去可读性,而且你还得记住每一层的嵌套层次才能在排查时定位。
我的处理原则是:嵌套不超过两层,每一层该命名的地方都命名。外层循环生成的 block 用add_module(f"block{i}", ...),block 内部的层用OrderedDict起名,这样键名至少是block0.fc1.weight这种能看懂的形式。另外,print(model)打印出来的缩进结构就是实际的嵌套关系,调试时直接看它比猜键名快得多。
5.2 切片与赋值背后的对象共享
前面提过切片返回新容器但共享模块对象,这里说清楚它会在什么场景咬人。假设你从主模型切出前几层做特征提取器:
extractor = model.body[:4]extractor和model.body里装的是同一批nn.Linear实例。如果你为了做实验,把extractor[0]换成一个新的Linear,主模型不受影响(因为替换只改了extractor这个容器的_modules字典);但如果你是去改extractor[0].weight的值,或者给它设了requires_grad = False,主模型对应的那层也跟着变,因为它们是同一个对象。
这个区别很容易记混:替换容器里的槽位是"改指向",修改对象本身是"改内容"。做对照实验时我一般会在切片之后立刻copy.deepcopy,虽然多占一点显存,但省掉了一整类"为什么结果不对"的排查。
5.3 冻结部分层与分组设置学习率
微调时冻结前几层是很常见的需求,Sequential的切片让这件事变得非常直观:
backbone = nn.Sequential(*list(pretrained.children())[:-1]) for p in backbone[:6].parameters(): p.requires_grad = False optimizer = torch.optim.AdamW([ {"params": [p for p in backbone[6:].parameters() if p.requires_grad], "lr": 3e-4}, {"params": model.head.parameters(), "lr": 1e-3}, ])两个注意点。第一,冻结之后优化器里不要再传这些参数,虽然传了也不会更新,但会占优化器状态的内存,AdamW这种带一阶二阶动量的更明显。第二,冻结不等于不计算梯度,requires_grad=False的参数不会产生梯度,但它们的输入如果还需要梯度,反向传播还是会走到这些层——想省显存得配合torch.no_grad()或者把整段包在eval模式下,不过后者会改变 BN 的行为,要看具体场景。
5.4 用 apply 做分层初始化
nn.Module.apply(fn)会递归遍历所有子模块,是统一初始化的标准做法:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, nonlinearity="relu") nn.init.zeros_(m.bias) elif isinstance(m, nn.LayerNorm): nn.init.ones_(m.weight) nn.init.zeros_(m.bias) model.body.apply(init_weights)apply的回调会被传入每一个子模块,包括容器本身,所以一定要用isinstance判断类型,否则对Sequential实例去取.weight会直接报错。另外,Sequential没有"按层类型批量设置"的内置接口,想给不同深度的层设不同初始化,只能在回调里根据层名或者维度做判断——这种写法不算优雅,但胜在集中,比在__init__里散落十几行初始化代码好维护。
6. 六个高频坑与它们的现场特征
6.1 同一个实例被放进容器两次
shared = nn.Linear(64, 64) bad = nn.Sequential(shared, nn.ReLU(), shared)这段代码不报错,行为却和你想的完全不同:两处引用的是同一个模块对象,参数完全共享。state_dict里会出现0.weight和2.weight两个键,保存的是同一份数值,加载时先后写入同一个 tensor,看起来一切正常,训练时两处的梯度会累加到同一组参数上。如果你本来就想共享权重,那没问题;如果只是想搭两层一样的层,必须copy.deepcopy。现场特征:参数量比预期少了一大截,sum(p.numel() for p in model.parameters())一算就能发现。
6.2 lambda 与函数式 API 混进容器
nn.Sequential(lambda x: x * 2)会直接抛错,因为容器在注册子模块时要求传入的是nn.Module实例。想插入自定义的逐元素运算,得包一个最小模块:
class Scale(nn.Module): def __init__(self, k): super().__init__() self.k = k def forward(self, x): return x * self.k这个类没有参数,但它作为子模块存在,会被print(model)显示出来,结构一目了然。写多了你会发现这类"无参数模块"其实挺好用,nn.Identity()也是同一类东西。
6.3 函数式 Dropout 让 eval 失效
这是我在评审别人代码时见得最多的一类问题。容器里装的是nn.Dropout还好,一旦有人在forward里手写F.dropout(x, 0.3),model.eval()就管不到它了——因为F.dropout是一个纯函数调用,不在模块树上,eval()递归设置的training标志它读不到。现场特征:训练集和验证集的指标差距异常大,而且随着训练推进不收敛。修法有两种,要么换成nn.Dropout(0.3)放进容器,要么显式传training=self.training。
同样的道理适用于F.batch_norm——这个更危险,因为 BN 在训练和推理下的计算方式完全不同,用函数式调用忘记切模式,推理结果会直接错。
6.4 nn.Identity 作为占位替换
改造模型时经常需要"跳过某一层但不改结构",nn.Identity()是最省事的办法:
model.body[2] = nn.Identity() # 暂时跳过第 2 层它没有任何参数,forward原样返回输入,而且作为模块仍然占据索引位置——这意味着键名不会因为替换而错位。做消融实验时我经常这么干,比注释掉一行再改forward干净得多。注意一点:Sequential里替换掉的层,它原来的参数就不在state_dict里了,如果之后要恢复,记得先把权重文件备份好。
6.5 device 与 dtype 的传播边界
model.to(device)会递归处理所有子模块的参数和 buffer,Sequential里的层自然会被搬过去。但如果你在自己的模块里把某个 tensor 存成了普通属性(self.mask = torch.ones(...)),它不会被.to()搬运,跑到 GPU 上就会报 device 不一致。正确做法是用register_buffer注册,这样它会进state_dict、也会跟着.to()走。这个坑和Sequential没有直接关系,但只要模型用了容器组织,出问题时定位路径会变长——从报错的那一层往上捋,很容易漏掉某个手写的缓存张量。
6.6 脚本化与序列化的结构限制
torch.jit.script对Sequential本身是支持的,但如果容器里有自定义模块带 Python 层的动态控制流(比如根据输入形状决定走哪个分支),脚本化会失败。另外,把模型序列化成 TorchScript 之后,结构就固定了,之后想通过替换Sequential里的某一层来改模型是做不到的。我的经验是:需要频繁改结构的阶段用 eager 模式,结构稳定、要部署了再考虑脚本化,两者不要混着做。
最后分享一个我现在的习惯:任何超过五层的顺序堆叠,我都会先写成Sequential,并且从第一版就用命名写法。看着是多打了几个字,但等到第一次需要加载别人的权重、或者三个月后回来改结构时,那些名字会替你省下大量时间。结构简单的时候容器是锦上添花,结构复杂起来之后,它就是你唯一能快速看懂自己模型的地方。