加载预训练模型为何要替换全连接层?迁移学习核心操作解析 📅 发布时间:2026/9/9 6:09:11 👁 浏览次数: 1. 为什么加载预训练模型时第一件事就是关掉全连接层先把这个结论放在最前面你下载的预训练模型几乎没有一个是为你的任务准备的它们的最后一层全连接层只对“它原本训练的那个任务”有效。我见过太多新手踩同一个坑——高高兴兴把resnet50预训练权重下载下来加载进自己的模型然后跑起来直接报维度错误或者更隐蔽的维度没报错但训练好几轮loss纹丝不动精度低得离谱。问题的根源十有八九出在把人家模型“脖子以上”的部分也一起端了过来。用一个生活化的类比来解释这件事。预训练模型就像一位已经精通素描、色彩和构图的画师它的卷积层和注意力层学的是自然界通用的视觉特征边缘、纹理、形状、物体部件。这些能力放之四海而皆准无论你是要分类猫狗、检测划痕还是分割肿瘤这些底层特征都能复用。但画师最后画出来的“成品”是什么主题是由他最后那个工作室——也就是全连接层——决定的。这位画师原本的工作室是画1000种ImageNet物体分类的你非要让他直接画“你的产品瑕疵分类”他画出来的当然不是你要的东西。具体来说预训练模型后半部分的全连接层把学到的特征“映射”到了它自己训练时的类别空间。以torchvision里经典的ResNet50为例默认结构末尾是全局平均池化AdaptiveAvgPool2dFlattenLinear(in_features2048, out_features1000)这里out_features1000就是ImageNet的1000个类别数。如果你自己的任务只有10类加载这个模型后输入一张图模型输出一定是1000维的向量。你接一个自己的分类头维度对不上训练系统直接抛异常。退一步说就算你用了什么trick把它强制对齐了也毫无意义。因为这个全连接层的权重存储的是“ImageNet 1000类”这个特定语义空间下的分类决策边界。这个边界对你的任务来说不仅没用还会起反作用——它相当于一个固执的老顾问不断把模型的注意力往错误的方向带。所以加载预训练模型的操作原则就一句话把模型当特征提取器用只拿卷积/注意力部分丢掉它自带的那个“脑袋”换上你自己的新脑袋。这就是本题“关掉全连接层”的真正含义。2. 哪些层要留、哪些层要换特征层和任务层的正确切分要准确执行“关掉全连接层”先得搞清楚预训练模型的结构切分逻辑。2.1 特征提取层预训练模型的真正价值所在预训练模型从输入到最后一个卷积块/注意力块以及伴随的池化层这部分称为backbone或特征提取器。它的作用是不断抽象输入数据生成高层次的语义特征向量。还是看ResNet50特征是2048维。这2048维向量已经浓缩了一张图片的全局语义信息。你可以把它看作模型对输入的一张“画像描述”。对于绝大多数下游任务这张“画像”的质量已经足够好好到很多任务光靠这个向量就能取得不错的结果。类似的NLP领域的预训练模型如roberta-zh它的做法是前向传播后取[CLS]位置的输出向量或者对token级输出做池化这个向量同样是对整句输入的高度抽象。这部分没有任何类别相关的信息是通用的语义表示。2.2 任务头classification head必须替换的模块任务头通常就是全连接层组合作用是把特征向量映射到具体的输出空间——分类任务的类别概率、回归任务的连续值、检测任务的框坐标和类别。以分类任务为例这个头的核心就是一个Linear(in_features特征维度, out_features类别数)。类别数是你自己的num_classes。因此你必须删掉预训练模型自带的全连接层新建一个随机初始化的全连接层输出维度你的类别数。有些预训练模型结构更复杂比如torchvision里SSD检测模型的头是多个卷积层加全连接层组合NLP模型的头部可能是好几层全连接的结构。但原理一致凡是输出维度与预训练任务强相关的层统统扔进垃圾桶凡是负责抽象特征的层原样保留。2.3 一个高频混淆点torchvision vs timm vs HuggingFace不同代码库的模型结构命名、属性名完全不同这是很多初学者栽跟头的地方。torchvision.models.resnet50模型有fc属性直接替换即可timm.create_model(resnet50, pretrainedTrue)模型末尾属性叫head或fc不同模型变体命名不统一建议直接打印model查看HuggingFace transformers的BertModel/RobertaModel模型有pooler和classifier。BertModel本身不带分类头只有底层的pooler一个全连接层如果你用的是BertForSequenceClassification那你需要重新实例化时直接指定num_labels框架会帮你重新初始化分类头。实操建议加载预训练模型后第一件事永远是print(model)把模型结构从头到尾看一遍确认一下最后一层/头部到底叫什么名字。这个习惯价值巨大能帮你避开60%以上的加载陷阱。3. 多种网络热词的真正含义ResNet预训练、RoBERTa中文以及RBF替代全连接层看到热搜词里有“resnet预训练模型”“roberta中文预训练模型”“径向核函数代替全连接层”这三个词实际上对应了“关掉全连接层”这个操作在三个不同维度上的具体延伸。3.1 ResNet预训练模型视觉任务里的标准操作视觉领域最常用的加载方式从ResNet系列到EfficientNet、ConvNeXt操作都是类似的。以torchvision为例标准的替换写法是import torchvision.models as models import torch.nn as nn # 加载预训练权重backbone参数自动填充 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) # 打印模型结构找到fc层 print(model) # 替换掉最后一个全连接层换成自己的分类头 num_classes 10 model.fc nn.Linear(in_featuresmodel.fc.in_features, out_featuresnum_classes)注意上面我用了一个model.fc.in_features来动态获取输入维度而不是硬编码2048。这是我认为最稳妥的写法——就算换了模型不用自己去查文档数维度代码自动适配。3.2 RoBERTa中文预训练模型NLP任务的同款操作NLP领域换分类头思路完全一致但API略不同。以中文RoBERTa为例from transformers import AutoModelForSequenceClassification, AutoTokenizer model_name hfl/chinese-roberta-wwm-ext tokenizer AutoTokenizer.from_pretrained(model_name) # 直接指定num_labels框架自动帮你重新初始化分类头 model AutoModelForSequenceClassification.from_pretrained( model_name, num_labels2 # 二分类任务 )注意这里并没有“手动删除全连接层”因为你用的是AutoModelForSequenceClassification这个带分类任务的类它本身在加载时就会拿一个新的随机初始化分类头替换掉预训练原生的分类头如果原模型带的话。但如果你用的是原始AutoModel也就是BERT/RoBERTa基底模型那它压根没有分类头你要自己去接from transformers import AutoModel import torch.nn as nn base_model AutoModel.from_pretrained(hfl/chinese-roberta-wwm-ext) class MyClassifier(nn.Module): def __init__(self, base_model, num_labels): super().__init__() self.base_model base_model # 注意这里拿的是base_model的hidden_size做分类头输入维度 self.classifier nn.Linear(base_model.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask): outputs self.base_model(input_idsinput_ids, attention_maskattention_mask) # 取[CLS]位输出形状是 [batch_size, hidden_size] cls_token outputs.last_hidden_state[:, 0, :] return self.classifier(cls_token)这里有一个非常重要的经验千万不能用base_model.pooler的输出作为分类特征除非你明白自己在做什么。这是HuggingFace BERT系列模型的一个历史遗留问题——pooler层在预训练阶段被训练用来做“Next Sentence Prediction”任务它输出的向量严重丢失了下游任务需要的语义信息。用它与用[CLS]直出向量在很多中文任务上精度差出一截这是我实测过的结论。3.3 径向核函数代替全连接层一种值得收藏的替换思路热搜词中出现了“径向核函数代替全连接层”这是一个很硬核的进阶玩法。核心思想是既然全连接层本质上是“特征向量到类别空间的线性映射”那这个映射不一定非得用全连接层做。径向基函数Radial Basis Function, RBF网络的做法是为每个类别设置若干原型向量然后计算输入特征与这些原型之间的距离高斯核、多项式核等把距离值作为分类依据。import torch import torch.nn as nn class RBFLayer(nn.Module): def __init__(self, in_features, num_classes, gamma1.0): super().__init__() self.gamma gamma # 每个类别一个原型中心可以初始化为类别特征均值 self.centers nn.Parameter(torch.randn(num_classes, in_features)) def forward(self, x): # x: [batch_size, in_features] diff x.unsqueeze(1) - self.centers.unsqueeze(0) # [batch, num_classes, in_features] dist_sq torch.sum(diff ** 2, dim-1) return torch.exp(-self.gamma * dist_sq) # [batch, num_classes]这种替代方案的优势在于RBF层的决策边界更灵活在样本量少、类别不平衡的场景下比起随机初始化的全连接层更容易收敛对冻结backbone的小样本任务尤为友好。同时RBF输出天然带有“距离语义”——特征离某个类的原型越近输出越大这比全连接层的logits更直观、更容易解释。但它也有明显的缺点原型中心的初始化质量对结果影响巨大如果初始点选得不好训练后期容易陷入局部最优。我个人的建议是不要一上来就用RBF替代全连接层先跑通标准的线性分类头确认特征提取部分没问题后再做这类“锦上添花”的尝试。4. 实操示范三种加载预训练模型的正确姿势与错误示范纸上谈兵终觉浅这一节我们直接把代码写在明面上给三类最常见的使用场景各配一份可直接抄走的代码。4.1 场景一PyTorch ResNet50做图像分类目标用ImageNet预训练模型提取特征应对一个10类图片分类任务。import torch import torch.nn as nn import torchvision.models as models from torchvision import transforms from PIL import Image # 1. 加载预训练模型 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) # 2. 查看模型结构确定修改点 # print(model) # 3. 冻结backbone参数可选根据数据量决定 for name, param in model.named_parameters(): if fc not in name: # fc层不冻结让它从头学 param.requires_grad False # 4. 替换全连接层 num_classes 10 model.fc nn.Sequential( nn.Dropout(p0.2), nn.Linear(model.fc.in_features, 512), nn.ReLU(inplaceTrue), nn.Dropout(p0.2), nn.Linear(512, num_classes) ) # 5. 验证输出形状 dummy_input torch.randn(2, 3, 224, 224) output model(dummy_input) print(模型输出形状:, output.shape) # 期望 [2, 10]关于是否冻结backbone我有一个建议如果数据集只有几百张建议全部冻结只训练分类头防止过拟合如果数据集有几万张建议只冻结浅层前几层微调深层和高层效果最佳如果数据集再大且和ImageNet域差异很大比如医学影像、卫星图建议全部解冻用较小的学习率整体微调同时把分类头的学习率设得比backbone高一些比如backbone用1e-5分类头用1e-3。4.2 场景二TensorFlow/Keras ResNet50Keras的用户也不少这里一并给出对应写法import tensorflow as tf from tensorflow.keras.applications import ResNet50 from tensorflow.keras import layers, models # 1. 加载预训练模型包含顶层全连接层 base_model ResNet50(weightsimagenet, include_topTrue, input_shape(224, 224, 3)) # 2. 取出base_model的输入和倒数第二层全局平均池化后的特征 base_input base_model.input feature_output base_model.layers[-2].output # 最后一层是Dense(1000)倒数第二层是全局平均池化 # 3. 接上自己的分类头 x layers.Dropout(0.2)(feature_output) x layers.Dense(512, activationrelu)(x) predictions layers.Dense(10, activationsoftmax)(x) # 4. 构建新模型 model models.Model(inputsbase_input, outputspredictions) # 5. 冻结base_model base_model.trainable False model.compile(optimizeradam, losscategorical_crossentropy, metrics[accuracy]) model.summary()这里的关键操作是include_topTruelayers[-2].output利用Keras函数式API把预训练模型的“脖子以下”部分接了出来。另一种更简洁的方式是include_topFalse这样压根不会加载全连接层模型输出直接就是特征图。但要注意include_topFalse时输出是(7, 7, 2048)的特征图而不是向量需要自己接GlobalAveragePooling2Dbase_model ResNet50(weightsimagenet, include_topFalse, input_shape(224, 224, 3)) x base_model.output x layers.GlobalAveragePooling2D()(x) x layers.Dense(10, activationsoftmax)(x) model models.Model(inputsbase_model.input, outputsx)这两种写法的差别在前一种用了预训练模型里的池化层layers[-2]后一种自己新加了一个池化层。对于ResNet系模型两者的差距几乎可以忽略但第二种写法更灵活不依赖模型内部层级顺序我是更推荐后者的。4.3 场景三HuggingFace RoBERTa中文做文本分类NLP场景的实际操作如下from transformers import AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer from datasets import Dataset model_name hfl/chinese-roberta-wwm-ext tokenizer AutoTokenizer.from_pretrained(model_name) # 二分类任务让框架自动重建分类头 model AutoModelForSequenceClassification.from_pretrained( model_name, num_labels2, id2label{0: 负面, 1: 正面}, label2id{负面: 0, 正面: 1} ) # 如果你知道config手动改head也是可以的 # from transformers import AutoConfig # config AutoConfig.from_pretrained(model_name, num_labels2) # model AutoModelForSequenceClassification.from_pretrained(model_name, configconfig) # 验证前向 texts [这家餐厅的菜非常好吃服务也特别周到, 等了一个小时还没上菜体验很差] inputs tokenizer(texts, paddingTrue, truncationTrue, max_length128, return_tensorspt) outputs model(**inputs) print(logits形状:, outputs.logits.shape) # 期望 [2, 2]这句num_labels2是HuggingFace框架的灵魂参数。用AutoModelForSequenceClassification时框架各方面都会自动适配只保留基底模型相当于去掉了原有的预训练分类头如果有的话在顶部新建一个nn.Linear(hidden_size, num_labels)权重是随机初始化的forward返回的logits维度自动变成[batch_size, num_labels]。我看到网上很多教程还在教“手动删除classifier层再新建”其实在HuggingFace生态里完全没必要那么麻烦多传一个num_labels就够了。4.4 错误示范我复盘过多次的教训错误一只改维度不改结构# 错误写法 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) model.fc nn.Linear(2048, 10) # 看似正确但这里in_features硬编码了2048这个写法问题不大但如果换成了ResNet18in_features512代码就崩了。用model.fc.in_features取维度才是正解。错误二冻结了backbone但忘了把eval()和train()切对# 错误示例冻结后一直用model.train() for param in model.parameters(): param.requires_grad False model.fc nn.Linear(2048, 10) # 训练时又调用了model.train() # 注意BatchNorm和Dropout在这种模式下会被错误激活如果模型里含BatchNorm层ResNet必有冻结backbone后训练时要特别小心。model.train()会让BN层继续更新running_mean/running_var而此时backbone参数是冻结的——BN层的统计量和卷积层参数就错位了。稳妥做法是def set_bn_eval(m): if isinstance(m, nn.BatchNorm2d): m.eval() model.apply(set_bn_eval)错误三NLP里忘了model.eval()导致预测结果随机推理阶段不切eval()Dropout还在随机丢弃信息同一句话每次预测结果还可能不一样。我当时犯这个错之后才真正理解模型有train/eval两种模式不只是形式要求直接影响前向计算逻辑。5. 必须注意的四个隐藏问题BN层、输出维度、参数量和transfer learning的边界抛开“替换全连接层”这个操作本身我在实战中还踩过一些隐藏更深的坑这里整理出来给你排雷。5.1 backbone冻结了BN层却还在更新这个问题前面提到过但值得单独强调一次。当你执行requires_grad False冻结backbone时PyTorch默认不会自动冻结BN层的统计量更新。BN层有两个状态一个是可学习的gamma和beta另一个是running_mean和running_var。前者受requires_grad控制后者不受控制——只要模型处于train()模式前向传播就会更新running_mean/running_var。如果数据量小BN层统计量乱飘模型精度会变得飘忽不定。解决办法就是上面那个set_bn_eval函数这个技巧在很多开源项目里都能看到不是玄学是真的管用。5.2 输出维度不匹配但没报错的隐蔽场景有一种情况迷惑性极强model.fc.out_features恰好等于你的类别数比如你的任务恰好也是1000类于是模型加载成功、前向成功、loss也在下降但训出来的模型效果很差。为什么因为虽然输出维度巧合匹配了但分类头的权重初始值还是ImageNet训练完的旧值——这个旧分类头对应的是“猫狗车船飞机”那套语义空间现在你的任务类别可能完全不同拿着旧的分类决策边界硬套新任务模型必须花大量时间去“遗忘”旧知识。我见过有人在这种巧合下训了20个epoch精度还是上不去最后换成随机初始化的分类头3个epoch就明显好转。所以判断标准不是“维度是否一致”而是“这个分类头是不是我的任务训练过的”。只要不是一律随机初始化。5.3 参数量的隐形变化换了分类头之后model.parameters()的总数变了。你如果是在做模型参数量对比的实验记得重新统计。我之前的经验是ResNet50的1000类全连接层大约占200万个参数2048*1000 1000换成10类分类头后只有约2万个参数模型总参数量会明显下降。如果任务有严格的模型大小限制比如部署在边缘设备上这个差异是需要考虑进去的。5.4 Transfer Learning的边界什么时候不该用预训练模型关掉全连接层、保留backbone这个思路的本质是迁移学习但迁移学习不是万能的。当你的输入数据和预训练模型的数据域差异极大时——例如用ImageNet预训练模型处理医学CT影像或者用中文RoBERTa处理一堆全是专业代码的运维日志——backbone的底层特征可能完全不适用。这时候有两种选择解冻所有层用较小学习率全局微调让模型重新适配新域干脆不用预训练模型从头开始训练。我自己的经验准则是如果你的数据集有10万条且和预训练域差异巨大从头训练的效果可能优于“预训练微调”因为预训练模型在错误方向上已经走得很远微调要花大量力气“掰回来”。但如果数据只有几千条无论如何都建议用预训练backbone哪怕域差异大也比随机初始化强得多——毕竟特征提取的基础能力是共通的。6. 常见问题与排查技巧实录把这些问题整理成一张速查表方便你遇到问题时直接对号入座。问题现象根本原因解决方案加载模型后前向报错提示mat1 and mat2 shapes cannot be multiplied全连接层输入维度与自己的特征维度不匹配打印模型结构确认in_features用model.fc.in_features动态读取前向成功但loss不下降或精度极低分类头保留了预训练权重且类别恰好巧合匹配确认分类头必须随机初始化不要沿用预训练权重训练时loss正常验证时效果极差BN层在train/eval模式下切换异常或Dropout未关闭冻结BN统计量验证阶段调用model.eval()同一输入两次预测结果不同推理时模型还处于train()模式Dropout仍在工作推理前调用model.eval()冻结backbone后模型任何参数都不更新连分类头也被冻结了检查冻结逻辑应跳过分类头参数打印requires_grad确认微调后精度反而比冻结epoch还差学习率设置过大破坏了预训练特征整体微调时学习率调到1e-5~1e-4量级分类头单独设更大学习率用HuggingFace模型时[CLS]向量做分类效果不佳没有使用正确的特征提取方式使用last_hidden_state[:, 0, :]绕开pooler输出我个人还想补充一个排错技巧遇到任何诡异的模型前向问题先别急着查数据先做一个最小的随机张量前向测试dummy torch.randn(4, 3, 224, 224) try: out model(dummy) print(前向成功, 输出形状:, out.shape) except Exception as e: print(前向失败:, e)这个“3分钟冒烟测试”能帮你把问题从“数据处理”和“模型结构”两个环节快速区分开来。模型结构的问题基本都会在这里暴露无遗。另外加载预训练权重时如果遇到strictTrue导致的key不匹配错误因为你的模型已经没有原始全连接层了有两条路可以走加载时加strictFalse让PyTorch跳过不匹配的层——但要注意这种加载方式很容易悄悄跳过你本意要加载的层隐患较大更稳妥的办法是先加载预训练权重到原模型结构再替换分类头这样权重加载是严格的不遗漏任何特征层。推荐第二种。# 推荐写法 import torchvision.models as models import torch.nn as nn # 第一步创建预训练模型带原始fc层 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) # 第二步替换fc层 model.fc nn.Linear(model.fc.in_features, 10) # 用strictFalse再加载一次也可以但上面两步法已经包含了权重7. 把“关掉全连接层”变成肌肉记忆说回这个话题本身。很多人第一次接触“加载预训练模型记得关掉全连接层”这句话以为就是一句简单的操作提醒但深挖进去会发现它背后牵涉到预训练模型的结构理解、特征层与任务层的切分、不同框架的API差异、以及一套完整的迁移学习训练策略。我个人在实际操作中的体会是这句话并不仅适用于初次加载模型的场景而是贯穿整个微调周期的一条主线。每次你改动任务、改动模型、改动数据都应该回头检查一次当前这个分类头还是不是随机初始化的backbone的参数是冻结还是解冻的BN层是处于什么状态这三个问题如果每次都过一遍就能避开90%以上的迁移学习陷阱。最后再分享一个小技巧如果你在做研究或者实验对比建议把“替换全连接层”“冻结策略”“学习率配置”这些操作全部封装成config参数而不是散落在代码各处。这样每跑一组实验改的只是config代码路径始终一致实验结果的可复现性和可比性都会好很多。预训练模型的生态越来越丰富从ResNet到ViT、从BERT到RoBERTa模型越来越大但这个核心原则始终没变拿它学会的特征忘掉它学会的任务。每次加载模型前默念三遍能帮你在深度学习这条路上少走很多弯路。