news 2026/10/29 12:51:20

PyTorch预训练模型修改指南:从全连接层到卷积层的实操解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch预训练模型修改指南:从全连接层到卷积层的实操解析

PyTorch 里改预训练模型,到底在改什么?

如果你用过 PyTorch 的预训练模型,十有八九会撞上这个场景:拿 ResNet 在 ImageNet 上训好的权重,想用到自己的二分类任务上,于是照着网上的代码把model.fc换成nn.Linear(2048, 2),然后发现只改最后一层根本不够用。再往后想做点高级操作——比如把输入从三通道改成单通道、把中间某个卷积层换成注意力模块、或者干脆把整个特征提取阶段换掉——就不知道该动哪里了,只能对着model.children()一层一层数。

这篇文章我就把 PyTorch 里修改预训练模型这件事掰开揉碎讲清楚,覆盖三个层次:改全连接层、改单个卷积层、改多个卷积层。不绕弯子,直接给你能落地的代码和思路,同时把每一步背后的原理讲明白。适合已经会用 PyTorch 训练简单模型、但还没系统折腾过预训练模型结构的人。

1. 改模型之前,先看清预训练模型的"骨架"

很多人上来就model.fc = ...,改完跑通就完事了。但等你要动卷积层的时候,这套经验就不够用了,因为你根本不知道卷积层叫什么名字、长在哪里。所以第一步不是改,是看。

1.1 用 print 和 named_modules 摸清层级结构

PyTorch 的模型本质是一棵由nn.Module嵌套组成的树。最直观的查看方式是直接print(model):

import torchvision.models as models model = models.resnet50(pretrained=True) print(model)

输出会显示完整结构,大致长这样(我节选关键部分):

ResNet( (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False) (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True) (relu): ReLU(inplace=True) (maxpool): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (layer1): Sequential(...) (layer2): Sequential(...) (layer3): Sequential(...) (layer4): Sequential(...) (avgpool): AdaptiveAvgPool2d(output_size=(1, 1)) (fc): Linear(in_features=2048, out_features=1000, bias=True) )

print能让你看到模块的嵌套关系,但要精确拿到每层名字和参数规模,得用named_modules():

for name, module in model.named_modules(): print(name, "->", module.__class__.__name__)

拿 ResNet50 来说,前面的输出你能看到conv1、bn1、layer1.0.conv1、layer1.0.bn1这种带点的层级名字。实际改模型时,这个点分层的命名极其重要,因为加载权重靠的就是这些 key。

1.2 state_dict:权重是怎么和结构对应的

预训练权重不是一堆堆叠的数字,而是以字典形式存储的。每一条 key 都对应网络中的一个具体参数:

sd = model.state_dict() for k, v in sd.items(): print(k, v.shape)

输出类似:

conv1.weight torch.Size([64, 3, 7, 7]) bn1.weight torch.Size([64]) bn1.bias torch.Size([64]) bn1.running_mean torch.Size([64]) bn1.running_var torch.Size([64]) ... fc.weight torch.Size([1000, 2048]) fc.bias torch.Size([1000])

注意这里的命名规则:layer1.0.conv1.weight表示layer1这个容器下的第 0 个模块里的conv1的卷积核。一个卷积层的权重 shape 是[输出通道, 输入通道, 卷积核高, 卷积核宽],全连接层权重是[输出特征数, 输入特征数]。

改模型的本质,就是让"结构"和"权重"这对映射重新对齐。你改了结构,state_dict 里对应的 key 或者 shape 就变了,接下来要考虑的就是怎么处理权重。

1.3 改模型改的是计算图,不是权重本身

新手最容易误解的一点:以为model.fc = nn.Linear(...)之后,模型就自动"知道"该怎么做。实际上这个赋值操作重新搭建了计算图的一部分——原来通往fc的特征是 2048 维,新fc接收 2048 维输入,输出 2 维,前向传播就能正常跑。但新fc.weight的参数是随机初始化的,跟预训练权重没有关系。所以"修改预训练模型"严格来说是两件事的叠加:调整计算图结构、决定哪些权重保留哪些重新初始化。

