三、BERT训练与优化:训练数据准备、学习率策略、梯度累积
搞BERT微调的时候,最容易出问题的其实不是模型本身。我见过不少同学,把预训练权重一加载,随便写个DataLoader就开始训。结果要么loss不降,要么训到一半直接炸掉。说白了,数据准备、学习率、梯度累积这三个东西,才是决定成败的隐形战场。
3.1 训练数据准备——你喂的东西决定了一切
我习惯先把原始文本洗干净。很多公开数据集里混着HTML标签、乱码、多余空格。你想想看,BERT词表里根本没有 这种东西,它只会把标签当成一堆没意义的字符炸开。
数据清洗之后,第二步就是分词和截断。BERT最大输入长度是512个。对于超过512的文本,一般用截断策略:取头128 + 尾382(保留两边语义)。我个人习惯这样写:
def truncate_pair(text_a, text_b, max_len=512):
tokens_a = tokenizer.tokenize(text_a)
tokens_b = tokenizer.tokenize(text_b)
# 保留 [CLS] + tokens_a + [SEP] + tokens_b + [SEP]
total = len(tokens_a) + len(tokens_b) + 3
if total <= max_len:
return tokens_a, tokens_b
# 需要截断
# 头尾各留一些,中间全砍
max_a = int((max_len - 3) * 0.6)
max_b = max_len - 3 - max_a
return tokens_a[:max_a], tokens_b[:max_b]
然后是动态Mask。预训练时用的是静态Mask(每个样本固定一次Mask位置),但微调阶段最好自己做动态Mask——每个epoch重新随机Mask一次。这样等于让模型看到更丰富的上下文变体。
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 静态Mask | 预处理时一次性Mask,后续epoch不变 | 资源受限,追求稳定复现 |
| 动态Mask | 每个epoch重新随机选择15%位置Mask | 通用微调(我推荐) |
| 全词Mask (WWM) | 将同一个word被分词后的子词全部Mask | 中文/日语等非空格语言 |
3.2 学习率策略——别让训练一开始就崩
BERT微调的学习率通常很小,1e-5到5e-5之间。但直接上来就用这个学习率,你想想看,模型参数是从预训练的大范围突然跳到新的任务空间,步子太大容易扯着蛋。所以Warmup几乎是标配。
Warmup的思路很简单:前几步让学习率从0缓慢升到目标值。我习惯用线性Warmup + 线性衰减的组合,代码长这样:
from transformers import get_linear_schedule_with_warmup
num_training_steps = len(train_dataloader) * num_epochs
num_warmup_steps = int(0.1 * num_training_steps) # 10%步数用于warmup
optimizer = AdamW(model.parameters(), lr=2e-5)
scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=num_warmup_steps,
num_training_steps=num_training_steps
)
除了线性衰减,还有余弦退火(Cosine Annealing)。我记得有一次做长文本分类任务,用余弦衰减的效果比线性好了一点点,因为最后阶段学习率衰减得更慢,能让模型在最优解附近多震荡几下。不过大部分场景线性就够了。
3.3 梯度累积——显存不够?那就用时间换空间
BERT Base 12层,单张16GB的显卡,batch size通常只能调到16左右。但有时候你需要更大的batch才能稳定(比如NLP竞赛里常见batch=64或128)。这时候梯度累积就派上用场了。
原理很简单:多次前向传播累积梯度,再统一反向传播并更新参数。假设你想达到batch_size=64,但显卡只能塞下16,那就累积4步:
accumulation_steps = 4
model.train()
optimizer.zero_grad()
for step, batch in enumerate(train_dataloader):
outputs = model(**batch)
loss = outputs.loss
loss = loss / accumulation_steps # 分摊
loss.backward()
if (step + 1) % accumulation_steps == 0:
optimizer.step()
scheduler.step()
optimizer.zero_grad()
注意要把loss除以累积步数。不然累积4步的loss就是原来的4倍,梯度会爆炸。
3.4 三者串联——一张图看清全流程
把数据准备、学习率调度和梯度累积串在一起,大概就是下面这张流程。我画的时候尽量把关键决策点标出来,你在实际调参时可以按这个路径排查。
3.5 避坑指南——我当年吃过的亏
- Warmup步数别设错。 我同事曾把
num_warmup_steps设置为总的step数,结果整个训练过程都在warmup,学习率从没到达目标值。跑了10个epoch,效果跟随机瞎猜一样。 - 梯度累积一定要同步BatchNorm。 如果你用含BatchNorm的模型(比如BERT本身没有,但下游分类头可能有),累积时BatchNorm的统计量会因为多个小batch叠加而失真。建议把模型换成LayerNorm,或者累积时不要更新BatchNorm。
- 内存占用会略升高。 梯度累积需要保留每一步的计算图直到累积完成。如果你的显存已经接近极限,累积步数太大可能炸显存。我一般控制在2~8步。
- 学习率衰减必须和优化器配合。 有些框架会在
scheduler.step()后自动更新学习率,有些需要手动。我习惯在optimizer.step()之后立即执行scheduler.step(),确保每个有效更新都对应一步衰减。
一句话总结:数据决定上限,学习率控制走向,梯度累积突破显存瓶颈。这三者看似独立,实际一环扣一环。先把数据洗好,再配一个带warmup的线性衰减学习率,最后根据显存大小设定梯度累积步数。这套组合拳我用了三年,基本没出过大问题。