ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

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

预训练模型迁移学习:特征提取与部分微调怎么选 1. 先把两个概念掰开谁在训练谁被冻住做迁移学习的时候模型部分微调和特征提取这两个词几乎天天出现在讨论里但很多人第一次接触时会把它们当成一回事。我在带新人做图像分类任务时最常听到的一句话就是“我把预训练模型加载进来了然后呢”。这个问题看着简单背后其实是一个关于“信任程度”的决策你到底有多信任预训练权重学到的表示愿意让它在新数据上改动多少。特征提取的做法是把主干网络当成一个固定的、不可修改的特征变换器参数全部冻死只训练后面新加的那一小截分类头部分微调则是把主干的一部分层解开让它们跟着新任务一起更新通常是解冻靠后的几个stage或者最后一个block前面保留冻结。这个区别在定义上只有一句话但落到实验结果、训练时长、显存占用、可复现性上差异相当明显。我先把结论摆在这里特征提取本质上是在做“换头”部分微调本质上是在做“换头加局部改造”。前者假设预训练模型已经学到了足够通用的表示你只需要一个线性分类器把它映射到新标签空间后者假设预训练表示大体可用但和新任务之间存在语义偏移需要让高层特征重新适应。这个假设成立与否跟你的数据量、数据和新任务的距离、以及算力预算直接相关。写这篇文章我想把这两条路从原理到代码到踩坑都拆一遍尽量让刚入门的朋友看完能直接上手也让已经在做项目的人对选型有个清晰的判断依据。不管你现在是在做工业质检的小样本分类、还是做文本多分类、还是给一个小数据集做快速基线这两个方案都是你绕不开的起点。1.1 一句话区分代码层面到底差在哪几行如果只看代码两者的差异小到让人意外核心就是requires_grad和优化器收哪些参数。特征提取的典型写法是这样加载预训练权重把主干所有参数的requires_grad设成False然后替换掉最后的全连接层新建的层默认requires_gradTrue优化器只接收新层的参数。部分微调则是在这个基础上把指定的若干层重新设回True再把这些解冻的参数以更小的学习率交给优化器。注意这里有个很多新手会掉的坑优化器是在冻结之前构造的那么即使你后续把requires_grad改成False那些参数虽然不会更新因为梯度是None但仍然会挂在优化器里占着状态内存还可能被 weight decay 之类的逻辑误伤。所以正确顺序永远是先冻结、再建优化器。再往细里说特征提取还有一个更彻底的加速版本既然主干完全不更新那它对同一张输入永远输出同一个向量那就干脆不要在每个 epoch 重复跑前向直接一次性把所有样本的特征算出来存起来后面只训练一个线性层或者甚至用逻辑回归。这个做法在数据量不大几万张以内、增强策略比较温和的场景下非常好用原本要跑几个小时的训练几分钟就出结果而且能让你快速判断“这套预训练特征到底有没有信息量”。当然它也有代价后面会详细说。部分微调的代码差异主要是参数分组。你需要把参数按层级拆成几组分别给不同的学习率通常主干靠后的层给1e-5到1e-4新加的头给1e-3到1e-2形成一种从“几乎不动”到“放开训练”的梯度。这个做法在业内叫判别式微调思路来源于一个很朴素的观察靠前的层学的是边缘、颜色、纹理这种通用东西基本不需要改靠后的层学的是语义组合最需要适配新任务。所以给它们不同的步长比一视同仁地设一个学习率更稳。1.2 从反向传播看本质梯度流经的范围决定了成本要理解两者为什么性能和成本都不同得从反向传播的路径上看。特征提取时因为主干参数requires_gradFalse并且输入也不需要梯度PyTorch 的自动微分机制在主干部分根本不会记录中间激活值前向算完就释放。这意味着显存占用里最大的一块激活值几乎不占只有新加的头部会保存中间结果。部分微调则不同只要某一层参数需要梯度它前面的所有计算图节点都得保留直到反向传播走完。哪怕你只解冻最后一个 block前面所有层的激活仍然要存着因为梯度要一路传回去链式求导。这是很多人没意识到的点解冻最后一层省下的显存远比你想象的少省下的是计算量而不是显存。我做过一个粗略的对比以常用的 50 层残差网络为例输入 224×224全量微调在单卡上大概能开 batch size 64 左右就顶到显存上限换成只训练分类头同样显存能开到 256 甚至更高。但如果你只是解冻最后一个 stagebatch size 大概只能提到 80 到 96提升有限。这个实测结果和很多人的直觉相反他们以为“我只解冻一点点显存应该接近特征提取”实际不是。所以如果你的瓶颈是显存特征提取才是真正的解法如果你只是想省训练时间部分微调的加速效果也就那样。另一个从反向传播角度看的重要差异是梯度尺度。新加的分类头是随机初始化的训练初期梯度很大如果主干也一起放开这些大梯度会顺着链式法则一路污染主干权重把预训练学到的表示冲垮。这就是为什么微调必须用很小的学习率或者先冻结主干训几轮头再解冻业内管这个叫预热。特征提取天然没有这个问题因为主干压根不接收梯度。1.3 为什么会有这两种做法迁移学习的谱系位置把视野拉远一点看特征提取和部分微调其实是“迁移学习”这条谱系上的两个点而不是两个孤立的技术。谱系的一端是完全冻结、只训头另一端是全量微调中间还有逐层解冻、分层学习率、加轻量适配层等一串变体。选择哪个点本质上是在回答三个问题新数据和预训练数据的分布差多少、新数据有多少、你能接受多大的训练成本。分布差得远、数据又少就往冻结那头靠因为放开训练容易过拟合分布差得远、数据挺多反而应该多解冻因为旧的高层语义确实不管用了分布接近、数据少冻结就够分布接近、数据多全量微调最省心。这个谱系思维很关键因为它让你不必在“提特征”和“部分微调”之间做二选一而是可以设计一条渐进路线先用特征提取跑一个基线看看模型到底能到什么水平如果欠拟合明显再逐步解冻。这个流程我在实际项目里用了很多次比一上来就调微调超参要高效得多因为基线跑得快你能在半天内摸清数据的难度上限。2. 冻结参数到底改变了什么原理与几个隐藏坑2.1 requires_grad 与优化器参数组一个必须理清的关系很多人以为设了requires_gradFalse就等于“这层不参与训练”这个说法大方向没错但细节上有几层含义需要分清。第一层是计算图层面参数不需要梯度前向过程中不会为该参数的操作保存反向所需的中间量所以显存和计算都省。第二层是优化器层面如果这个参数被传进了优化器即使梯度是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), lr1e-3, weight_decay1e-4)如果想验证冻结是否生效可以在第一个 epoch 结束后打印一下冻结层权重的范数和初始值对比差异应该是零。这个小检查我在调试时经常做比看 loss 曲线更直接。还有一种情况是加载预训练权重之后requires_grad被重置了比如某些封装库在load_state_dict之后会重建模块这时候要重新设置一遍。2.2 train 与 evalBN 统计量和 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_blockingTrue) out backbone(images) feats.append(out.cpu()) labels.append(targets) return torch.cat(feats), torch.cat(labels) # 主干只保留到池化层去掉原始分类头 backbone torchvision.models.resnet50(weightsDEFAULT) 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(weightsDEFAULT) 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_decay1e-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 污染训练时冻结归一化层为评估模式特征提取探针准确率很低预处理与预训练预期不匹配对齐均值方差、通道数、分辨率显存占用远超预期主干激活被记录检查是否遗漏无梯度上下文同一配置两次结果差异大种子未固定或冻结顺序不一致统一入口冻结并固定全部随机源微调收敛极慢头部初始化尺度过小或学习率过低检查头部初始化头部学习率单独放大解冻多层后性能反而下降解冻范围超出数据支撑能力回退到只解冻最后一层这张表我贴在项目笔记首页新同事上手时照着查能省掉大量重复的排查时间。表格里的每一条都是从实际报错和曲线异常里总结出来的不是理论推演。最后分享一点个人体会。这几年做下来我发现选型的难点不在技术本身而在于你对自己数据的判断有多准。同样是小样本任务有的数据虽然少但标注质量高、类间差异明显特征提取就能到九成有的数据量不小但标签噪声大、类间混淆严重微调再怎么调也就七成出头。所以在动手调参之前花半天时间看看错样本、算算类间距离、跑一次线性探针比后面几天的调参都值。我现在的习惯是任何新任务先花两小时做这三件事基本就能判断该走哪条路剩下的时间都花在数据清洗和验证集构建上。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表