理解了这一点,后面所有操作都顺了。

2. 改全连接层:从 1000 类到 N 类的标准套路

这是最常见的一类修改。预训练模型在 ImageNet 上赌了 1000 类,你的数据集可能只有 10 类,那输出层肯定要换。

2.1 最稳妥的改法:只替换分类头

以 ResNet 为例,最标准的做法是:

import torch.nn as nn num_classes = 10 model.fc = nn.Linear(model.fc.in_features, num_classes)

model.fc.in_features这个写法很实用,不管你的预训练模型是 ResNet18 还是 ResNet50,甚至换成了 EfficientNet,只要最后一层是Linear,它都能自动帮你取到输入维度。

但这里有一个很多人踩的坑:不是所有模型的分类头都叫fc。不同模型的命名差异很大:

模型分类头属性名输入特征维度
ResNet 系列fc512 / 2048 等
VGG 系列classifier[6]4096
DenseNet 系列classifier1024
EfficientNet 系列classifier[1]1280
MobileNetV3classifier[3]1280

所以修改前先print(model),确认你的模型分类头到底叫什么。万一名字不同,直接暴力尝试model.fc = ...只会报AttributeError。

2.2 权重加载的两种方式:pop 还是 strict=False

替换完fc层后,如果直接load_state_dict加载预训练权重,会报错:

RuntimeError: Error(s) in loading state_dict for ResNet: size mismatch for fc.weight: copying a param with shape torch.Size([1000, 2048]) from checkpoint, the shape in current model is torch.Size([10, 2048]).

原因很直白:fc.weight的 shape 对不上了。解决办法有两条路。

第一条:加载前先把旧权重里不需要的 key 删掉:

import torch checkpoint = torch.load("resnet50.pth") # 假设已有权重文件 state_dict = checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint # 移除 fc 相关的 key state_dict.pop("fc.weight", None) state_dict.pop("fc.bias", None) model.load_state_dict(state_dict, strict=False)

第二条:直接用strict=False:

model.load_state_dict(checkpoint, strict=False)

strict=False的含义是:允许 checkpoint 里"多"的 key 或者"缺"的 key 存在,只要相同名称的 key 形状匹配即可。但注意,用strict=False时如果某个同名字的 key shape 不匹配,还是会报错。所以更推荐先pop再加载,逻辑上更可控。

如果你想保留旧fc的部分知识做冷启动,也可以把旧的fc.weight复制一部分过来,不过分类数差异太大的话意义不大,我一般不这么干。

2.3 替换之后,细节上要注意什么

改完fc之后的第一个动作,是用随机输入快速前向一遍,确认输出尺寸正确:

x = torch.randn(2, 3, 224, 224) out = model(x) print(out.shape) # 期望 torch.Size([2, 10])

第二步是确认哪些参数要参与训练。大多数迁移学习场景会把骨干网络冻结,只训练新加层:

for param in model.parameters(): param.requires_grad = False # 只让 fc 层可训练 for param in model.fc.parameters(): param.requires_grad = True

这里有个经验之谈:如果你连骨干网络一起微调,初始学习率要调小一个数量级,不然预训练权重很快会被破坏掉,尤其是数据集很小的时候,模型会直接过拟合甚至梯度爆炸。用冻结+小学习率,前几个 epoch 的效果会稳很多。

3. 动单个卷积层:从输入通道调整到层内替换

改完全连接层还能跑,改单个卷积层就没那么容易了。这一情况通常在两种场景下出现:要么输入源变了(比如摄像头是灰度图、有特殊通道的遥感影像),要么你觉得某个特定卷积层不够好,想替换成自定义结构。

