ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

动手学BERT:从预训练到微调,避开调参陷阱

动手学BERT:从预训练到微调,避开调参陷阱

别急着调参,先把这三件事想明白

如果用一个词概括自然语言处理这几年的底色,我觉得就是"预训练"。而BERT,是所有预训练模型里最值得亲手摸一遍的那个。它不仅是学术上的分水岭,更是无数工业级产品的发动机。很多朋友看到"动手学深度学习——BERT"这个标题,第一反应是去搜代码、跑demo,但我建议先停下来,把三件事想清楚:你要解决什么问题、你要学到什么层面、你手头的算力能支撑到什么程度。因为BERT的学习路径可以分为"会用"和"会做"两个阶段,二者对应的代码量和知识密度截然不同。

这篇文章我打算按照我真正实操时的顺序来写:先讲清楚BERT的核心逻辑,再聊环境和数据准备,然后分别给出"从零预训练"和"微调下游任务"两条实操路径,最后把踩过的坑集中列一遍。写出来的东西不追求一次性塞满所有细节,但保证每一步都是我自己试过、能跑的方案。无论你是刚接触深度学习的学生,还是项目里需要落地文本模型的工程师,这篇文章都能帮你少走弯路。

1. 动手学BERT的整体设计思路

1.1 先给BERT画个像:它到底做了什么

BERT的全称是Bidirectional Encoder Representations from Transformers,中文常译作"基于Transformer的双向编码器表示"。名字有点拗口,但拆开来看就清楚了。

Transformer是2017年提出的基于自注意力机制的序列建模架构。和传统的循环神经网络(RNN)不同,Transformer不按时间步逐个处理词,而是一次性把整个句子铺开,通过注意力矩阵直接算出每个词和其他词的相关性。这样一来,长距离依赖的问题天然被解决,而且可以高度并行。

BERT取的就是Transformer的Encoder部分。它最核心的改动有两个:第一,采用双向上下文,这意味着模型在编码"银行"这个词的时候,既能看到左边的"工商",也能看到右边的"贷款",两个方向的信息同时参与计算;第二,引入两个预训练任务——掩码语言建模(Masked Language Model, MLM)和下一句预测(Next Sentence Prediction, NSP)。MLM的做法是随机遮住15%的词,让模型猜测被遮住的词是什么,这迫使模型学会利用上下文推理语义;NSP则让模型判断两个句子是否在原文中连续出现,帮助模型理解句子间的关系。

举个例子,处理"小明喜欢[ MASK ],尤其是草莓味"这句话,模型看到周围的"喜欢""草莓味",就能推断出被遮住的词大概率是"蛋糕"或"冰淇淋"。这个过程看起来简单,但要让模型在千万级语料上反复做这种猜测,它学到的就不是单个句子里的搭配,而是整个语言的分布规律。这就是预训练的妙处:之后拿来做下游任务时,模型已经"理解"语言,我们只需要在它上面加个轻量分类器,微调即可。

1.2 为什么建议"两头都动手":预训练 + 微调

很多教程只教你微调,即用现成的bert-base-uncased权重训练一个文本分类器。这种做法当然实用,但它有一个致命的问题:你始终在使用别人训练好的"大脑",一旦需要修改词表、处理特殊领域文本、或者想做一个轻量级模型,就会手足无措。

所以我建议的路径是先跑一个简化的预训练过程,再跑微调。不需要复刻完整的BERT-large,甚至连base都不必,完全可以用一个小的config(比如12层、384维度、2个注意力头)在几百万字的语料上做MLM训练。这样你有三个收获:第一,亲眼看到"掩码预测"是怎么构造数据、怎么参与训练的;第二,理解padding、attention_mask、token_type_ids这些张量的实际作用;第三,后面微调时,你对加载进来的权重有实感,不会把模型当黑盒。

1.3 工具选型:PyTorch + Hugging Face

工具方面我推荐PyTorch,搭配Hugging Face的Transformers库。原因很简单:生态最完整、教程最多、踩坑资料好找。TensorFlow也能做,但涉及动态图和调试体验时,PyTorch对新手更友好。Transformers库把BERT的模型定义、tokenizer、训练管线都封装好了,但封装不意味着黑盒,你在用BertForMaskedLM和BertForSequenceClassification的时候,一定要点进源码看几行,搞清楚forward函数里每个参数是怎么用的。

2. 环境准备与工具链踩坑

2.1 硬件和训练时间怎么规划

