做深度学习的,谁还没跟nn.Module打过交道?但我敢说,很多人用了一年半载 Pytorch,其实并没真正搞懂这个类到底在幕后干了什么。你天天class Net(nn.Module)这么写,但你有没有想过:为什么必须要继承它?forward到底什么时候被调用?parameters()里那堆权重是怎么被“自动”收集起来的?模型搬到 GPU 上为什么一个.to(device)就全过去了?这些问题的答案,全在这个系列的第九篇里。
这篇不是 API 文档的翻译,也不是照着官方教程念一遍。我会从一个常年拿 Pytorch 训练模型的实践者角度,把模型创建这件事从头到尾拆开揉碎——从nn.Module的设计哲学,到__init__和forward的分工逻辑,再到自定义层、参数管理、模型保存加载的避坑经验。这篇适合两类人:一类是刚学完张量操作、准备上手搭网络的初学者,另一类是已经能跑通训练但总觉得“差点意思”、想补一补底层认知的同学。看完你应该能做到:给自己写一个带自定义逻辑的网络模块,理直气壮地跟人解释nn.Module到底做了什么。
1. 为什么你的模型必须继承 nn.Module
说句不太好听的话:如果你的网络没有继承nn.Module,那你基本是在用 Python 的dict和list手搓深度学习,路会走得特别艰辛。nn.Module在 Pytorch 里不只是“基类”这么简单,它更像是一个自带了参数管理、设备管理、训练/评估状态切换、钩子机制的管理容器。
1.1 nn.Module 到底替你管理了什么
一个模型,本质上就是一堆参数的集合加上一层层的数据变换。当你写class Net(nn.Module)的时候,你实际上获得了四样关键能力:
- 参数自动注册:子模块里所有的
nn.Parameter会被自动加入模型的参数表,你不需要手动维护一个 list 去收集权重。 - 子模块递归管理:你放到
__init__里的nn.Linear、nn.Conv2d、nn.BatchNorm2d等一切nn.Module实例,都会被自动登记为当前模块的子模块。当你调用model.cuda()、model.to(device)、model.eval()时,会以递归的方式作用到所有子模块上。 - 状态字典导出:
state_dict()方法会把你模型里所有可学习参数和 Buffer(比如 BN 层的 running_mean 和 running_var)打包成有序字典,这是保存模型、做推理服务、做迁移学习的基础接口。 - 钩子与流程控制:注册
forward、backward的钩子函数,在模型的前向传播和反向传播中进行“插桩”,方便你调试、做中间特征可视化、做梯度裁剪。
这四样东西,如果让你全部用原生 Python 实现一遍,你会发现在第一个训练循环跑通之前就已经被折磨得不行了。而nn.Module把这些基础能力集中封装好,你只需要专注于两件事:在__init__里声明模块结构,在forward里编写数据流逻辑。
1.2 模型是“容器”,不是“函数”
很多初学者会把 Pytorch 模型理解成一个“函数”:给一个输入,得到一个输出。这个理解没错,但不完整。严格来说,模型是一个“带参数的容器”,forward只是这个容器对外暴露的核心数据通路。
这个理念差异直接影响了你的代码组织和思维方式。当你把模型当成容器,你会自然地把“参数化部件”(例如卷积层、全连接层)和“非参数化操作”(例如relu、softmax、flatten、reshape)分开放。参数化部件放进__init__,非参数化操作放进forward。前者是需要被优化器扫描并更新的对象,后者只是纯数据变换。
我之前带过一个转行做算法的新人,他一开始很迷惑:“ReLU不是也有一个nn.ReLU()类吗?为什么我见他总在forward里直接调F.relu?”这个问题的答案其实就是上面的设计哲学:nn.ReLU()是一个带有forward逻辑的模块,你可以用它,但如果你不需要它内部保存任何状态、不需要参数,直接用函数式接口F.relu会更清爽。你不需要它变成模型结构的一部分,只是需要它作为一个数据变换动作出现。
2. 模型创建的标准流程与核心机制拆解
现在咱们直接上手操作。我先把一个标准的模型创建流程摆出来,然后一步一拆地讲解每一步在干什么。
import torch import torch.nn as nn import torch.nn.functional as F class MyNet(nn.Module): def __init__(self, num_classes=10): super(MyNet, self).__init__() self.conv1 = nn.Conv2d(3, 64, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(64) self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(128) self.pool = nn.MaxPool2d(2, 2) self.fc = nn.Linear(128 * 8 * 8, num_classes) def forward(self, x): x = F.relu(self.bn1(self.conv1(x))) x = self.pool(x) x = F.relu(self.bn2(self.conv2(x))) x = self.pool(x) x = x.view(x.size(0), -1) x = self.fc(x) return x这段代码是所有卷积网络的“骨架级”模板,你以后写任何 CV 模型大概率都是从这种结构衍生出去的。
2.1__init__里到底该放什么
__init__是你声明模型“部件清单”的地方。关键词是声明,不是计算。这里只定义“模型由哪些层构成”,不写任何跟具体输入尺寸相关的数据流动逻辑。
在__init__里每一行都是一个子模块的定义,这些模块自身又维护了它们自己的参数。举个例子:nn.Conv2d(3, 64, kernel_size=3, padding=1)这一行,实际创建了一个包含一个权重张量(shape 为[64, 3, 3, 3])和一个偏置张量(shape 为[64])的模块。当你把这个模块赋给self.conv1时,Pytorch 从nn.Module.__setattr__这个魔法方法里做了手脚——它会判断被赋值的对象是不是nn.Module实例,如果是,就自动挂到当前模块的子模块列表中。
这个过程不需要你手动调用任何注册函数,也不需要你手动分配内存。赋值即注册,这是nn.Module的第一大“魔法”。
2.2forward的职责与调用时机
forward是模型完成数据变换的通道。你定义了模型如何把输入一步步变成输出。在forward里,你可以调用任何 Python 语法,可以使用if-else做动态控制流,可以写for循环实现复杂的数据遍历,也可以随便使用print打印中间变量做调试。
但有一个知识点非常关键:你几乎不会直接调用forward,而是通过model(x)来触发整个前向计算。为什么?因为nn.Module重写了__call__方法,它会在真正进入forward之前执行一系列“前置钩子”,在返回结果之前执行“后置钩子”。
# 正确用法 output = model(x) # 非正确用法 # output = model.forward(x)这两者最大的区别是:model(x)会经过__call__的钩子体系,执行所有注册的 forward 钩子;而直接调用.forward(x)会跳过所有钩子,包括 Pytorch 内部用于实现分布式、混合精度、自动反向的一些关键逻辑。平时自己调试模型怎么写都无所谓,但一旦你上了大规模分布式训练或者需要精细控制反向传播,直接调.forward会踩出各种隐性 bug。所以养成习惯:永远使用model(x)。
2.3super().__init__()不只是“形式主义”
super(MyNet, self).__init__()这行代码是新手最容易糊弄过去的地方。我可以负责任地告诉你:少了这行,你对self的每一项子模块赋值都不会被注册。
原因在于,nn.Module的__init__里面初始化了一批内部管理用的关键属性,比如_parameters、_modules、_buffers这些 OrderedDict。如果你在子类里重写了__init__而没有调用父类的__init__,那么这些内部字典根本不存在。你执行self.conv1 = nn.Conv2d(...)的时候,nn.Module.__setattr__虽然会被触发,但由于_parameters、_modules这些属性不存在,赋值逻辑会直接走异常路径,轻则参数没被收集,重则直接报错。
所以super().__init__()本质上是“初始化父类的管理机制”,不是形式主义,是基石操作。而且我建议用super().__init__()这种无参写法,而不是nn.Module.__init__(self),因为前者在遇到多重继承时会更安全。
2.4 一个模型实例化后内部长什么样
实例化模型后,你可以通过一些内建方法查看模型内部结构:
model = MyNet(num_classes=10) print(model) # 输出类似这样: # MyNet( # (conv1): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) # (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) # (conv2): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) # (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) # (pool): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) # (fc): Linear(in_features=8192, out_features=10, bias=True) # )print(model)展示的是模型的结构树,也就是_modules里存放的内容。注意,F.relu这些函数式接口不会出现在这个结构树里,因为它们是静态的、无状态的函数调用,不是模块。这个细节和前面提到的设计哲学是一致的。
再往下看,你还可以用named_parameters()查看模型里所有的参数及其名称:
for name, param in model.named_parameters(): print(name, param.shape) # conv1.weight torch.Size([64, 3, 3, 3]) # conv1.bias torch.Size([64]) # bn1.weight torch.Size([64]) # bn1.bias torch.Size([64]) # conv2.weight torch.Size([128, 64, 3, 3]) # conv2.bias torch.Size([128]) # ...named_parameters()是按照模块的层级结构递归遍历的,并用点号分隔层级路径。这套命名规则在你做模型剪枝、加载部分权重、做权重重命名时会非常重要。
3. 参数到底是什么——从nn.Parameter说起
前面反复提到了“参数”,现在咱们直接面对这个核心概念。在 Pytorch 里,nn.Parameter是Tensor的子类,它比普通 Tensor 多了一个“身份标签”——只要把它赋值给nn.Module的属性,它就会被自动注册到_parameters字典中。
3.1nn.Parameter与普通 Tensor 的区别
一句话先说结论:nn.Parameter是“有身份证的 Tensor”。它具备两个特征:
- 作为
Tensor的一种,它拥有所有张量运算能力。 - 作为参数,它会被模型的
parameters()迭代器收集,最终被优化器识别并更新。
举一个对照实验你就明白了:
class DemoNet1(nn.Module): def __init__(self): super().__init__() self.weight1 = nn.Parameter(torch.randn(4, 4)) # 会被收集 self.weight2 = torch.randn(4, 4) # 不会被收集 self.layer = nn.Linear(4, 4) # 内部的 weight 和 bias 会被收集 def forward(self, x): return x @ self.weight1 + self.weight2 + self.layer(x) net = DemoNet1() for name, param in net.named_parameters(): print(name, param.shape) # weight1 torch.Size([4, 4]) # layer.weight torch.Size([4, 4]) # layer.bias torch.Size([4, 4])注意,weight2就是普通 Tensor,它不会出现在named_parameters()的输出里。这意味着,如果你在forward函数里用到了self.weight2,它在梯度计算时会存在——但它不会被优化器更新。这很容易在模型设计阶段造成“看似在训练、实际某块权重根本没更新”的隐蔽 bug。
所以这里有一个非常重要的实操准则:所有需要被优化器学习的参数,必须以nn.Parameter的形式定义,或者放在一个nn.Module容器里。
3.2 手动定义可学习参数的一个实用场景
有些情况下,Pytorch 内置层不能满足你的需求,你需要自己定义带可学习参数的层。最典型的例子就是做某种自定义 attention 缩放因子,或者定义一个可学习的温度系数、可学习的 softmax 温控参数。
class LearnableScalar(nn.Module): def __init__(self, init_value=1.0): super().__init__() self.scale = nn.Parameter(torch.tensor(init_value)) def forward(self, x): return x * self.scale这个LearnableScalar本身只有 1 个参数,但它是一个合法的nn.Module。它可以被放到任何其他模块里,也会被端到端地训练。这种“自定义小模块”的思路在做复杂模型的时候很常用——你不需要所有模块都是砖块,有些模块只需要定义一个简单的可调节参数。
3.3 参数初始化:为什么不能跳过
Pytorch 的nn.Conv2d、nn.Linear在创建时都有默认的参数初始化方案,但当你自己做多层网络或自定义模块时,默认初始化不一定是最优的。尤其是深层网络,不恰当的初始化会导致梯度消失或爆炸,训练半天 loss 不降。
Pytorch 在torch.nn.init里提供了丰富的初始化函数,常用的有:
def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) nn.init.constant_(m.bias, 0) model.apply(init_weights)model.apply(init_weights)会递归地对模型内每个子模块调用init_weights函数。这个写法结合了isinstance判断和针对性的初始化策略,是很多开源项目里惯用的初始化模式。建议接入自己的训练代码里,尤其是在你搭了比较深、比较宽的模型时,好的初始化能明显加速收敛。
4. 实操:从回归模型到卷积网络的完整拆解
现在咱们不看花架子,直接实操。我用三个不同复杂度的例子,把模型创建的思路层层推进。每个例子里都会带上“为什么这么写”的解读。
4.1 例一:一个手写的多层感知机(MLP)
class MLP(nn.Module): def __init__(self, in_dim=784, hidden_dims=[256, 128], out_dim=10): super().__init__() layers = [] prev_dim = in_dim for hidden_dim in hidden_dims: layers.append(nn.Linear(prev_dim, hidden_dim)) layers.append(nn.ReLU()) prev_dim = hidden_dim layers.append(nn.Linear(prev_dim, out_dim)) self.net = nn.Sequential(*layers) def forward(self, x): x = x.view(x.size(0), -1) return self.net(x)这里有个值得细品的小技巧:我用nn.Sequential装载一组层。Sequential也是一个nn.Module,它的forward会按顺序执行内部模块。当你的模型是一连串顺序排列的层时,Sequential能极大简化你的代码。
但要注意,Sequential适合“直线型”网络结构。如果你的网络里有跨层连接(比如 ResNet 的残差分支),或者需要条件控制流(比如不同的forward路径),Sequential就不够灵活了,这时候还是得自己写forward。
4.2 例二:一个带自定义层的卷积网络
接下来是一个稍微复杂一点的例子,我会在里面加入一个自定义的前向逻辑层——空间注意力模块。
class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size=kernel_size, padding=kernel_size // 2) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) attention = torch.cat([avg_out, max_out], dim=1) attention = self.conv(attention) return x * self.sigmoid(attention) class AttentionNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.attention = SpatialAttention(kernel_size=3) self.classifier = nn.Linear(64 * 8 * 8, num_classes) def forward(self, x): x = self.features(x) x = self.attention(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x这个例子的重点是自定义模块SpatialAttention。你发现没有:它虽然是自定义的,但它继承了nn.Module,在__init__里含有nn.Conv2d子模块,所以它的参数会被自动管理。整个模型在训练、验证、保存时,完全不需要特殊处理这个自定义模块——它已经被当作标准的一等公民了。
这就是nn.Module设计的威力:只要你遵守“继承 + 把子模块放进__init__+ 在forward里写数据流”这三个铁律,你的自定义层就和官方层一样好用。
4.3 例三:动态层——输入决定层的选择
有些场景下,模型结构需要根据输入动态变化。比如多任务学习里,不同的任务需要不同的 head 分支。nn.Module完全可以支持这种动态特性。
class DynamicHeadNet(nn.Module): def __init__(self, backbone_out=512, num_tasks=3): super().__init__() self.backbone = nn.Sequential( nn.Linear(256, 512), nn.ReLU(), nn.Linear(512, backbone_out), ) self.heads = nn.ModuleList([ nn.Linear(backbone_out, 10) for _ in range(num_tasks) ]) def forward(self, x, task_id=0): features = self.backbone(x) # 动态选择对应的任务头 out = self.heads[task_id](features) return out注意这里我用了nn.ModuleList而不是 Python 原生 list。这是一个“新手犯大错”的高频雷区。
4.4nn.ModuleList与nn.Sequential的选择
很多初学者习惯用 Python list 存一堆层:
self.layers = [nn.Linear(10, 10) for _ in range(3)] # 错误示范这么写,list 里的层不会被注册到模块的管理体系中,因为它们只是普通的 list 元素,nn.Module.__setattr__并不会递归遍历 list 成员。最终结果是你的模型在.parameters()里根本找不到这些层,梯度不会更新,训练了等于白训练。
正确的做法是使用nn.ModuleList:
self.layers = nn.ModuleList([nn.Linear(10, 10) for _ in range(3)]) # 正确nn.ModuleList的行为像一个 Python list,支持索引、迭代,但它的内部实现了特殊容器逻辑,会把你 add 进去的所有模块自动注册到当前模块的子模块列表里。
nn.ModuleList和nn.Sequential的区别也要分清:ModuleList没有自己的forward逻辑,它只是一个容器;Sequential自带顺序执行逻辑,可以直接调用。当你需要把一个列表传进一个既有结构时,用ModuleList;当你需要直接按顺序跑一系列层时,用Sequential。
5. 模型参数的保存、加载与迁移学习
模型创建只是第一步,训练完怎么把参数存下来、怎么把参数再加载回去、怎么用别人预训练好的参数初始化自己的模型,这些是实战中最频繁面对的问题。我直接给你一组可直接“抄作业”的范式,顺带把常见报错一并讲清楚。
5.1state_dict()与torch.save的正确打开方式
推荐保存的方式是只存参数的state_dict,不存整个模型对象。这样做的原因有几个:
state_dict是纯张量字典,跨平台、跨版本兼容性更好。- 加载时你需要重新构建模型结构,这恰好保证了“结构定义和权重数据分离”。
- 整个模型对象可能含有优化器状态、临时缓存等冗余数据,体积大且容易出兼容问题。
# 保存 torch.save(model.state_dict(), "model_weights.pth") # 加载 model = MyNet(num_classes=10) model.load_state_dict(torch.load("model_weights.pth")) model.eval()加载之后有个关键细节:如果 saved 文件的 key 跟你当前模型的 key 不匹配,load_state_dict会直接抛 RuntimeError。这通常发生在你改过模型结构之后又去加载旧权重的情况。遇到这种问题不用慌,如果你只是想加载一部分匹配的层,在加载时加上strict=False:
model.load_state_dict(torch.load("model_weights.pth"), strict=False)但注意,strict=False也会悄悄地忽略掉那些已经不存在的 key,所以做迁移学习时,你要清楚知道模型里有哪些参数没被加载。我一般会打印一遍未匹配的 key 确认一下,免得静默出错。
5.2 迁移学习中的“冻结 vs 微调”
迁移学习的核心操作是:用预训练模型初始化骨干网络,然后把最后的分类层换成适合自己任务的形状,有选择性地更新参数。
# 加载预训练模型 pretrained = torch.load("resnet_pretrained.pth") model = MyNet(num_classes=100) model.load_state_dict(pretrained, strict=False) # 冻结 backbone,只训练最后的 head for name, param in model.named_parameters(): if "fc" not in name and "classifier" not in name: param.requires_grad = False # 优化器只接收 requires_grad=True 的参数 optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3)冻结参数的原理很简单:requires_grad=False的 Tensor 在反向传播时不会被更新,而filter过滤后优化器根本看不到它们。有个容易踩的小坑是:冻结参数后,这些层的 BatchNorm 统计量也可能不再更新——除非你手动把它们置为requires_grad=True,否则model.eval()会让 BN 层用 running_mean 和 running_var,而不是当前 batch 的统计量。做迁移学习时,你可以刻意让 BN 层保持train模式,或者干脆把整个 backbone 的 BN 层也冻结。这个细节不同任务有不同最优方案,建议多试两套配置对比。
5.3 为什么保存后加载的模型结果不一致
这是实战中很常见的一个玄学问题:训练时 loss 正常,测试时效果也还行,但保存后重新加载,推理结果跟训练时的验证阶段对不上。十有八九是忘了.eval()。
model.load_state_dict(torch.load("model_weights.pth")) # 缺少这一步 # model.eval()model.eval()会把模型切换为评估模式,影响主要有:
- Dropout 层关闭随机失活,不再做随机丢弃。
- BatchNorm 层切换为使用累积的 running_mean / running_var,而不是当前 batch 的统计数据。
如果你在推理时忘了调用.eval(),模型的行为会和你验证阶段完全不同。反过来,如果训练时你忘了调用model.train()(重置模式),模型可能一直在 eval 模式,BN 的统计量和 Dropout 的行为就会出错。所以我在训练循环里,通常会在 epoch 开始显式写model.train(),验证前显式写model.eval()。不要依赖初始状态,显式调用是唯一的保险。
6. 常见问题与排查技巧实录
最后这部分,我把过去几年实操里见过的、和模型创建相关的高频问题做一份速查表,每一个背后都有一把辛酸泪。
6.1 高频报错速查表
| 报错信息 | 产生原因 | 快速解法 |
|---|---|---|
AttributeError: 'MyNet' object has no attribute 'conv1' | 忘记调用super().__init__()或赋值顺序错误 | 在__init__第一行调用super().__init__(),再执行子模块赋值 |
RuntimeError: Input type (torch.FloatTensor) and weight type (torch.cuda.FloatTensor) should be the same | 输入数据在 CPU 上,模型参数在 GPU 上,或反之 | 统一调用.to(device):模型、输入、标签全部搬到同一设备 |
KeyError: 'fc.weight' is missing from state_dict | 加载权重时模型结构与保存时不一致 | 比较保存文件的 key 集合和当前模型的 key 集合,打印差异,修正模型结构 |
ValueError: Expected more than 1 value per channel when training | BatchNorm 在 batch_size=1 时训练报错 | 训练时保证 batch_size > 1,或把 BN 层换成 GroupNorm |
RuntimeError: Trying to backward through the graph a second time | 同一计算图被重复反向传播 | 每次迭代重新构造计算图,或使用retain_graph=True(仅特殊场景) |
6.2 关于.to(device)的一个隐蔽坑
.to(device)看起来是“简单搬移操作”,但有一个隐蔽行为需要注意:它对nn.Module是 in-place 的作用,对Tensor则是 out-place 的(默认返回新张量)。这个区别会造成一种经典误用:
# 错误示范 x = x.to(device) # 这样没问题,因为你对 x 重新赋值了 # 但如果写成 x.to(device),不重新赋值,x 其实还在 CPU 上 # 模型同理 model.to(device) # 这个调用会直接修改模型内部参数,不用写 model = model.to(device)还有一点,当你的模型里有额外创建的 Tensor Buffer(比如某个先验掩码、位置编码),手动定义时最好用register_buffer注册,而不是直接赋值普通 Tensor。只有注册成 buffer,才能随.to(device)自动迁移,并且会出现在state_dict()里方便保存。
class MyModel(nn.Module): def __init__(self): super().__init__() self.register_buffer("mask", torch.ones(4, 4))6.3 钩子机制:一个调试模型的“神器”
nn.Module内部嵌入的钩子机制是我日常工作里发现真香的功能。如果你想打印模型每一层的输出形状,不需要动模型代码,注册 forward 钩子就够了。
def print_hook_fn(module, input, output): print(f"Layer: {type(module).__name__}, output shape: {output.shape}") for name, module in model.named_modules(): module.register_forward_hook(print_hook_fn)这段代码会给模型里的所有模块都挂上打印钩子,每次前向传播时自动打印层名和输出形状。在做复杂模型 debug 的时候,这个技巧比在forward里一行行加print要方便得多。你也不用改模型主体代码,只需要在外部把钩子挂上去就行。
6.4 如何快速确认模型参数有没有在训练中被更新
有些时候你怀疑某个层梯度有问题,可以用钩子直接监听参数梯度。不过最省心的办法是记录一个 batch 训练前后的参数值,做减法看变化量。
param_before = {} for name, param in model.named_parameters(): param_before[name] = param.detach().clone() # 跑一个 batch 训练 optimizer.zero_grad() loss.backward() optimizer.step() param_after = {} for name, param in model.named_parameters(): param_after[name] = param.detach().clone() for name in param_before: diff = (param_after[name] - param_before[name]).abs().max() print(f"{name}: max diff = {diff}")如果某个层的diff是 0,那说明这个层的参数根本没有被优化器更新。原因可能是requires_grad=False、优化器里过滤掉了它、或者是这个层根本没参与forward的计算图。这个排查脚本我实测过无数回,高效得一批。
7. 最后再聊两句个人的体会
模型创建在 Pytorch 里从来不是“照着模板抄一遍”的事。前提是你心里真正想清楚:哪部分参数是你要学的,哪部分只是数据变换;你的模型是一个能递归管理自己子模块的容器,而不是一个孤零零的函数。当你把这些基础观念立住了,后面读源码、复现论文、部署到服务器,都不会再被这些底层机制绊住脚。
如果让我给刚开始用 Pytorch 写模型的人三条最实用的建议,我会说:第一,永远在__init__里super().__init__(),这个没得商量;第二,模型定义与数据处理分离,把模型结构当成一个独立的类来维护,不要跟训练循环揉在一起;第三,把model(x)调用方式刻进肌肉记忆,别碰model.forward(x)。做到这三条,你基本能在模型创建这件事上身段灵活、进退自如。剩下的,就是去训练循环里不停地实验、试错、优化了。