3.1 场景一:输入从三通道改成一通道

最经典的场景是灰度图输入。预训练模型的第一层conv1是按三通道设计的,灰度图只有一个通道,直接输入会报错。修改方式:

# 原始:Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)

这样改完之后模型能跑,但原来的conv1.weight(shape 是[64, 3, 7, 7])没法加载了,因为输入通道从 3 变成了 1。两种处理办法:

方法一:把三通道权重的均值复制到单通道。因为原始卷积核的每个空间位置上有三个通道的权重,取平均后作为单通道的初始值,相当于模拟了原始特征提取器的大致响应:

old_weight = model.state_dict()["conv1.weight"] # 加载旧权重后取出来 new_weight = old_weight.mean(dim=1, keepdim=True) # [64, 3, 7, 7] -> [64, 1, 7, 7] model.conv1.weight.data.copy_(new_weight)

方法二:不改变通道数,在输入数据层面做重复。把单通道灰度图复制三次变成三通道(灰度图的 RGB 三个通道一样),这样第一层完全不用动,预训练权重原样加载。这个方法在实践中更常用,因为几乎零成本。

我推荐的做法是:如果只是临时用,复制通道;如果是做专门的灰度/单通道任务,直接改conv1并用均值复制初始化。

3.2 场景二:替换某个具体位置的卷积层

比如你觉得 ResNet 的layer2.0.conv1(一个 1x1 卷积)不够好,想换成两个卷积串联的结构:

# 先记录原始模块的输入输出通道 old_conv = model.layer2[0].conv1 in_ch = old_conv.in_channels out_ch = old_conv.out_channels model.layer2[0].conv1 = nn.Sequential( nn.Conv2d(in_ch, out_ch // 2, kernel_size=1, bias=False), nn.BatchNorm2d(out_ch // 2), nn.ReLU(inplace=True), nn.Conv2d(out_ch // 2, out_ch, kernel_size=3, padding=1, bias=False), )

这里必须注意:替换层的in_channels必须和上一层输出的通道数一致,out_channels必须和下一层期望的输入通道数一致。换了中间层,前后链条不能断。

但是有个细节往往被忽略:替换之后的out_ch若不变,下层的bn和conv不需要动;可你替换进去的新卷积是随机初始化的,训练初期会有一段"震荡期",因为下游所有层接受的输入分布跟预训练时完全不同。

所以如果只是小改单个层,最好让新卷积的输出接近恒等映射。做法是:如果你替换的是一个残差块里的层,尽量让新模块的初始输出接近 0(这样可以起到类似恒等映射的作用),或者给最后的卷积层权重乘一个很小的系数,降低初始扰动的幅度。

3.3 单个卷积层修改后的权重迁移思路

前面提到待替换层如果 shape 和旧权重不一致,load_state_dict会报错。通常的做法是用strict=False加载,然后单独处理新层:

model.load_state_dict(checkpoint, strict=False) # 单独对新 conv1 做初始化(复制均值) model.conv1.weight.data.copy_(old_mean_weight)

提醒一句:strict=False只能容忍 key 缺失,不能容忍同 key 的 shape 不一致。如果你的新卷积刚好也叫conv1但 shape 不同,load_state_dict照样报错。稳妥做法是先把匹配的 key 全部手动灌进去,再对 mismatch 的 key 单独处理。

这个阶段最需要养成的习惯是:每次结构修改后,用named_modules重新打印一遍,确认层级名没有意外变化。改一个层看似简单,但你要是改了某个Sequential里的索引位置,后面的 key 可能是全乱套的。

4. 多个卷积层联动修改:结构替换与特征链路调整

如果说改单层是"外科手术",改多个卷积层就是"重建工程"。这类修改通常是为了提升特征表达能力,或者适配特殊的数据分布。常见操作有整体替换一个 stage、插入注意力模块、加旁路结构等。

4.1 为什么改单层经常没用:链路与感受野