BERT哪怕是小型模型,也比普通的CNN文本分类器重得多。我自己在GTX 1660 Super 6GB显卡上跑过一个小型BERT预训练:显存刚好够用,但batch size只能开到8,训练一天能跑大约5万步。如果你只有CPU,建议直接用蒸馏好的小型模型,或者买云GPU按小时租用。

不同任务的硬件参考如下表:

任务模型规模建议显存训练时间参考
微调BERT-base分类110M参数8GB~16GB1~2小时(单卡)
微调BERT-small14M参数4GB20~30分钟
从零预训练小型BERT30M参数8GB数小时到一天
从零预训练BERT-base110M参数24GB以上数周(多卡)

这里我要多说一句:如果你只是学习原理,完全不用上base。一个hidden_size=256、num_hidden_layers=6、num_attention_heads=8的模型已经足以说明所有机制,训练一轮普通分类语料也就一小时级别。学习阶段追求的是"跑通+看懂",不是"刷榜"。

2.2 一套可直接复制的环境配置

我用的是Python 3.9 + CUDA 11.8 + PyTorch 2.0。安装命令如下,供参考:

conda create -n bert_learning python=3.9 conda activate bert_learning conda install cudatoolkit=11.8 pip install torch==2.0.0 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.30.2 pip install datasets==2.13.1 pip install tokenizers==0.13.3 pip install accelerate==0.20.3 pip install scikit-learn matplotlib tensorboard

注意transformers和tokenizers的版本不要随意配。早期我试过transformers 4.30和tokenizers 0.15的组合,出现过一个奇怪的报错:tokenizer加载速度极慢,每次初始化都要等待十几秒。后来发现是tokenizers版本不兼容导致的FastTokenizer回退过程。锁版本是一个很实用但容易被忽略的习惯。

导入测试:

from transformers import BertForMaskedLM, BertTokenizer model = BertForMaskedLM.from_pretrained("bert-base-uncased") tokenizer = BertTokenizer.from_pretrained("bert-base-uncased") print(model.config)

如果这两行能顺利跑通,环境就算没问题了。另外建议安装jupyter lab或者使用VS Code的交互式窗口,因为逐步观察张量维度时,交互式环境比脚本方便得多。

3. 数据准备:预处理比训练更考验耐心

3.1 用Hugging Face datasets库加载语料

很多人刚接触BERT时,以为数据就是一个CSV文件配上标签,直接读进来就行。但实际训练中,数据管线至少包含三个步骤:加载原始数据、切分成合理的块、转换成模型输入格式。我在实际项目中习惯的做法是先把语料整理成一行一条的文本文件,然后用datasets库载入。

如果你需要中文语料,可以找一个开源的维基百科中文dump,或者直接用一些开放的中文新闻语料。这里为了演示方便,我用的是Hugging Face上公开的英文数据集"wikitext-2",因为它体积小、结构干净,适合用来跑通预训练流程。

from datasets import load_dataset dataset = load_dataset("wikitext", "wikitext-2-raw-v1", split="train") print(dataset[0])

运行后会得到一个字典,里面是对应的"text"字段。这里有两个容易踩的坑:

  • 数据里有很多空行和标题符号,直接丢进tokenizer会产生大量无效的padding,浪费显存。
  • 数据没有分句,而MLM需要基于句子级别的token序列来构造,不能简单粗暴地截断。

因此我的做法是先过滤掉长度小于50字符的行,然后按段落切分,再拼接成固定长度的token块。

3.2 tokenizer使用的三个关键参数

BERT的tokenizer有三个核心参数,很多人用了很久也没彻底分清:

  • max_length:指定编码后token序列的最大长度。BERT-base的默认最大位置嵌入是512,所以一般设置成512以内。超过512会被截断。
  • padding:设为max_length时,会把短句子补到设定的长度;设为longest时,会把一个batch内的所有句子补齐到该batch最长句子的长度。
  • truncation:设为True时,超长文本会按max_length截断。

这三个参数听起来简单,但组合不当会造成非常隐蔽的错误。比如训练时用padding='max_length'而推断时用padding='longest',同一个样本喂进去得到的attention_mask长度不一致,如果在模型外又套了一层固定维度的Linear层,就很容易报错或计算错误。

构造MLM样本的代码如下:

from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-uncased") def tokenize_function(examples): result = tokenizer( examples["text"], max_length=128, truncation=True, padding="max_length", return_special_tokens_mask=True ) return result tokenized_dataset = dataset.map(tokenize_function, batched=True, remove_columns=["text"])

