三、BERT训练与优化:训练数据准备、学习率策略、梯度累积

搞BERT微调的时候,最容易出问题的其实不是模型本身。我见过不少同学,把预训练权重一加载,随便写个DataLoader就开始训。结果要么loss不降,要么训到一半直接炸掉。说白了,数据准备、学习率、梯度累积这三个东西,才是决定成败的隐形战场。

3.1 训练数据准备——你喂的东西决定了一切

我习惯先把原始文本洗干净。很多公开数据集里混着HTML标签、乱码、多余空格。你想想看,BERT词表里根本没有 这种东西,它只会把标签当成一堆没意义的字符炸开。

我曾经踩过的坑:有一回我直接拿未清洗的Reddit评论做细粒度情感分类。训了两个epoch,loss死活下不了0.8。后来发现一堆“>>”“<br>”之类的符号占了一半序列长度,模型学了半天“>”和“<”的区别……浪费时间。

数据清洗之后,第二步就是分词和截断。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)。我记得有一次做长文本分类任务,用余弦衰减的效果比线性好了一点点,因为最后阶段学习率衰减得更慢,能让模型在最优解附近多震荡几下。不过大部分场景线性就够了。

小经验:Warmup比率建议在5%~20%之间。小批量(8~16)时用10%左右,大批量(64+)时可以提到15%~20%。我一般先试10%,跑几个step看loss曲线,如果前100步loss冲击很大,就加大warmup。

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倍,梯度会爆炸。

曾经出过的事:有一年做机器阅读理解,我忘了除accumulation_steps。结果跑了两个epoch模型就NaN了。排查了一下午才发现是梯度累积把loss放大了,优化器直接冲到了悬崖边上。嗯,从那以后我每次写梯度累积都会再检查一遍这句。

3.4 三者串联——一张图看清全流程

把数据准备、学习率调度和梯度累积串在一起,大概就是下面这张流程。我画的时候尽量把关键决策点标出来,你在实际调参时可以按这个路径排查。

BERT微调训练优化流程 原始数据 (文本/标签) 数据清洗 → 分词 → 动态Mask BERT前向 → 计算loss loss.backward() → 梯度累积 累积步数未到?继续下一batch optimizer.step() + scheduler.step() Warmup → 线性/余弦衰减 累积步数未到, 继续下一个batch 💡 动态Mask:每个epoch 重新随机选15%位置Mask 💡 Warmup推荐比率: 小batch 5-10% / 大batch 15-20%

3.5 避坑指南——我当年吃过的亏

  1. Warmup步数别设错。 我同事曾把num_warmup_steps设置为总的step数,结果整个训练过程都在warmup,学习率从没到达目标值。跑了10个epoch,效果跟随机瞎猜一样。
  2. 梯度累积一定要同步BatchNorm。 如果你用含BatchNorm的模型(比如BERT本身没有,但下游分类头可能有),累积时BatchNorm的统计量会因为多个小batch叠加而失真。建议把模型换成LayerNorm,或者累积时不要更新BatchNorm。
  3. 内存占用会略升高。 梯度累积需要保留每一步的计算图直到累积完成。如果你的显存已经接近极限,累积步数太大可能炸显存。我一般控制在2~8步。
  4. 学习率衰减必须和优化器配合。 有些框架会在scheduler.step()后自动更新学习率,有些需要手动。我习惯在optimizer.step()之后立即执行scheduler.step(),确保每个有效更新都对应一步衰减。

一句话总结:数据决定上限,学习率控制走向,梯度累积突破显存瓶颈。这三者看似独立,实际一环扣一环。先把数据洗好,再配一个带warmup的线性衰减学习率,最后根据显存大小设定梯度累积步数。这套组合拳我用了三年,基本没出过大问题。