☰
PyTorch模型结构查看与修改实战:从print到层级遍历与替换
2026/10/20 18:30:28 网站建设 项目流程

1. 为什么需要看清模型的层级结构

搞深度学习的人都有一个共同的体验:模型跑起来了,loss也在降,但你对里面到底长什么样其实一知半解。尤其是接手别人的代码,或者用了一个复杂的预训练模型,打开文件一看,几百行网络定义,嵌套了各种容器,想改其中某一层,找半天找不到入口。这时候,能快速打印出模型的层级结构,就成了一个非常实用的基本功。

PyTorch提供了多种查看模型结构的方式,从最简单的print(model)到named_modules()、named_children()、named_parameters(),每一种输出的信息粒度和用途都不一样。很多人只会用print(model),结果输出一大坨,嵌套深了根本看不清层级关系。而模型更改更是日常操作——想替换backbone、想冻结某些层、想在中间插入一个注意力模块、想把某一层的输出维度改掉,这些都需要你对模型结构有清晰的认知。

这篇文章面向的是有一定PyTorch基础、但在模型结构查看和修改上还不太熟练的开发者。我会从最基础的打印方法讲起,逐步深入到层级遍历、参数定位、模型替换和结构修改,把每一步的原理和实操都讲透。不管你是刚入门的新手,还是已经写过几个模型但想系统梳理一下的老手,应该都能从中找到有用的东西。

2. 打印模型结构的几种方式与适用场景

2.1 直接print:最快速但最粗糙

最直接的方式就是print(model)。PyTorch的nn.Module重写了__repr__方法,会递归打印所有子模块。比如一个简单的卷积网络:

import torch import torch.nn as nn class SimpleNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.classifier = nn.Sequential( nn.Linear(64 * 16 * 16, 256), nn.ReLU(inplace=True), nn.Linear(256, 10) ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x model = SimpleNet() print(model)

输出会是这样:

SimpleNet( (features): Sequential( (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (2): ReLU(inplace=True) (3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) ) (classifier): Sequential( (0): Linear(in_features=16384, out_features=256, bias=True) (1): ReLU(inplace=True) (2): Linear(in_features=256, out_features=10, bias=True) ) )

这种方式的好处是一目了然,能直接看到每一层的类型和关键参数。但问题也很明显:当模型嵌套很深时,输出会非常长,而且Sequential里的层只有数字索引,没有语义化的名字,定位起来不方便。另外,这种方式只能看到结构,看不到参数的形状和数量。

提示:print(model)适合快速确认模型是否按预期搭建,但不适合在复杂模型中精确定位某一层。

2.2 named_modules:带名字的完整遍历

named_modules()会返回一个迭代器,产出(name, module)的元组,name是完整的层级路径。这个方法是查看模型结构最全面的方式:

for name, module in model.named_modules(): print(f"{name}: {module.__class__.__name__}")

输出:

: SimpleNet features: Sequential features.0: Conv2d features.1: BatchNorm2d features.2: ReLU features.3: MaxPool2d classifier: Sequential classifier.0: Linear classifier.1: ReLU classifier.2: Linear

注意第一行的name是空字符串,代表模型本身。这种方式的好处是每个模块都有唯一的路径名,你可以直接用这个路径名来定位和操作。比如想冻结features.0的参数,就可以直接通过名字找到它。

named_modules()默认会递归遍历所有子模块。如果只想看直接子模块,可以用named_children(),它只返回第一层子模块,不会递归下去。还有一个children()方法,只返回子模块本身,不带名字。

2.3 named_parameters:定位到具体参数

有时候你不仅想知道有哪些层,还想知道每一层有哪些参数、形状是什么。named_parameters()就是干这个的:

for name, param in model.named_parameters(): print(f"{name}: shape={param.shape}, requires_grad={param.requires_grad}")

输出会列出所有可学习参数,包括权重和偏置。这个在调试时特别有用,比如你发现某个层的梯度没更新,就可以用这个方法来确认它的requires_grad是不是被意外设成了False。

还有一个parameters()方法,只返回参数张量,不带名字。named_buffers()则用于查看Buffer,比如BatchNorm的running_mean和running_var。

2.4 几种方式的对比与选择

方法返回内容是否递归适用场景
print(model)格式化字符串是快速查看整体结构
named_modules()(name, module)是精确定位某一层,批量操作
named_children()(name, module)否只看顶层子模块
named_parameters()(name, param)是查看参数形状和梯度状态
named_buffers()(name, buffer)是查看非参数状态

实际使用中,我通常先用print(model)看个大概,然后用named_modules()来精确定位。如果模型特别大,比如ResNet-152这种,print输出会非常长,这时候用named_modules()配合过滤条件会更高效。

3. 深入理解层级遍历的底层逻辑

3.1 nn.Module的树状结构

PyTorch的nn.Module本质上是一棵树。每个Module可以包含子Module,子Module还可以继续包含子Module,形成层级嵌套。当你给一个Module赋值一个子Module时,PyTorch会自动把它注册到_modules字典里。这就是为什么self.features = nn.Sequential(...)之后,features会自动出现在named_modules()的输出中。

理解这一点很重要,因为模型修改的本质就是操作这棵树——替换某个节点、删除某个节点、或者在某个位置插入新节点。你直接修改self.features这个属性,PyTorch的注册机制会自动更新_modules字典,不需要手动维护。

但有一个坑:如果你用列表来存子模块,比如self.layers = [nn.Linear(10, 10) for _ in range(3)],这些层不会被自动注册,因为Python的list不是Module。必须用nn.ModuleList才行。这个坑我见过太多人踩了,模型训练时发现某些层的参数根本没更新,查半天才发现是用了普通list。

3.2 命名规则与路径解析

named_modules()返回的name是用点号分隔的路径。比如features.0表示features这个Sequential下的第0个模块。这个路径可以直接用于get_submodule()方法:

conv_layer = model.get_submodule("features.0") print(conv_layer)

get_submodule()是PyTorch 1.9引入的方法,比手动用dict(model.named_modules())更安全,因为它在路径不存在时会抛出明确的错误。类似的方法还有get_parameter()和get_buffer()。

路径解析的规则是:从根模块开始,按属性名逐级向下。如果某一级是Sequential或ModuleList,就用数字索引。如果是ModuleDict,就用字典的key。这个规则在手动拼接路径时需要注意,写错了会直接报错。

3.3 递归遍历的实现原理

named_modules()的内部实现是一个递归生成器。它先yield自己的名字和模块,然后遍历_modules字典,对每个子模块递归调用。伪代码大概是这样:

def named_modules(self, memo=None, prefix=''): if memo is None: memo = set() if self not in memo: memo.add(self) yield prefix, self for name, module in self._modules.items(): if module is None: continue submodule_prefix = prefix + ('.' if prefix else '') + name yield from module.named_modules(memo, submodule_prefix)

memo集合用于防止重复遍历同一个模块(比如共享权重的情况)。这个细节在模型有共享层时很重要,否则会无限递归。

理解了底层逻辑,你就能明白为什么有些操作可行、有些不可行。比如为什么不能在遍历named_modules()的同时修改模型结构——因为迭代器是基于当前的_modules字典生成的,中途修改会导致迭代行为不确定。正确的做法是先收集要修改的模块名,遍历结束后再统一修改。

4. 模型更改的常见操作与实操

4.1 替换某一层

替换层是最常见的需求之一。比如你想把ResNet的最后一层全连接改成适合自己数据集的输出维度:

import torchvision.models as models model = models.resnet50(pretrained=True) # 查看最后一层 print(model.fc) # 替换 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 100) # 改成100类

这种直接属性赋值的方式适用于已知层路径的情况。如果层嵌套很深,比如在model.layer4[2].conv3这种位置,直接赋值也可以:

model.layer4[2].conv3 = nn.Conv2d(512, 256, kernel_size=1)

但要注意,替换后的层如果输入输出维度不匹配,forward时会报错。所以替换前一定要确认清楚前后层的维度关系。

对于Sequential中的层,可以用索引替换:

model.features[0] = nn.Conv2d(3, 32, 3, padding=1)

也可以用nn.Sequential的切片操作来批量替换,但切片赋值的行为在PyTorch中不太直观,建议还是逐层替换。

4.2 冻结与解冻参数

冻结参数在迁移学习中非常常用。基本操作是:

# 冻结所有参数 for param in model.parameters(): param.requires_grad = False # 只解冻最后一层 for param in model.fc.parameters(): param.requires_grad = True

但这里有个细节:冻结参数后,优化器如果已经包含了这些参数,它们仍然会被更新(虽然梯度是None或0)。正确的做法是在冻结之后再创建优化器,或者只把需要更新的参数传给优化器:

optimizer = torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr=0.001 )

另一个坑是BatchNorm层。即使你冻结了BN层的参数,它在train模式下仍然会更新running_mean和running_var。如果想让BN完全冻结,需要调用model.eval()或者手动设置bn.eval()。这个细节在微调时经常被忽略,导致结果不稳定。

4.3 在模型中插入新模块

有时候你想在现有模型中插入一个新的模块,比如在某个位置加一个注意力机制。如果模型是用Sequential搭建的,插入相对简单:

# 在features的第2层后插入一个SE模块 new_layers = [] for i, layer in enumerate(model.features): new_layers.append(layer) if i == 1: new_layers.append(SEBlock(64)) model.features = nn.Sequential(*new_layers)

但如果模型是自定义forward的,插入新模块就需要同时修改forward逻辑。比如你想在ResNet的每个stage后加一个输出,就需要改forward函数。这种情况下,更推荐用hook机制来获取中间层输出,而不是直接改模型结构。

4.4 修改forward逻辑

有些修改不是替换层,而是改变数据流向。比如你想让某一层的输出同时传给两个分支:

class ModifiedNet(nn.Module): def __init__(self, original_model): super().__init__() self.backbone = original_model.features self.branch1 = nn.Linear(512, 10) self.branch2 = nn.Linear(512, 5) def forward(self, x): x = self.backbone(x) x = x.view(x.size(0), -1) out1 = self.branch1(x) out2 = self.branch2(x) return out1, out2

这种方式相当于把原模型当作一个组件来用。好处是不破坏原模型的结构,坏处是需要重新定义forward,而且原模型的classifier部分可能就浪费了。

4.5 批量修改相同类型的层

有时候你想对所有Conv2d层做统一操作,比如把kernel_size从3改成1,或者把所有ReLU换成LeakyReLU。这时候可以遍历named_modules(),按类型过滤:

for name, module in model.named_modules(): if isinstance(module, nn.ReLU): # 不能直接替换,因为遍历时修改会出问题 pass # 正确做法:先收集,再修改 replace_dict = {} for name, module in model.named_modules(): if isinstance(module, nn.ReLU): replace_dict[name] = nn.LeakyReLU(0.1) for name, new_module in replace_dict.items(): # 解析父模块和属性名 parts = name.split('.') parent = model for part in parts[:-1]: parent = getattr(parent, part) if not part.isdigit() else parent[int(part)] setattr(parent, parts[-1], new_module)

这段代码看起来有点绕,但它是通用的。核心思路是:先遍历收集要替换的模块,然后逐个解析路径找到父模块,最后用setattr替换。对于Sequential中的层,父模块是Sequential本身,属性名是数字索引,用setattr也可以工作,因为Sequential内部也是用_modules字典存储的。

5. 实操案例:从零修改一个预训练模型

5.1 案例背景与目标

假设我们有一个在ImageNet上预训练的ResNet-18,现在要用于一个10类的医学图像分类任务。数据是灰度图(单通道),图像尺寸是224x224。我们需要做以下修改:

  1. 把第一层卷积从3通道输入改成1通道输入
  2. 把最后的全连接层从1000类改成10类
  3. 冻结前两个stage的参数
  4. 在最后一个stage后加一个Dropout层

这个案例涵盖了模型修改的大部分常见操作,下面一步步来。

5.2 第一步:查看原始结构

import torchvision.models as models import torch.nn as nn model = models.resnet18(pretrained=True) print(model)

输出会显示ResNet的完整结构,包括conv1、bn1、layer1到layer4、avgpool和fc。重点关注conv1和fc,因为这两个是要改的。

5.3 第二步:修改第一层卷积

原始conv1是Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)。改成单通道输入:

original_conv1 = model.conv1 new_conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) # 把原始权重的3个通道求平均,赋给新卷积 with torch.no_grad(): new_conv1.weight.copy_(original_conv1.weight.mean(dim=1, keepdim=True)) model.conv1 = new_conv1

这里为什么要对权重求平均?因为原始权重是针对RGB三个通道训练的,直接取其中一个通道会丢失信息,求平均是一种合理的初始化方式。当然也可以直接随机初始化,但用预训练权重的均值通常收敛更快。

5.4 第三步:修改全连接层并添加Dropout

num_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.5), nn.Linear(num_features, 10) )

注意这里把Dropout和Linear打包成了一个Sequential。这样修改后,forward中model.fc(x)的输出就是10维的。但要注意,ResNet的forward中fc的输入是经过avgpool后的特征,形状是(batch, 512, 1, 1),需要view成(batch, 512)。ResNet的原始forward已经做了这个处理,所以替换fc后不需要改forward。

5.5 第四步:冻结前两个stage

# 冻结layer1和layer2 for param in model.layer1.parameters(): param.requires_grad = False for param in model.layer2.parameters(): param.requires_grad = False # 确认冻结状态 for name, param in model.named_parameters(): if not param.requires_grad: print(f"Frozen: {name}")

冻结后,这些层的梯度不会计算,反向传播时也不会更新。但要注意,如果用了BatchNorm,它在train模式下仍然会更新running stats。如果想完全冻结,需要把对应的BN层设为eval模式。