先讲一个我踩过的坑:最初做一个小数据集任务时,我觉得 ResNet 底层的 7x7 大卷积核下采样太狠,把conv1的步长从 2 改成 1,然后maxpool也去掉了,以为这样能保留更多空间信息。结果模型训练出来效果反而变差。

原因在于:预训练权重里,conv1的步长 2 与大核的感受野设计是配套的。改动之后,后面所有 BatchNorm 的 running_mean/running_var 统计口径对不上了,而且中间层输出的特征图分辨率变了,下游层的输入分布完全偏离预训练状态。单个卷积层的修改,影响的不只是这一层,而是整条特征链路。

这就是为什么改多个卷积层必须整体考虑,而不只是"一个一个改"。

4.2 典型案例:整体替换 ResNet 的最后一层 stage

假设你的任务不需要过于高层的语义信息,或者觉得最后一层 stage(layer4)的特征太粗,想直接换掉。这时候可以整体替换:

import torch.nn as nn # 自定义一个更轻量的最后阶段 class LightStage(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.downsample = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=2, bias=False), nn.BatchNorm2d(out_ch), ) def forward(self, x): identity = self.downsample(x) out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out = self.relu(out + identity) return out # 替换 layer4 in_ch = model.layer3[-1].conv2.out_channels # 上游输出通道 out_ch = 512 model.layer4 = LightStage(in_ch, out_ch)

替换后,原来layer4.0.conv1这类 key 全部失效,加载权重时strict=False只能接受这种"缺失",这是正常的。但你要保证in_ch和上一层输出完全匹配,否则前向直接崩。

4.3 插入注意力模块:保留原路径,增加旁路

另一个高频操作是插入注意力模块,常见的是把 SE 模块或 CBAM 加进残差块里。以在layer2[0]的残差连接后、激活前插入 SE 为例:

class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.squeeze = nn.AdaptiveAvgPool2d(1) self.excitation = nn.Sequential( nn.Linear(channels, channels // reduction, bias=False), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels, bias=False), nn.Sigmoid(), ) def forward(self, x): b, c, _, _ = x.shape y = self.squeeze(x).view(b, c) y = self.excitation(y).view(b, c, 1, 1) return x * y.expand_as(x)

要插进现有结构,最方便的是重写这个子模块的前向逻辑。如果不想改太多代码,可以直接包装整个 block:

class BlockWithSE(nn.Module): def __init__(self, block): super().__init__() self.block = block self.se = SEBlock(block.conv3.out_channels) def forward(self, x): out = self.block(x) return self.se(out)

这样新模块的state_dict里多了se.squeeze、se.excitation.0.weight等 key,旧权重里没有,加载需要strict=False或自己逐个合并。

这里有个选择问题:你是"原位重写"还是"包一层"?原位重写(直接改forward)性能更优,但破坏原有模块结构,可读性差;包一层不改原结构,但多了模块嵌套层级,任何想load_state_dict的人都得研究一下 key 映射。真实项目里我倾向于包一层,方便回滚。

4.4 多卷积层修改最容易爆的雷:通道与分辨率

改多个卷积层时,两个参数必须始终盯防:通道数(channel)和空间尺寸(spatial size)。

  • 通道数在卷积层的in_channels/out_channels之间传递,一旦某一个中间层改了输出通道,后面所有层的输入通道都要跟着改。
  • 空间尺寸变化由步长、padding、池化决定。你把某个 stride=2 的层去掉或改成 1,后面的特征图分辨率直接翻倍,不仅影响计算量,还会让最后的AdaptiveAvgPool2d输出行为改变。

所以我有一个习惯:每次改完多个层之后,马上用一个固定 shape 的随机输入走一遍前向,并在关键位置打印特征图尺寸:

x = torch.randn(1, 3, 224, 224) hooks = [] def hook_fn(name): def fn(module, input, output): print(f"{name}: {output.shape}") return fn model.layer2[0].register_forward_hook(hook_fn("layer2.0")) model.layer3[0].register_forward_hook(hook_fn("layer3.0")) model.layer4[0].register_forward_hook(hook_fn("layer4.0")) model(x)

这样能第一时间发现哪一层输出尺寸不对劲,而不必等到训练中途才报错。

5. 改完模型之后,必须逐一验证的四件事

结构改完了,别急着开跑训练。我会按固定顺序做四步检查,缺一步都可能让后面排错难上加难。

5.1 前向验证:随机张量过一遍

这一步不仅确认能跑,还要观察输出 shape 是否符合预期。如果改了分类头,输出维度应该是[batch, num_classes];如果改了中间层,输出维度可能不变但数值范围变了,打印一下标准差和均值做个对比基准。

import torch model.eval() x = torch.randn(4, 3, 224, 224) with torch.no_grad(): out = model(x) print(out.shape) # [4, num_classes]

如果结构修改涉及重新初始化层,前向输出数值的尺度往往和预训练时有明显偏差。这不是 bug,但意味着新层初始权重可能需要调整(见 3.3 里提到的缩放初始化)。

5.2 权重加载验证:逐个 key 比对

加载权重后,用一段脚本确认关键层的权重来源是否符合预期:

sd = model.state_dict() for name, param in model.named_parameters(): if param.requires_grad: print(name, param.shape, "trainable") else: print(name, param.shape, "frozen")

再针对混搭场景(部分层用预训练、部分层随机初始化)敲定"权重来源清单":

层权重来源是否可训练
conv1~layer3预训练权重冻结
layer4(新)随机初始化可训练
fc(新)随机初始化可训练
注意力模块随机初始化可训练

这个清单写下来,之后调试就不再抓瞎。

5.3 梯度验证:确认你的"冻结"真的冻结了

很多人用requires_grad = False冻结层之后,还是发现损失在变、所有参数都在动。原因可能是忘了把模型切成train模式或者 BN 层行为没搞清楚。用一段代码确认梯度只会出现在可训练层:

x = torch.randn(2, 3, 224, 224) y = torch.randn(2, num_classes) loss = nn.functional.mse_loss(model(x), y) loss.backward() for name, param in model.named_parameters(): if param.grad is not None and param.grad.abs().sum() > 0: print(name, "got grad")

如果某个被冻结的层也出现了梯度,说明你把requires_grad设在了错误的子模块上,或者模型在load_state_dict之后重建了参数。这个检查能帮你精准定位。

5.4 保存与复现:结构修改后怎么固化

最后一步是把修改后的模型结构存一份"源代码级"的记录。因为修改后结构已经不能从预训练模型一键重建了,如果代码丢失,权重即使保存下来也失去了结构对应的意义。我通常会把模型定义文件单独 copy 一份注释版,并在model.state_dict()存盘时附带结构描述信息:

torch.save({ "state_dict": model.state_dict(), "arch": str(model), "num_classes": num_classes, "input_channels": 3, "note": "ResNet50 with custom layer4 and SE inserted" }, "model_modified.pth")

这样几个月后回来看,还能想起当时干了什么。

6. 几点实操心得

最后分享一些我在多个项目里攒下的经验,不算教程,但遇到类似场景会让你少走弯路。

第一,永远保留一份原始模型副本。我会把models.resnet50(pretrained=True)的state_dict单独存盘,或者把 model 实例深拷贝一份。这样改结构改坏了,随时能"一键还原"权重,不用重新下载。

第二,调试中间层输出用 hook,不要改代码到处print。PyTorch 的register_forward_hook是排查模型结构的神器,能实时打印任何一层输入输出的 shape、均值、标准差,比断点调试高效。很多结构兼容性问题,看一眼中间输出的 shape 就对上了。

第三,对待新加结构,初始化的心态要"保守"。你新加的卷积层、全连接层最好是尽量不破坏预训练特征的初始化状态。随机初始化的幅度一大,前几个 epoch 的 loss 会异常地高,看起来像没收敛,其实只是初始扰动太大。必要的时候把新层输出的初始信号缩小(比如对卷积核做小方差初始化),训练会稳非常多。

第四,修改结构的代码尽量写成可配置的函数形式。比如我常用的写法是:

def build_custom_model(num_classes=10, replace_stage=None, use_se=False): model = models.resnet50(pretrained=True) model.fc = nn.Linear(model.fc.in_features, num_classes) if replace_stage is not None: ... if use_se: ... return model

这样同一个脚本能生成多种结构组合,做实验对照特别方便。很多"跑完就忘"的实验,都是因为结构代码没有参数化,事后再想复现同一组实验就得重新猜参数。

改预训练模型这件事,本质上就是搞清楚三层东西:网络的层级结构、state_dict 里的 key 映射、以及修改前后的特征链路约束。全连接层好改,因为它是"末端";卷积层难改,因为它嵌在整条链路中间。只要每次动手之前先把这三件事理清楚,无论改单个卷积层还是多个卷积层,都不会做成一团乱麻。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 12:51:20

用本地优先的网页工具打造PS5游戏数据管理台

AnyPS5这个项目,说白了是一个给我自己用的“个人游戏数据管理台”。我在PS5平台上玩游戏的时间不算短,攒下来的游戏也越来越多,但很长一段时间里,我对“自己到底有哪些游戏、哪个游戏多久没碰、存档什么时候备份过”这些问题&…

作者头像 李华
网站建设 2026/10/23 10:14:16

knowledge-work-plugins:插件化知识工作工具链的设计与实现

1. 这个插件体系到底在解决什么问题 第一次看到 knowledge-work-plugins 这个名字,我脑子里蹦出来的不是某个具体功能,而是一类很典型的困境:知识工作者的工具链太碎了。写文档用一个软件,做任务管理用一个,记笔记用一…

作者头像 李华
网站建设 2026/10/22 14:48:06

Java SSM框架在美容院管理系统中的实践与应用

1. 项目概述与背景 美容行业作为服务业的细分领域,近年来随着消费升级呈现出快速增长态势。传统美容院在客户管理、预约服务、产品库存等方面普遍存在效率低下、数据分散的问题。我去年参与开发了一套基于Java SSM框架的美容院管理系统,通过数字化手段解…

作者头像 李华
网站建设 2026/10/23 7:00:51

Ncat 网络工具实战:端口探测、文件传输与端口转发核心用法

1. 为什么值得花时间搞懂 Ncat Ncat 这个工具,很多人第一次接触是在排查端口连通性的时候。当时可能只是随手敲了一句 nc -zv 目标地址 端口 ,看到 succeeded 就关掉了终端。但如果你真的把它当成一个“网络瑞士军刀”来用,会发现它能做的事…

作者头像 李华
网站建设 2026/10/24 15:57:27

Java开发的糖尿病居家监控管理系统架构与实现

1. 项目背景与核心需求解析糖尿病作为全球范围内的高发慢性病,其日常监测与管理的重要性不言而喻。传统门诊随访模式存在数据断层、响应滞后等问题,而市场上多数健康管理软件又缺乏针对糖尿病的专业功能模块。这个Java开发的糖尿病居家监控管理系统&…

作者头像 李华
网站建设 2026/10/23 10:22:17

HTML5构建民谣音乐网站:技术实现与毕业设计指南

1. 项目背景与核心价值民谣音乐作为独立音乐的重要分支,近年来在年轻群体中持续升温。传统音乐平台往往将民谣混杂在流行音乐中,缺乏针对性的内容组织和社区氛围。这个毕业设计项目选择用HTML5技术构建垂直类民谣网站,正好切中了三个关键需求…

作者头像 李华