这里我特意加了return_special_tokens_mask=True,因为后续构造掩码标签时,需要在加特殊token时跳过[CLS]和[SEP],不能让模型去预测这两个特殊token。

3.3 MLM数据生成:15%掩码规则的具体实现

预训练时输入是一个token序列,标签则是"被掩码位置的原本token id"。但BERT的MLM不是简单地把15%的词替换成[MASK],它有一个精妙的细节:

  • 选中的token中,80%替换成[MASK]
  • 10%替换成随机的其他token
  • 10%保持不变,但依然需要在标签中填入原token id

为什么要这样设计?如果只使用[MASK]进行训练,模型会形成一种"见到MASK就知道要猜测词"的偏见,而在下游任务中不会出现[MASK],模型会无所适从。加入随机替换和保持不变两种扰动后,模型被迫真正理解每个位置的上下文含义,而不是依赖某个特定符号。

我用纯PyTorch实现这个逻辑,代码不过二十多行:

def mask_tokens(inputs, tokenizer, mask_prob=0.15): labels = inputs.clone() probability_matrix = torch.full(labels.shape, mask_prob) special_tokens_mask = [ tokenizer.get_special_tokens_mask(val, already_has_special_tokens=True) for val in inputs.tolist() ] probability_matrix.masked_fill_(torch.tensor(special_tokens_mask, dtype=torch.bool), 0.0) masked_indices = torch.bernoulli(probability_matrix).bool() # 有80%的概率替换为 [MASK] indices_replaced = torch.bernoulli(torch.full(labels.shape, 0.8)).bool() & masked_indices inputs[indices_replaced] = tokenizer.convert_tokens_to_ids(tokenizer.mask_token) # 有10%的概率替换为随机token indices_random = torch.bernoulli(torch.full(labels.shape, 0.5)).bool() & masked_indices & ~indices_replaced random_words = torch.randint(len(tokenizer), labels.shape, dtype=torch.long) inputs[indices_random] = random_words[indices_random] # 剩余10%保持不变 labels[~masked_indices] = -100 return inputs, labels

标签中把未掩码位置设为-100是为了配合PyTorch的交叉熵损失——计算损失时会自动忽略label为-100的位置,这是从代码层面接管逻辑的标准做法。

4. 从零训练一个小型BERT模型

4.1 定义模型的Config

很多人以为必须直接用BertForMaskedLM.from_pretrained("bert-base-uncased"),但实际上我们完全可以用BertConfig自定义一个小模型。我在学习阶段建议使用如下配置:

from transformers import BertConfig, BertForMaskedLM config = BertConfig( vocab_size=tokenizer.vocab_size, hidden_size=256, num_hidden_layers=6, num_attention_heads=8, intermediate_size=1024, max_position_embeddings=512, type_vocab_size=2, ) model = BertForMaskedLM(config) print(f"参数量: {model.num_parameters() / 1e6:.2f}M")

这样定义出来的模型参数量大约在30M左右,相比BERT-base的110M小了很多,但结构完整。hidden_size=256表示每个token对应的向量维度是256;num_hidden_layers=6表示叠加6层Transformer Encoder;intermediate_size=1024是前馈网络中间层的维度。配置的每一项都有实际影响,暂时不理解没关系,但要把它们记下来,等看完前向传播后再回来看,会有新的体会。

4.2 训练循环的完整流程

训练小型BERT的过程和普通模型略有不同。你需要准备一个DataLoader,每个batch返回一个字典,包含input_ids、attention_mask和labels。其中labels是上面mask_tokens函数生成的那个已经被替换了部分token的inputs的副本。

我用AdamW优化器,学习率采用带热身的线性调度器,振荡很稳定。代码如下:

from torch.utils.data import DataLoader from transformers import get_linear_schedule_with_warmup train_loader = DataLoader(tokenized_dataset, batch_size=16, shuffle=True) optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4) total_steps = len(train_loader) * 10 # 假设训练10个epoch scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps )

训练循环中,我习惯在每一个step里打印loss和当前学习率,方便发现lr是否震荡过大。同时每隔500步做一次验证,用验证集计算困惑度(Perplexity)。这里有一个值得注意的细节:验证时不需要mask,直接把原始token序列喂给模型,让模型对每个非特殊token计算交叉熵损失。看起来这跟MLM预训练不一致,但实际你是在验证语言模型的概率分布,是合理的。

4.3 训练之后能做什么