5.6 第五步:验证修改结果

# 打印修改后的结构 print(model) # 用假数据测试forward dummy_input = torch.randn(2, 1, 224, 224) output = model(dummy_input) print(f"Output shape: {output.shape}") # 应该是 (2, 10) # 检查参数梯度状态 for name, param in model.named_parameters(): print(f"{name}: requires_grad={param.requires_grad}")

这一步很关键,很多人改完模型不测试就直接训练,结果跑了几轮才发现维度不对。用假数据跑一遍forward,能提前发现大部分问题。

5.7 修改后的模型保存与加载

修改后的模型保存时,如果直接torch.save(model.state_dict()),加载时需要先构建相同的结构再load。如果保存整个模型torch.save(model),加载时依赖原始的类定义,不推荐。更好的做法是保存state_dict,并在加载时重新构建模型:

# 保存 torch.save(model.state_dict(), "modified_resnet18.pth") # 加载 model = models.resnet18(pretrained=False) model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) model.fc = nn.Sequential( nn.Dropout(0.5), nn.Linear(512, 10) ) model.load_state_dict(torch.load("modified_resnet18.pth"))

注意加载时的结构必须和保存时完全一致,否则load_state_dict会报key不匹配的错误。

6. 常见问题与排查技巧

6.1 打印输出太长怎么办

当模型很大时,print(model)的输出可能几百行。这时候可以用named_modules()配合过滤:

# 只看特定类型的层 for name, module in model.named_modules(): if isinstance(module, nn.Conv2d): print(f"{name}: {module}")

或者只看顶层结构:

for name, module in model.named_children(): print(f"{name}: {module.__class__.__name__}")

还可以用torchsummary或torchinfo这类第三方库,它们能输出更紧凑的表格形式,包含每层的输出形状和参数量。安装torchinfo后:

from torchinfo import summary summary(model, input_size=(2, 1, 224, 224))

这个输出非常直观,推荐在调试时使用。

6.2 替换层后forward报维度错误

这是最常见的问题。原因通常是替换后的层输入输出维度与原层不一致。排查方法是:先用print(model)确认替换后的结构,然后逐层检查维度。可以在forward中插入print语句:

def forward(self, x): x = self.conv1(x) print(f"After conv1: {x.shape}") x = self.bn1(x) print(f"After bn1: {x.shape}") # ...

或者用hook来打印每一层的输出形状:

def hook_fn(module, input, output): print(f"{module.__class__.__name__}: input={input[0].shape}, output={output.shape}") for name, module in model.named_modules(): module.register_forward_hook(hook_fn)

hook方式不需要改forward代码,更灵活。

6.3 冻结参数后模型仍然在更新

前面提到过,冻结参数后如果优化器已经包含了这些参数,它们仍然可能被更新。另外,BatchNorm的running stats不受requires_grad控制。排查步骤:

  1. 确认param.requires_grad确实是False
  2. 确认优化器只包含需要更新的参数
  3. 确认BN层处于eval模式(如果需要完全冻结)
  4. 检查是否有其他地方手动修改了参数

