
做 NLP 任务这几年我越来越发现一个现象很多人一上来就把整个 BERT 全部参数丢进优化器里跑哪怕只是做一个几千条数据的情感分类。模型是能跑但训练时间、显存占用、过拟合风险每一项都在悄悄膨胀。而另一拨人则走了相反的极端把主干模型冻得死死的只训练一个线性分类头结果在专业领域任务上效果又上不去。BERT 微调里的冻结参数和全量微调之争其实不是简单的谁好谁坏而是取决于你的任务形态、数据规模、显存预算和迭代节奏。这篇内容我不打算谈太悬的理论而是基于一个真实跑过的中文文本分类模拟项目把两种方案的原理拆开再把从数据处理到训练评估的完整流程走一遍关键参数、收敛速度、最终指标都摆出来。适合同样在做 BERT 微调、但不清楚该冻结还是该全量更新的朋友。1. 冻结参数与全量微调改动的是模型里的哪些部分1.1 先搞清楚 BERT 的参数量是怎么组成的要理解两种微调方式的差别首先得知道 BERT 的参数都在哪。以常用的中文 BERT base 为例整体大概 1.1 亿个参数核心结构是 12 层 Transformer encoder每一层又包含多头自注意力子层和前馈网络子层。这些层在预训练阶段学到的是泛化的语言知识语法结构、上下文语义、词的搭配习惯这些知识对绝大多数下游任务都有迁移价值。在做下游任务时我们通常会在 BERT 末尾接一个任务头。以分类任务为例这个任务头就是一个全连接层输入是 BERT 的 768 维句向量输出是类别数。这个任务头的参数量很小比如二分类场景下大概只有 1.5k 个参数跟 1.1 亿相比几乎可以忽略不计。但参数少和重要是两回事。微调的本质就是调整预训练模型的参数让语言知识更贴近你的下游任务。问题在于是让全部 1.1 亿参数都参与调整还是只让任务头去适应、主干参数保持不动这正是冻结参数和全量微调的岔路口。1.2 冻结参数的核心操作逻辑冻结的做法在代码层面非常直接把主干网络所有参数的 requires_grad 设为 False让反向传播阶段梯度不回传到这些参数上优化器只更新任务头。我用 Hugging Face 的 transformers 库做这个操作代码大概是这样的from transformers import BertForSequenceClassification model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels2 ) for name, param in model.named_parameters(): if classifier not in name: param.requires_grad False trainable sum(p.numel() for p in model.parameters() if p.requires_grad) total sum(p.numel() for p in model.parameters()) print(ftrainable params: {trainable} / {total})我建议你务必跑一下这个打印语句。我见过不止一次有人以为自己冻结了主干实际上漏掉了某些层导致显存照爆、时间照花效果还没有明显提升。对比之后就会发现冻结方案里真正参与训练的参数量只有几千个训练速度自然快得多内存占用也肉眼可见地降了下来。从原理上说冻结参数之所以在小数据场景下好用是因为它等价于一种极强的正则化。主干参数不在你的小数据集上做大幅更新就避免了模型去记住训练集里的噪声。任务头参数少拟合能力有限反而让模型在验证集上表现得稳定。1.3 全量微调的机制与代价全量微调就没有任何保留所有参数都参与梯度更新。优化器覆盖全部参数学习率设置得很低通常取 2e-5 到 5e-5 之间的值。原因在于 BERT 预训练得到的语义空间已经很好如果学习率过高一步更新就可能把原来的语言知识冲得面目全非训练集上表现很好验证集上直接崩掉。全量微调的代价是明显的。第一是显存反向传播需要保存各个层的中间激活值用于梯度计算在相同 batch size 和序列长度下显存占用通常是冻结方案的两到三倍。第二是时间因为梯度要流经全部网络层每个 step 的计算量大很多。第三是数据需求参数越多模型容量越大在样本不足的情况下越容易过拟合。我习惯打一个比方冻结参数就像给一台调校好的车换一个更合适的轮胎车本身的底盘、发动机、变速箱都不动全量微调则是把整车拆散重新调校理论上限更高但风险也更大尤其是你手上只有一小段测试赛道的时候。2. 任务场景判断数据量、任务类型、算力都影响方案选择2.1 小数据与大数据的分水岭在哪里我自己做过一组模拟实验用 2000 条训练样本的中文情感分类数据集做对比。冻结方案差不多在第 3 个 epoch 就稳定下来测试集准确率在 87.3% 左右全量微调在前期表现略差但从第 4 个 epoch 开始反超最终到 88.2%。在 2000 条这个量级上全量微调确实略胜一筹但领先幅度不足一个百分点考虑到训练时间和显存开销这个优势显得不那么值。当我把数据量加到 2 万条之后差距就拉开了。全量微调最终冲到了 93.4%冻结方案则停留在 89.2% 附近。原因也好理解冻结方案所有可训练参数就那么几千个容量上限摆在那里数据再多模型也没有足够多的自由度去进一步拟合任务规律。所以我心里的分水岭大致是这样的低于 3000 到 5000 条样本冻结方案往往是性价比最高的选择超过 2 万条样本全量微调的优势就非常明显了。两者之间属于模糊地带需要用验证集指标来决策而不是拍脑袋。2.2 通用任务和专业任务的差别除了数据量还要看任务本身和预训练知识的契合度。通用领域的句子分类、情感分析、句子相似度这些任务依赖的语言能力跟 BERT 预训练阶段学到的内容高度重合。这时候冻结主干、只训练任务头没有什么问题因为分类头要做的只是在已有的语义空间里找到一条合适的分界线。但换成专业领域任务情况就变了。比如生物医学领域的命名实体识别、法律文书的条款分类、工业日志的故障判断这类任务里有大量专有名词和特殊表达方式语言模式和通用预训练语料差别很大。如果冻结主干相当于让分类头在一个不懂行话的语义空间里强行做决策效果自然受限。我自己的经验是专业领域任务哪怕数据只有几千条也至少要让 BERT 的高层参与更新必要时整个模型全部解冻。2.3 硬件条件和时间预算同样关键实际操作中很多人忽略了一个现实因素训练环境到底允不允许你做全量微调。我在一张普通入门显卡上做过测试序列长度 128、batch size 16 的情况下冻结方案显存占用大约 3GB 到 4GB全量微调直接逼近 7GB。如果你的显卡显存只有 6GB全量微调可能连 batch size 开到 8 都很勉强。时间上的差距更直观。同一个数据集冻结方案训练一个 epoch 大约 40 秒到 1 分钟全量微调则要 2.5 到 3 分钟。如果你的任务需要频繁调参验证比如跑多组学习率对比实验冻结方案能让你在同样的时间里多做几轮实验这在开发效率上的优势是实打实的。2.4 我的方案选择速查表为了方便参考我把决策逻辑整理成了一个表格判断维度偏向冻结参数偏向全量微调训练数据量少于 5000 条超过 2 万条任务类型通用领域任务专业领域任务显存资源6GB 以下12GB 以上调参节奏需要快速验证追求最终指标过拟合风险已经出现明显过拟合稳定且可控这个表格不绝对但可以帮你快速锁定方向。我在实际项目中通常会用这个表做初步决策再跑一个短实验来验证。3. 一次完整的微调实践环境、代码、训练对比3.1 实验环境与数据准备这次实验我用的是 Python 3.8、PyTorch 2.0、transformers 4.26单卡训练。数据集来自一个模拟的中文情感分类项目包含 2000 条训练样本、500 条验证样本和 500 条测试样本类别为二分类。数据格式就是普通的文本加标签量级不大但足以展示两种方案的差异。数据处理主要靠 transformers 自带的 tokenizer。中文 BERT 使用字级别的词表不需要做分词直接按字切分即可。我固定 max_length 为 128超过的截断不足的补 padding。这里有一个小建议如果你的数据长度差异很大可以考虑动态 padding 来提速但如果只是为了做对比实验固定长度更省心两种 padding 方式对最终指标几乎没有影响。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) def encode_batch(examples): return tokenizer( examples[text], truncationTrue, paddingmax_length, max_length128 )3.2 冻结方案的训练配置与代码冻结方案的完整训练循环并不复杂。加载模型后把主干冻结然后把过滤后的参数传给优化器。我在这里强烈建议用 filter 过滤后再传给优化器虽然从原理上讲requires_gradFalse 的参数即使传给优化器梯度为 0 也不会更新但过滤之后可以让优化器的参数状态更干净也方便后续打印调试。学习率设置上冻结方案的任务头是随机初始化的需要一个相对较大的学习率才能快速收敛。我的经验值在 2e-4 到 5e-4 之间比全量微调的 3e-5 高一个数量级。from transformers import BertForSequenceClassification, AdamW model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels2 ) for name, param in model.named_parameters(): if classifier not in name: param.requires_grad False optimizer AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr2e-4 )训练循环就按标准的 PyTorch 流程写。每个 epoch 结束之后在验证集上计算准确率记录到日志里。我额外推荐保存最优验证准确率对应的模型权重而不是最后一个 epoch 的权重因为全量微调在后期很容易出现过拟合最后一个 epoch 往往不是最好的状态。3.3 全量微调的配置差异全量微调部分的代码跟冻结版本很接近最大的区别就是不去修改任何参数的 requires_grad 属性。但学习率的设置必须换一档我通常用 3e-5 起步配合线性调度和 warmup 使用。from transformers import BertForSequenceClassification, AdamW, get_linear_schedule_with_warmup model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels2 ) optimizer AdamW(model.parameters(), lr3e-5) total_steps len(train_dataloader) * epochs warmup_steps int(total_steps * 0.1) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps )这里解释一下为什么全量微调需要 warmup。BERT 的预训练参数已经处于一个比较稳定的语义空间训练一开始就用较大学习率容易让模型参数偏离太多。warmup 阶段让学习率从一个很小的值逐渐上升到设定值可以缓解初始阶段的破坏性更新。实际测试中我把学习率调到 1e-4 试过一次训练集损失降得很低验证集指标却明显恶化这就是典型的过度更新破坏了语言表示。3.4 训练过程记录与结论分析我把两种方案各跑 5 个 epoch验证集准确率的走势如下epoch冻结参数验证准确率全量微调验证准确率183.2%81.6%285.8%84.9%387.4%87.0%487.5%87.8%587.3%88.2%冻结方案在初期优势明显前两个 epoch 的准确率一路攀升。全量微调在前期进展较慢因为整个模型都在做幅度有限的调整直到第 3 个 epoch 之后才逐渐追上来。最终在测试集上全量微调领先约 0.8 个百分点但训练时间是冻结方案的 2.5 倍左右显存占用也高出一截。对于 2000 条小数据这个场景我的判断是冻结方案完全可以作为首选差距微乎其微但换来的是更快的迭代速度和更低的资源需求。如果你正处于调参阶段先用冻结方案把数据流程、评估逻辑全部跑通再切换到全量微调去刷最终的指标这是很高效的推进方式。4. 实战中踩过的坑不收敛、过拟合、显存不足的排查记录4.1 冻结方案训练不收敛怎么排查我踩过的第一个坑是冻结方案在训练时准确率始终在 50% 附近徘徊完全没有收敛的迹象。排查了一圈问题出在学习率上。刚开始我沿用全量微调的 3e-5 学习率这个数值对随机初始化的分类头来说太低了几个 epoch 下来参数几乎没怎么动。换成 2e-4 之后分类头的收敛速度立刻就正常了。这里的关键在于不同参数的初始化状态对学习率的要求是不同的。预训练好的 BERT 主干需要小学习率防止破坏而随机初始化的任务头需要大学习率加速收敛。如果两者共用同一个学习率照顾了任务头就伤害了主干照顾了主干就拖垮了任务头。另一个容易忽略的点是某些库的模型结构中pooler 层默认也会参与前向计算虽然它的梯度在冻结状态下不会更新但它的前向输出影响 classifier 的输入这一点在冻结方案中并不影响训练只是在检查网络结构时容易让人困惑。打印模型内部的层名称和 requires_grad 状态是定位这类问题最直接的手段。4.2 全量微调过拟合的几个信号全量微调在小数据上过拟合几乎是必然事件只是程度不同。我比较明显的信号是训练损失还在下降但验证损失开始上升验证准确率停滞甚至回落。这种情况一般出现在第 3 个 epoch 之后所以我通常会把训练轮数控制在 3 到 5 个 epoch 之间并配合早停策略。缓解过拟合的手段除了早停我常用的是做 weight decay。在 AdamW 中设置 weight_decay0.01可以为主干参数提供轻微的正则化效果。数据量特别少的时候打乱数据顺序、增加轻微噪声等方式也能起一点作用但我个人的经验是如果过拟合已经很明显最有效的方案还是干脆切到冻结方案而不是在 500 条数据上硬刚全量微调。4.3 显存不足的处理思路全量微调时显存告急应该先看 batch size。将 batch size 从 16 降到 8显存占用通常能减半。但要注意减小 batch size 会影响梯度估计的稳定性。正确的做法是把 batch size 降下来同时用梯度累积来维持等效 batch size。accumulation_steps 2 # 等效 batch size 8 * 2 16 optimizer.zero_grad() for step, batch in enumerate(train_dataloader): outputs model(**batch) loss outputs.loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() scheduler.step() optimizer.zero_grad()还有一个很实用的思路是使用混合精度训练。PyTorch 自带的 torch.cuda.amp 可以把大部分计算转换到 FP16显存占用能减少不少而且在这类微调任务上几乎不影响最终效果。不过要留意某些老版本代码在 FP16 下会出现 loss 变成 NaN 的问题遇到这种情况优先检查学习率是否过大。4.4 冻结不彻底导致的隐性误差我见过一个同行在项目里以为自己使用的是冻结方案跑出来的时间和显存却跟全量微调差不多。检查代码之后发现他用了 model.bert.encoder.layer[:11] 这种切片方式去冻结前 11 层但最后一层、pooler 层、以及嵌入层的参数都没有被冻结。这其实也属于一种合理的部分微调策略但如果目标是冻结主干那这种方案就达不到预期效果。稳妥的做法是统一以参数名中的关键字为准。classifier 之外的所有参数全部冻结再用前面的打印代码确认可训练参数数量。只有当你看到的可训练参数量接近几千这个量级才算真正做到了冻结。4.5 部分解冻的混合微调策略在两类方案之间还有一个我经常用的中间态部分解冻。具体做法是冻结 BERT 的低层让顶层若干层和分类头一起参与训练。这种策略的前景在于BERT 低层学到的是更基础的语言特征通用性强高层则更接近具体任务语义需要针对下游任务做调整。我在一个专业领域的小数据任务上试过这种混合做法冻结前 10 层解冻最后两层和分类头训练 4 个 epoch。最终效果比完全冻结高约 2 到 3 个百分点比全量微调只低 0.5 个百分点左右但训练用时和显存消耗介于两者之间。如果你面对的是数据量不大又不小的尴尬区间这种折中方案值得尝试。5. 我的选择思路先用冻结方案跑通流程再决定要不要解冻要让我给出一个通用答案我会说冻结参数适合作为项目初期的基线方案全量微调适合在拥有足够数据和算力时追求上限。但我更想分享的是自己这些年养成的工作习惯它比单纯二选一有用得多。无论面对什么任务我都会先按冻结方案把流程完整跑一遍。这个阶段的目的不是拿到最优指标而是确认数据加载、输入格式、标签映射、损失函数、评估逻辑都没有问题。冻结方案跑得快、显存低出了问题也好排查。当这个基线稳定之后我再去解冻部分层做增量训练一步步逼近全量微调的效果。有一次我因为赶进度直接从全量微调开始跑结果跑到第 3 个 epoch 才发现验证代码里标签顺序搞反了几小时的训练全部作废。从那之后我再也没有跳过先冻结、后解冻的流程。如果你正在为 NLP 任务选方案不妨也试试这个节奏它能让你的调参过程顺畅很多。实践出真知同样的模型在不同任务上表现差异可能很大。我的建议是不要盲目追随某篇文章的结论而是花半天时间用自己的数据把两种方案各跑一遍看看差距到底有多大。绝大多数情况下你得到的实验结果会比任何经验之谈都更有说服力。