训练完这个小模型后,建议你至少做三件事来验证效果:

  • 用model.generate生成几个句子,看看能不能输出通顺的文本。小型BERT的生成能力很弱,但至少能输出一些高频搭配。
  • 可视化第一层和最后一层的注意力矩阵,看看attention是否具有句法结构。
  • 把词向量取出来,做一次PCA降维,看看"苹果"、"香蕉"这类语义相近的词是否落在相近的位置。

这些验证本身也是理解BERT的好方式。若模型完全没学到东西,大概率不是代码问题,而是数据量太少或学习率设置不对。不要把预期放得太高,小型模型的目的是理解机制,不是取得SOTA。

5. 微调BERT完成文本分类任务

5.1 加载预训练权重,更换头部

微调是实际项目里最常用到的技能。这里我以情感二分类任务为例,数据是某电商评论数据集,包含正向和负向两种标签。微调的第一步是把BERT的编码部分加载进来,然后在上面接一个分类头。

使用Transformers库时可以不用手动编写分类头,直接调用BertForSequenceClassification:

from transformers import BertForSequenceClassification, BertTokenizer model = BertForSequenceClassification.from_pretrained( "bert-base-uncased", num_labels=2 )

这个类内部会丢弃预训练模型的MLM头部,替换为一个包含dropout和线性层的分类头。需要注意,from_pretrained方法会加载BERT底层的全部权重,但分类头是随机初始化的。所以你第一次看到训练loss很高是正常的,那是新的分类头在从零学习,不代表前面的预训练权重失效。

5.2 训练配置的关键区别

微调和预训练的学习率设置是大不相同的。预训练阶段使用5e-4的学习率是因为要从头学习大量参数;微调阶段因为模型主体已经收敛得很好,学习率如果太高会把预训练的语义知识冲掉。我常用1e-5到3e-5之间。

另外,微调通常很吃batch size。大batch会让训练更稳定,但显存有限时建议使用梯度累积。

from transformers import AdamW optimizer = AdamW(model.parameters(), lr=2e-5) # 梯度累积 accumulation_steps = 4 for step, batch in enumerate(train_loader): outputs = model(**batch) loss = outputs.loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

这里解释一个容易迷惑的点:为什么loss要除以accumulation_steps再backward?因为PyTorch的梯度是累积的,每执行一次loss.backward(),梯度会加到之前的梯度上。如果我们想模拟一个大小为batch_size * 4的batch,就要连续做4次前向反向,每次loss除以4,保证累积的梯度均值为4个batch的平均梯度。

5.3 评估指标别只用准确率

分类任务里,准确率是最直观的指标,但是在类别不平衡的场景下容易骗人。比如数据里90%是负向评论,模型什么都不学,一直预测负向也能有90%准确率,看起来很好看,实际上一无是处。所以至少要加上F1分数和混淆矩阵。

评估代码:

from sklearn.metrics import accuracy_score, f1_score, confusion_matrix y_true = [] y_pred = [] model.eval() with torch.no_grad(): for batch in eval_loader: outputs = model(**batch) pred = outputs.logits.argmax(dim=-1) y_true.extend(batch["labels"].tolist()) y_pred.extend(pred.tolist()) print("准确率:", accuracy_score(y_true, y_pred)) print("F1:", f1_score(y_true, y_pred, average="weighted")) print("混淆矩阵:\n", confusion_matrix(y_true, y_pred))

配一个TensorBoard日志更是好习惯,我会记录每500步的loss、accuracy和F1,因为微调阶段往往跑十几个epoch后会出现"训练集指标继续涨但验证集指标下降"的过拟合信号,这时需要尽早停止。

6. 常见问题与排查技巧实录

6.1 显存不足怎么办

BERT模型的显存占用主要来自三部分:前向计算的中间激活、反向传播所需的梯度、优化器状态。如果你在微调BERT-base时OOM,优先尝试以下几种方法:

  • 调小batch size,比如从16降到8甚至4;
  • 打开gradient_checkpointing,用时间换显存;
  • 使用混合精度torch.cuda.amp.autocast;
  • 减少max_length,从512降到256或128。

其中混合精度的收益最直接,代码改动也最小:

from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for batch in train_loader: with autocast(): outputs = model(**batch) loss = outputs.loss / accumulation_steps scaler.scale(loss).backward() if (step + 1) % accumulation_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()

注意混合精度不是银弹,如果显存还差很多,就要认真考虑剪枝或使用蒸馏模型。我做项目时曾经因为显存不足强行调小batch size到2,结果BN层统计量变得很不稳定,模型收敛速度骤降,后来换用gradient checkpointing才解决。

6.2 训练不收敛的三种常见诱因