6.4 加载state_dict时key不匹配

这通常是因为保存和加载时的模型结构不一致。解决方法:

# 查看当前模型的keys model_keys = set(model.state_dict().keys()) # 查看保存的keys checkpoint = torch.load("model.pth") checkpoint_keys = set(checkpoint.keys()) # 找出差异 print("Missing keys:", model_keys - checkpoint_keys) print("Unexpected keys:", checkpoint_keys - model_keys)

根据差异调整模型结构,或者用strict=False来忽略不匹配的key:

model.load_state_dict(checkpoint, strict=False)

但strict=False要慎用,它可能掩盖真正的问题。

6.5 常见问题速查表

问题可能原因解决方法
forward维度错误替换层维度不匹配检查前后层输入输出维度
参数不更新requires_grad为False或优化器未包含检查梯度状态和优化器参数
BN层行为异常train/eval模式不对设置正确的模式
state_dict加载失败结构不一致对比key差异,调整结构
显存占用过高冻结层仍计算梯度用torch.no_grad()包裹冻结层
打印输出混乱嵌套太深用named_modules过滤

提示:修改模型后,务必用假数据跑一遍forward,确认输出形状和预期一致,再开始训练。这个习惯能帮你省下大量调试时间。

7. 一些实操心得与进阶技巧

7.1 用hook获取中间层输出

有时候你不想改模型结构,只是想获取某一层的输出用于分析或可视化。hook是最好的选择:

features = {} def get_features(name): def hook(module, input, output): features[name] = output.detach() return hook model.layer4.register_forward_hook(get_features("layer4")) model.fc.register_forward_hook(get_features("fc")) # forward之后,features字典里就有对应的输出 output = model(dummy_input) print(features["layer4"].shape)

这种方式不侵入模型定义,非常适合做特征提取和可视化。

7.2 用ModuleDict管理可配置的层

如果你需要根据配置动态选择层,nn.ModuleDict比普通字典更好:

class ConfigurableNet(nn.Module): def __init__(self, config): super().__init__() self.layers = nn.ModuleDict({ "conv": nn.Conv2d(3, 64, 3, padding=1), "bn": nn.BatchNorm2d(64), "relu": nn.ReLU() }) self.use_bn = config.get("use_bn", True) def forward(self, x): x = self.layers["conv"](x) if self.use_bn: x = self.layers["bn"](x) x = self.layers["relu"](x) return x

ModuleDict会自动注册所有子模块,而且支持按key访问,比普通dict更安全。

7.3 模型结构可视化

除了文本打印,还可以用torchviz生成计算图:

from torchviz import make_dot output = model(dummy_input) make_dot(output, params=dict(model.named_parameters())).render("model_graph")

这会生成一个PDF或PNG文件,直观展示计算流程。不过对于大模型,图会非常复杂,建议只对子模块使用。

7.4 修改模型时的版本兼容性

PyTorch不同版本之间,模型API可能有变化。比如get_submodule()是1.9才有的,torchinfo在不同版本下输出格式也可能不同。建议在修改模型前先确认PyTorch版本:

print(torch.__version__)

如果团队协作,最好在requirements中固定版本,避免因为版本差异导致模型加载失败。

7.5 一个容易忽略的细节:inplace操作

很多激活函数默认inplace=False,但有些人为了省显存会设成inplace=True。这在修改模型时可能引发问题:如果你在inplace操作后插入了一个hook,hook拿到的output可能已经被修改了。排查这类问题时,可以先把inplace关掉,确认逻辑正确后再开启。

我在实际项目中遇到过一个问题:替换了ResNet的fc层后,训练loss一直不降。查了半天发现是新的fc层没有正确初始化,默认的随机初始化方差太大,导致梯度爆炸。后来改成用nn.init.kaiming_normal_手动初始化,问题就解决了。所以替换层之后,别忘了检查初始化方式,尤其是当原始层有特殊初始化时。

另一个经验是:修改模型后,先用一个很小的数据集跑几个epoch,确认loss能正常下降,再上全量数据。这样能快速发现结构性问题,避免浪费大量时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询