ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

预训练模型迁移学习:特征提取与部分微调怎么选

预训练模型迁移学习:特征提取与部分微调怎么选

1. 先把两个概念掰开:谁在训练,谁被冻住

做迁移学习的时候,模型部分微调和特征提取这两个词几乎天天出现在讨论里,但很多人第一次接触时会把它们当成一回事。我在带新人做图像分类任务时,最常听到的一句话就是“我把预训练模型加载进来了,然后呢?”。这个问题看着简单,背后其实是一个关于“信任程度”的决策:你到底有多信任预训练权重学到的表示,愿意让它在新数据上改动多少。特征提取的做法是把主干网络当成一个固定的、不可修改的特征变换器,参数全部冻死,只训练后面新加的那一小截分类头;部分微调则是把主干的一部分层解开,让它们跟着新任务一起更新,通常是解冻靠后的几个stage或者最后一个block,前面保留冻结。这个区别在定义上只有一句话,但落到实验结果、训练时长、显存占用、可复现性上,差异相当明显。

我先把结论摆在这里:特征提取本质上是在做“换头”,部分微调本质上是在做“换头加局部改造”。前者假设预训练模型已经学到了足够通用的表示,你只需要一个线性分类器把它映射到新标签空间;后者假设预训练表示大体可用,但和新任务之间存在语义偏移,需要让高层特征重新适应。这个假设成立与否,跟你的数据量、数据和新任务的距离、以及算力预算直接相关。写这篇文章我想把这两条路从原理到代码到踩坑都拆一遍,尽量让刚入门的朋友看完能直接上手,也让已经在做项目的人对选型有个清晰的判断依据。不管你现在是在做工业质检的小样本分类、还是做文本多分类、还是给一个小数据集做快速基线,这两个方案都是你绕不开的起点。

1.1 一句话区分:代码层面到底差在哪几行

如果只看代码,两者的差异小到让人意外,核心就是requires_grad和优化器收哪些参数。特征提取的典型写法是这样:加载预训练权重,把主干所有参数的requires_grad设成False,然后替换掉最后的全连接层,新建的层默认requires_grad=True,优化器只接收新层的参数。部分微调则是在这个基础上,把指定的若干层重新设回True,再把这些解冻的参数以更小的学习率交给优化器。注意这里有个很多新手会掉的坑:优化器是在冻结之前构造的,那么即使你后续把requires_grad改成False,那些参数虽然不会更新(因为梯度是None),但仍然会挂在优化器里占着状态内存,还可能被 weight decay 之类的逻辑误伤。所以正确顺序永远是先冻结、再建优化器。

再往细里说,特征提取还有一个更彻底的加速版本:既然主干完全不更新,那它对同一张输入永远输出同一个向量,那就干脆不要在每个 epoch 重复跑前向,直接一次性把所有样本的特征算出来存起来,后面只训练一个线性层或者甚至用逻辑回归。这个做法在数据量不大(几万张以内)、增强策略比较温和的场景下非常好用,原本要跑几个小时的训练几分钟就出结果,而且能让你快速判断“这套预训练特征到底有没有信息量”。当然它也有代价,后面会详细说。

部分微调的代码差异主要是参数分组。你需要把参数按层级拆成几组,分别给不同的学习率,通常主干靠后的层给1e-5到1e-4,新加的头给1e-3到1e-2,形成一种从“几乎不动”到“放开训练”的梯度。这个做法在业内叫判别式微调,思路来源于一个很朴素的观察:靠前的层学的是边缘、颜色、纹理这种通用东西,基本不需要改;靠后的层学的是语义组合,最需要适配新任务。所以给它们不同的步长,比一视同仁地设一个学习率更稳。

1.2 从反向传播看本质:梯度流经的范围决定了成本

要理解两者为什么性能和成本都不同,得从反向传播的路径上看。特征提取时,因为主干参数requires_grad=False,并且输入也不需要梯度,PyTorch 的自动微分机制在主干部分根本不会记录中间激活值,前向算完就释放。这意味着显存占用里最大的一块(激活值)几乎不占,只有新加的头部会保存中间结果。部分微调则不同,只要某一层参数需要梯度,它前面的所有计算图节点都得保留,直到反向传播走完。哪怕你只解冻最后一个 block,前面所有层的激活仍然要存着,因为梯度要一路传回去链式求导。这是很多人没意识到的点:解冻最后一层省下的显存远比你想象的少,省下的是计算量而不是显存。