如果你发现loss一直不降或者不断震荡,先别急着调参,按下面顺序检查:

  • 检查学习率是否过大。BERT微调超过5e-5就很容易发散,预训练小型模型也尽量不要超过1e-3。
  • 检查数据管道。确认没有把标签和输入错位、padding后的token id有没有被当成真实词来计算损失、特殊token是否被错误地纳入掩码。
  • 检查mask逻辑。用一个小句子的输入输出打印一遍,人工核对[MASK]的位置和标签值是否合理。

我在实际踩坑中发现,第二类问题最常见,尤其是使用DataLoader时忘了remove_columns,导致label列保留为原始字符串。这样labels张量和logits的shape不匹配,PyTorch会隐式广播,loss看起来也在下降,但其实训练方向完全错了。

6.3 tokenizer加载特别慢

如果你发现BertTokenizer.from_pretrained每次都要等很久,先检查是不是apex或tokenizers版本不匹配。还有一种可能是Hugging Face缓存目录权限问题,它尝试下载模型但网络受限,反复重试导致卡顿。解决办法是预先手动把模型文件下载到本地,然后直接把路径传给from_pretrained。

另外,英文BERT库的tokenizer在加载时会执行一次vocab检查,如果vocab.txt里的token数量跟config里的vocab_size不一致,有时候会静默截断或扩展,容易引发后续Embedding维度错误。遇到这类问题,最稳的做法是同时加载tokenizer和model,然后打印model.config.vocab_size和tokenizer.vocab_size,手动对一下。

6.4 推断速度太慢

微调后的模型在生产环境往往面临推理延迟要求。BERT-base一次前向在CPU上大约需要几十毫秒,但如果并发量高,可以:

  • 把模型转成ONNX,用onnxruntime加速;
  • 使用蒸馏后的蒸馏BERT(比如distilbert-base-uncased);
  • 将batch请求合并,减少重复计算;
  • 调整数据预处理,让padding尽量接近真实句长,避免无效计算。

这些优化方法中,转ONNX是我的个人偏好,因为在CPU上实测能获得2~3倍加速,而且不需要重新训练模型。转换代码很简短:

import torch from transformers import BertForSequenceClassification model = BertForSequenceClassification.from_pretrained("bert-base-uncased") model.eval() dummy_input = torch.randint(0, 100, (1, 128)) torch.onnx.export( model, dummy_input, "bert_classifier.onnx", opset_version=13, input_names=["input_ids"], output_names=["logits"], dynamic_axes={"input_ids": {0: "batch_size"}, "logits": {0: "batch_size"}} )

注意这里的dummy_input只传入input_ids,没有传attention_mask,因为onnx export时默认会有固定常量张量。如果你想支持动态mask,需要在export前额外把attention_mask也放进dummy输入里。

7. 一棵树与一片森林:动手之后还能往哪里走

把小型BERT预训练和微调全流程跑通之后,你对"预训练+微调"这套范式的理解会达到一个新的层次。不过我不太建议止步于此,BERT的设计其实留了很多可以继续深挖的接口。从我个人的实操体会看,有几个方向值得顺着这个切入点走下去。

第一个方向是各种变体模型。BERT之后出现了RoBERTa,去掉了NSP任务,改用动态掩码,训练时间更长,效果更好;ALBERT通过参数共享大幅减少内存占用;ELECTRA换了一套"替换token检测"的预训练任务,效率更高。如果理解了BERT的MLM,再看这些模型会轻松很多。

第二个方向是生成模型。BERT本身是Encoder结构,不太适合直接做文本生成,但它启发了T5和BART这类Encoder-Decoder模型。你可以在BERT基础上尝试加一个Decoder来做简单的摘要任务,这属于把BERT当作初始化编码器的进阶玩法。

第三个方向是把BERT应用到多模态领域。现在很多多模态模型在文本侧依然使用BERT做语义编码,视觉侧用CNN或ViT提取特征,然后融合注意力。理解BERT的序列编码方式,对理解这类模型很有帮助。

最后说一个我在实际项目里反复体验到的小技巧:任何下游任务,在微调前先固定BERT部分权重,只训练分类头,看loss能不能降到合理范围,再用不同层次的学习率联合微调。这个"两阶段微调"技巧能帮你快速定位问题,避免一开始就把全部参数搅在一起。动手学深度学习,动手是第一位的,但思考也一定不能缺席。希望你也能在拆解和重组BERT的过程中,真正收获属于自己的理解。

RELATED READING

延伸阅读

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