我做过一个粗略的对比,以常用的 50 层残差网络为例,输入 224×224,全量微调在单卡上大概能开 batch size 64 左右就顶到显存上限,换成只训练分类头,同样显存能开到 256 甚至更高。但如果你只是解冻最后一个 stage,batch size 大概只能提到 80 到 96,提升有限。这个实测结果和很多人的直觉相反,他们以为“我只解冻一点点,显存应该接近特征提取”,实际不是。所以如果你的瓶颈是显存,特征提取才是真正的解法;如果你只是想省训练时间,部分微调的加速效果也就那样。

另一个从反向传播角度看的重要差异是梯度尺度。新加的分类头是随机初始化的,训练初期梯度很大;如果主干也一起放开,这些大梯度会顺着链式法则一路污染主干权重,把预训练学到的表示冲垮。这就是为什么微调必须用很小的学习率,或者先冻结主干训几轮头再解冻,业内管这个叫预热。特征提取天然没有这个问题,因为主干压根不接收梯度。

1.3 为什么会有这两种做法:迁移学习的谱系位置

把视野拉远一点看,特征提取和部分微调其实是“迁移学习”这条谱系上的两个点,而不是两个孤立的技术。谱系的一端是完全冻结、只训头,另一端是全量微调,中间还有逐层解冻、分层学习率、加轻量适配层等一串变体。选择哪个点,本质上是在回答三个问题:新数据和预训练数据的分布差多少、新数据有多少、你能接受多大的训练成本。分布差得远、数据又少,就往冻结那头靠,因为放开训练容易过拟合;分布差得远、数据挺多,反而应该多解冻,因为旧的高层语义确实不管用了;分布接近、数据少,冻结就够;分布接近、数据多,全量微调最省心。

这个谱系思维很关键,因为它让你不必在“提特征”和“部分微调”之间做二选一,而是可以设计一条渐进路线:先用特征提取跑一个基线,看看模型到底能到什么水平;如果欠拟合明显,再逐步解冻。这个流程我在实际项目里用了很多次,比一上来就调微调超参要高效得多,因为基线跑得快,你能在半天内摸清数据的难度上限。

2. 冻结参数到底改变了什么:原理与几个隐藏坑

2.1 requires_grad 与优化器参数组:一个必须理清的关系

很多人以为设了requires_grad=False就等于“这层不参与训练”,这个说法大方向没错,但细节上有几层含义需要分清。第一层是计算图层面:参数不需要梯度,前向过程中不会为该参数的操作保存反向所需的中间量,所以显存和计算都省。第二层是优化器层面:如果这个参数被传进了优化器,即使梯度是None,优化器仍然会在step()时跳过它(PyTorch 里p.grad is None时直接 continue),但优化器内部为它分配的动量缓冲、二阶矩缓冲依然占显存,对大的主干网络来说这笔开销不小。第三层是权重衰减层面:有些实现会在外部手动做参数衰减,如果不加过滤,冻结的层也会被缓慢改动,虽然幅度小,但会让“冻结”名不副实。

所以标准做法是写一个过滤函数,在构造优化器之前明确收集需要训练的参数:

def trainable_params(model): return [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.AdamW(trainable_params(model), lr=1e-3, weight_decay=1e-4)

如果想验证冻结是否生效,可以在第一个 epoch 结束后打印一下冻结层权重的范数,和初始值对比,差异应该是零。这个小检查我在调试时经常做,比看 loss 曲线更直接。还有一种情况是加载预训练权重之后requires_grad被重置了,比如某些封装库在load_state_dict之后会重建模块,这时候要重新设置一遍。

2.2 train 与 eval:BN 统计量和 Dropout 的隐形漂移

这是我认为最容易翻车、但文档里往往一笔带过的地方。批归一化层有两类东西:一类是可学习的缩放和平移参数(weight、bias),属于参数;另一类是滑动平均的均值和方差(running_mean、running_var),属于缓冲区,不是参数。你把参数requires_grad设成False,缓冲区依然会在model.train()模式下根据当前 batch 更新。也就是说,你以为自己“冻结了整个主干”,实际上主干里的归一化统计量正在被新数据慢慢改写。

这个行为在两种情况下会带来麻烦。第一种是新数据的分布和预训练数据差得比较远,比如预训练用的是自然图像,你的是灰度工业图,统计量被新数据改写后,原本适配的归一化就偏了,特征质量下降。第二种是 batch size 很小,比如只有 8 或 16,用这么小的 batch 估计出来的均值和方差噪声很大,会污染滑动平均,导致验证时表现抖动。这两种情况我都在项目里遇到过,现象都是“训练 loss 在降但验证集不涨甚至掉”,排查半天最后发现是归一化统计量在作祟。

解决办法是训练时把归一化层强制留在评估模式:

def freeze_bn(module): if isinstance(module, torch.nn.modules.batchnorm._BatchNorm): module.eval() model.apply(freeze_bn)

但要注意,model.train()会递归地把所有子模块设回训练模式,所以如果你在每个 epoch 开头调用model.train(),这个冻结就失效了。常见的处理是每个 epoch 训练前先model.train(),然后立刻再调用一次上面的冻结函数;或者干脆重写模型的train方法。Dropout 也是类似的逻辑,不过 Dropout 在推理时本来就关闭,训练时开启是正常行为,一般不需要特殊处理,除非你想让冻结主干的输出完全确定。

2.3 特征分布偏移:为什么越靠后的层越“认死理”

深度网络有一个被反复验证的现象:浅层学的东西通用性强,深层学的东西任务相关性强。浅层提取的是边缘、角点、颜色对比这类东西,无论你识别猫还是识别电路板缺陷,这些基元都在用;深层组合出来的可能是“猫耳朵”“车轮”这种具体概念,换到新领域就完全对不上。这就是为什么特征提取在小样本、域差异大的场景下常常表现平庸——你用的恰恰是最不适配的那一层特征。

我用过一个比较直观的诊断方法:把主干最后几层的输出特征做一次降维可视化,看新数据的两类样本在这上面是否可分。如果特征提取的线性探针准确率明显低于随机猜测加一点,说明高层特征几乎完全失效,此时只训头就是徒劳,必须解冻。反过来,如果线性探针能到八成以上,说明特征本身携带信息,问题出在分类头的容量或者正则上,这时候继续加深微调收益不大。这个判断比盲目调参有用得多,而且成本极低。

3. 两套可复现的实现骨架

3.1 特征提取:把主干当成固定变换器

最基础的实现方式是每个 epoch 跑一遍完整前向。这种写法简单,但浪费算力,因为主干输出对同一张图是恒定的(增强随机性除外)。更实用的写法是先做一次全量特征缓存,然后用缓存训练分类头。这样做有个前提:你的数据增强不能是强随机的,否则同一张图在不同 epoch 应该是不同视图,缓存就把增强固定住了。实践中我的做法是缓存阶段只用 resize 加中心裁剪这种确定性变换,训练头的时候不加增强或者只加很轻的噪声。

import torch import torch.nn as nn from torch.utils.data import DataLoader @torch.no_grad() def extract_features(backbone, loader, device): backbone.eval() feats, labels = [], [] for images, targets in loader: images = images.to(device, non_blocking=True) out = backbone(images) feats.append(out.cpu()) labels.append(targets) return torch.cat(feats), torch.cat(labels) # 主干只保留到池化层,去掉原始分类头 backbone = torchvision.models.resnet50(weights="DEFAULT") backbone.fc = nn.Identity() backbone = backbone.to(device) train_feats, train_labels = extract_features(backbone, train_loader, device) val_feats, val_labels = extract_features(backbone, val_loader, device)

缓存下来之后,训练分类头就是纯矩阵运算,一个 epoch 几百毫秒,可以把学习率、正则强度、头结构扫个遍。存储上算一笔账:50 层残差网络池化后是 2048 维,float32 每个样本 8KB,十万张图就是 800MB,用 float16 降到 400MB,内存里放得下;如果是百万级数据,就得落盘或者用分块训练。这个成本换来的收益是数量级的迭代速度提升,我个人觉得非常值。

如果任务需要空间信息,比如分割、检测、或者细粒度定位,那缓存的池化向量就不够用了,因为它把空间结构压没了。这时候你需要中间层的特征图,尺寸可能是原图的 1/32,一个 512×7×7 的张量。这种情况下“特征提取”实际上退化成“冻结主干 + 训练下游结构”,仍然省计算,但没法缓存成紧凑向量,显存占用也会回升,因为下游结构要读取大张量。

3.2 部分微调:分层解冻与差异学习率

部分微调的关键在于“解冻哪些、给多大学习率”。按名字匹配是最省事的做法:

model = torchvision.models.resnet50(weights="DEFAULT") model.fc = nn.Linear(model.fc.in_features, num_classes) # 先全部冻结 for p in model.parameters(): p.requires_grad = False # 只解冻最后一段和分类头 for name, p in model.named_parameters(): if name.startswith("layer4") or name.startswith("fc"): p.requires_grad = True groups = [ {"params": [p for n, p in model.named_parameters() if n.startswith("layer4")], "lr": 1e-5}, {"params": [p for n, p in model.named_parameters() if n.startswith("fc")], "lr": 1e-3}, ] optimizer = torch.optim.AdamW(groups, weight_decay=1e-4)

学习率的比例关系有个经验值:主干解冻部分的学习率是新头部的十分之一到百分之一。这个比例的来由是梯度尺度差异,头是随机初始化的,梯度大,需要大步长;主干是预训练好的,只需要小步长微调,步长太大会把已有表示冲掉。我一般在数据量几千到几万的场景下用 1:100,数据量上万之后可以放宽到 1:30 甚至 1:10。

另一个细节是解冻的粒度。按残差网络的四个 stage 来解冻比较自然,只解冻layer4是稳妥起点,效果不够再解冻layer3。从后往前逐层放开,配合更小的学习率,这就是逐层解冻的朴素版本。它比一次性解冻全部要稳,因为每一步的改动都比较小,你能观察到验证集在哪一步开始变差,从而确定解冻的深度上限。

3.3 算力与显存:把账算清楚再动手

以常用的 50 层残差网络为例,总参数量约 2560 万。如果做十类分类,新的全连接层是 2048×10 加 10 个偏置,约 2 万个参数,占总量的万分之八。这个数字放在一起看很直观:特征提取方案实际训练的参数量只有全量微调的万分之八,训练速度快一个数量级是理所当然的。前向计算量方面,单张 224×224 输入的前向大约 4 GFLOPs,特征提取每个样本只需要一次前向,全量微调需要前向加反向,反向大约是前向的两倍,所以约 12 GFLOPs。

显存方面,激活值是大头,粗略估算按每层输出张量的大小累加,输入分辨率和 batch size 都成正比。实际的数字跟实现框架的优化策略有关,我建议你在自己的环境里跑一次实测,用torch.cuda.max_memory_allocated()打点,比自己推导更靠谱。实测的方法是固定 batch size 逐步增大,看什么时候抛显存不足,两个方案各测一次,把结论记在项目笔记里,下次换任务直接复用。

还有一点容易被忽略:训练时间不只是计算量决定的。特征提取能通过缓存把 IO 和数据增强的开销也省掉,实际提速往往超过理论值;而部分微调时数据加载如果跟不上,GPU 利用率会掉到五六成,这时候提升 dataloader 的 worker 数量比优化模型更有效。

3.4 两套方案的训练配置对照表

维度特征提取部分微调
可训练参数仅新分类头(约万分级)头部加主干后段(百万级)
主干梯度无,激活不保存有,全部前向激活需保存
典型显存占用低,可开大 batch中高,受分辨率影响大
单轮训练耗时秒级(缓存后)分钟级
过拟合风险低,参数少中高,需要早停与正则
对域差异的适应弱中到强
超参敏感度低,主要是正则强度高,学习率比例影响大
复现难度极低中,随机种子与冻结顺序都要固定
适合的数据量几百到几万几千到几十万
适合的域差异小到中中到大

这张表不是教条,但能帮你在项目开始时快速定一个起点。我的习惯是无论什么任务,都先跑特征提取拿到基线数字,再决定是否要往表的下半部分走。

4. 选型:不同场景下怎么定

4.1 决策依据:数据量、域差异、算力与交付周期

选型不该凭感觉,我一般看四个量。第一个是标注数据量,几百到几千条的时候,可训练参数越少越稳,特征提取几乎是唯一选择;几万条可以开始尝试解冻最后一两个 stage;十万条以上才值得考虑全量微调。第二个是域差异,用公开自然图像预训练的权重去做医学影像或者遥感影像,低层特征还能用,高层基本失效,这时候只训头往往卡在七成准确率上不去,需要解冻中后段。第三个是算力,如果只有一张消费级显卡、还要和别的任务共享,训练时长直接影响你能做多少轮实验,特征提取那种十几分钟出一版结果的节奏对小团队更友好。第四个是交付周期,如果一周内要出可用模型,先特征提取交付第一版,再慢慢迭代微调版本,这个策略在项目排期紧的时候特别实用。

还有一个容易被忽视的维度是验证集的可信度。小样本场景下验证集本身就只有几百条,微调版的方差会很大,同一个配置换个随机种子可能差三四个点,这时候你很难判断微调到底有没有收益。特征提取的参数少、方差小,结论更可靠。我在数据少于两千条的项目里基本不看微调结果,因为噪声比信号大。

4.2 典型场景拆解

小样本工业缺陷分类是很典型的例子。缺陷样本往往只有几百张,而且图像是灰度的、高对比度的,和预训练用的自然图像差异极大。这种场景下很多人第一反应是解冻整个网络微调,结果过拟合严重,验证准确率来回震荡。我的做法是特征提取先跑,同时把输入预处理改成三通道复制加归一化调整,让统计量尽量贴近预训练分布;如果线性探针只能到六成,说明高层特征确实不管用,再解冻最后一个 stage,学习率压到1e-6级别,配合强增强和早停。这套组合在我手上比直接全量微调稳定得多。

文本分类是另一个常见场景。用预训练语言模型做下游任务时,特征提取对应的是取池化输出接一个线性层,部分微调对应的是解冻最后几层 Transformer block。经验上,情感分析这类和预训练目标比较接近的任务,冻结后几层就能拿到很好的效果;而领域术语密集的任务,比如专业文档分类,高层语义偏移明显,解冻两到四个 block 收益显著。这里还要注意归一化层的位置,Transformer 里的 LayerNorm 不涉及跨样本统计,冻结与否的影响比卷积网络里的 BatchNorm 小很多,这一点常被混淆。

分割和检测这类密集预测任务是特征提取的“例外区”。因为输出需要空间分辨率,你不能只用池化向量,必须用中间特征图,而中间特征图接上采样和解码器之后,梯度虽然不回传到主干,但主干前向的激活要保留给解码器读取,显存优势就没了。这类任务我基本直接上冻结主干的完整微调框架,或者只解冻解码器,把“特征提取”这个词换成“固定编码器”更准确。搞清楚这个区别能避免你在检测任务上照着分类的教程去缓存特征,白折腾半天。

4.3 折中路线:先提特征再解冻、逐层解冻、轻量适配层

实际项目里我更常用的是渐进路线,而不是一开始就二选一。第一步,冻结主干训练头,跑到收敛,记下验证指标,这是下限。第二步,解冻最后一个 stage,学习率设成头部的百分之一,再训若干轮,看指标有没有超过下限一个阈值,比如两个点以上。如果没有,说明瓶颈不在主干表示,可能是数据质量问题或者标签噪声,继续解冻只会更糟。第三步,如果确有提升,再往前解冻一层,重复判断。这个流程的好处是每一步都有对照,你能清楚知道收益来自哪一层,而不是一锅端地调参。

另一个折中思路是加轻量适配结构,比如在冻结主干之后插入一个小的瓶颈层或者低秩适配矩阵,只训练这部分参数。这种做法的参数量介于纯特征提取和小范围微调之间,显存占用接近特征提取,但表达能力比纯线性头强。实现上就是在主干输出后接一个降维再升维的小模块,维度压缩比取 8 到 32,参数量控制在几万到几十万。我在显存吃紧又要提升精度的场景下用过几次,收益比纯线性头稳定高出一到三个点。

5. 踩坑实录与排查清单

5.1 训不动或掉点时的排查顺序

遇到微调后验证指标不如特征提取基线,我按这个顺序查。先看学习率,主干学习率是不是设太大了,把预训练权重冲掉了,直接现象是训练 loss 快速下降但验证集在第二三个 epoch 就开始恶化。然后看归一化层,是不是没冻结统计量,batch 又小。接着看数据预处理,训练和验证的归一化参数是否一致,这一点在手工实现时经常出错。最后看解冻范围,是不是一次解冻太多层。这个顺序不是随便排的,是按出现频率排的,我遇到的案例里学习率和归一化两类占了绝大多数。

还有一个隐蔽的原因是类别不平衡下的采样策略。特征提取时因为只训线性头,模型容量小,对不平衡的敏感度低;换成微调后容量变大,模型会迅速偏向多数类,看起来像“掉点”,实际是阈值问题。这时候调损失函数的类别权重比调解冻范围更有效。

5.2 特征提取效果上不去的常见原因

线性探针准确率卡住,先怀疑预处理不匹配。预训练模型对输入的均值方差、通道顺序、颜色空间是有预期的,你把灰度图直接塞进去,或者归一化用了另一套参数,特征质量会明显下降。其次是分辨率,预训练用 224 但你把图缩到 128,高层感受野对不上,特征会失真。第三是池化方式,全局平均池化对小目标不友好,如果任务关注局部细节,换成最大值池化或者拼接多种池化结果可能有改善。第四是特征层选错,用最后一层的输出未必最好,中间层的特征有时线性可分性更强,值得把几个 stage 的输出都取出来做一次探针对比。

我做过一次系统的探针实验,把四个 stage 的输出分别接线性分类器,结果中间层的准确率比最后一层高出五个点,原因就是最后一层的语义太偏向原始任务的类别体系。这个实验成本很低,但能帮你选到更合适的特征层,比盲目加深网络有效。

5.3 显存、速度与复现性问题

显存爆掉的时候,先确认有没有多余的梯度记录。特征提取阶段忘了加torch.no_grad()或者在评估时没切到eval(),都会让显存翻倍。部分微调阶段可以试试梯度检查点,用时间换显存,代价是训练变慢两三成。速度慢的话,先看数据加载是不是瓶颈,用nvidia-smi看 GPU 利用率,低于七成基本就是数据管道拖后腿,增加 worker、预取、把增强放到 GPU 上做都能改善。

复现性问题主要来自三处:随机种子没固定全(包括数据增强的随机数生成器)、冻结顺序在不同运行里不一致、以及非确定性算子。前两个靠代码规范解决,把冻结逻辑写成一个函数在入口调用一次;第三个在调试阶段可以开启确定性模式,虽然会慢一点,但至少能保证两次运行结果一致。

5.4 问题速查表

现象可能原因处理方式
微调后验证指标低于特征提取基线主干学习率过大降到头部学习率的百分之一
训练 loss 降但验证抖动大归一化统计量被小 batch 污染训练时冻结归一化层为评估模式
特征提取探针准确率很低预处理与预训练预期不匹配对齐均值方差、通道数、分辨率
显存占用远超预期主干激活被记录检查是否遗漏无梯度上下文
同一配置两次结果差异大种子未固定或冻结顺序不一致统一入口冻结并固定全部随机源
微调收敛极慢头部初始化尺度过小或学习率过低检查头部初始化,头部学习率单独放大
解冻多层后性能反而下降解冻范围超出数据支撑能力回退到只解冻最后一层

这张表我贴在项目笔记首页,新同事上手时照着查,能省掉大量重复的排查时间。表格里的每一条都是从实际报错和曲线异常里总结出来的,不是理论推演。

最后分享一点个人体会。这几年做下来,我发现选型的难点不在技术本身,而在于你对自己数据的判断有多准。同样是小样本任务,有的数据虽然少但标注质量高、类间差异明显,特征提取就能到九成;有的数据量不小但标签噪声大、类间混淆严重,微调再怎么调也就七成出头。所以在动手调参之前,花半天时间看看错样本、算算类间距离、跑一次线性探针,比后面几天的调参都值。我现在的习惯是任何新任务先花两小时做这三件事,基本就能判断该走哪条路,剩下的时间都花在数据清洗和验证集构建上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进