AI BERTology踩坑记录
AI BERTology我没按教科书顺序做。
先解决眼前的阻塞,再回头补原理。
为什么是 BERT?
在 BERT 之前,NLP 的主流还是 Word2Vec、GloVe 这类静态词向量,加上 RNN/LSTM/GRU 这类序列模型。静态词向量的问题是,一个词在不同语境下只能有同一个表示,“apple"在"eat an apple"和"Apple Inc.“里是同一个向量,这显然不合理。
ELMo 最早提出了上下文词向量,用双向 LSTM 来编码上下文,但它的问题在于 LSTM 的训练效率低,而且长距离依赖能力有限。Transformer 的出现解决了这个问题——通过自注意力机制,模型能直接捕捉任意两个词之间的关联,不受距离限制。
BERT 的核心思想很简单:用 Transformer encoder 做一个双向上下文编码器,然后在大规模语料上做两个预训练任务——Masked Language Model (MLM) 和 Next Sentence Prediction (NSP)。
MLM 会在输入序列中随机 mask 掉 15% 的 token,让模型去预测它们。NSP 则是给模型两个句子,判断它们是否是连续的。这两个任务让模型同时学到了词级别的理解和句子级别的关联。
第一次用 BERT 做文本分类时,我的代码大概是这样:
from transformers import BertTokenizer, BertModel, BertForSequenceClassification
import torch
# 加载预训练模型和 tokenizer
model_name = 'bert-base-chinese'
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForSequenceClassification.from_pretrained(model_name, num_labels=3)
# 准备输入
text = "这家餐厅的菜品很不错,但服务有点慢"
inputs = tokenizer(text, return_tensors='pt', truncation=True, padding=True, max_length=128)
# 前向传播
with torch.no_grad():
outputs = model(**inputs)
predictions = torch.nn.functional.softmax(outputs.logits, dim=-1)
print(f"情感预测: {predictions}")
这段代码能跑通,但实际项目里就没这么简单了。数据不平衡、类别噪声、过拟合、推理速度慢,这些问题一个接一个来。
BERT 的架构细节
BERT-base 有 12 层 Transformer encoder,隐藏层维度 768,注意力头 12 个,参数量 110M。BERT-large 把这些数字翻了一倍多——24 层、1024 维度、16 个注意力头、340M 参数。
从架构上看,BERT 基本就是 Transformer encoder 的堆叠,但它有几个关键设计:
双向注意力:BERT 能同时看到上下文,而 GPT 类模型只能看左边。这对很多理解类任务很重要,比如命名实体识别、情感分析。
位置编码:BERT 用的是可学习的位置嵌入,不是 Transformer 原论文里的固定 sin/cos 编码。这个改动争议不小,有人认为可学习的位置编码限制了模型的泛化能力。
Segment embeddings:BERT 支持两个句子输入,所以需要额外的 segment embedding 来区分 token 属于哪个句子。这个设计主要为了 NSP 任务。
下面是 BERT 输入处理的一个简化示意:
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
text_a = "今天天气不错"
text_b = "适合出门散步"
# BERT 输入格式:[CLS] 句子A [SEP] 句子B [SEP]
inputs = tokenizer(text_a, text_b, return_tensors='pt')
print("Input IDs:", inputs['input_ids'])
print("Token Type IDs:", inputs['token_type_ids']) # 0 表示句子A,1 表示句子B
print("Attention Mask:", inputs['attention_mask'])
实际用的时候,你会发现 [CLS] 这个特殊 token 很重要。BERT 在分类任务里,通常用 [CLS] 位置的输出作为整个序列的表示,然后在上面接一个分类层。
from transformers import BertTokenizer, BertModel
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
model = BertModel.from_pretrained('bert-base-chinese')
text = "深度学习模型需要大量数据"
inputs = tokenizer(text, return_tensors='pt')
with torch.no_grad():
outputs = model(**inputs)
cls_embedding = outputs.last_hidden_state[:, 0, :] # [CLS] token 的表示
这个设计其实挺巧妙——用一个固定的位置来聚合整个序列的信息。但它也有问题:[CLS] 的表示能力有限,有些任务直接用所有 token 的平均池化效果更好。
预训练任务的设计
BERT 的两个预训练任务里,MLM 是核心,NSP 后来被证明没那么重要。
MLM 的工作原理是:随机 mask 掉输入序列中 15% 的 token,然后用 mask、随机词、原词三种策略来替换,让模型预测原始 token。这个设计是为了避免模型过度依赖 [MASK] 这个 token。
# 手动实现一个简单的 MLM 流程
from transformers import BertTokenizer, BertForMaskedLM
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
model = BertForMaskedLM.from_pretrained('bert-base-chinese')
text = "今天天气真[MASK],适合出门散步"
inputs = tokenizer(text, return_tensors='pt')
with torch.no_grad():
outputs = model(**inputs)
predictions = torch.nn.functional.softmax(outputs.logits, dim=-1)
# 获取被 mask 位置的预测结果
mask_token_index = torch.where(inputs['input_ids'] == tokenizer.mask_token_id)[1]
predicted_token_id = predictions[0, mask_token_index].argmax(axis=-1)
predicted_token = tokenizer.decode(predicted_token_id)
print(f"预测词: {predicted_token}") # 可能会预测成 "好"
NSP 任务后来被 RoBERTa 等工作证明收益不大。有些研究甚至发现去掉 NSP 后效果更好。原因可能是 NSP 的人工构造痕迹太重,真实语料里"连续句子"的边界本身就很模糊。
从现在的视角看,BERT 的预训练任务设计其实是比较粗糙的。后来的 ELECTRA、DeBERTa 等模型用了更精巧的任务设计,但核心思想还是一样的——在大规模语料上学到通用的语言表示。
微调实践
BERT 最早推崇的用法是端到端微调:在下游任务上加载预训练权重,然后继续训练整个网络。但实际项目里,这种做法有几个坑:
过拟合风险高:下游数据量小的话,微调很容易过拟合。我试过在一个 2000 条样本的数据集上微调 BERT-large,训练集准确率到 98%,验证集只有 60%。
显存占用大:BERT-large 动辄几百 M 参数,加上优化器状态,一张 16G 显存的卡可能跑不动大的 batch size。
训练不稳定:学习率稍微调不对,模型就可能发散或者学不动。
我现在更推荐的做法是参数高效微调,比如只微调最后几层,或者用 LoRA、Adapter 这类方法。
from transformers import BertForSequenceClassification, AdamW, get_linear_schedule_with_warmup
model = BertForSequenceClassification.from_pretrained('bert-base-chinese', num_labels=3)
# 冻结前面 10 层,只训练最后 2 层 + 分类层
for name, param in model.named_parameters():
if "encoder.layer.0" in name or "encoder.layer.1" in name or "encoder.layer.2" in name or "encoder.layer.3" in name or "encoder.layer.4" in name or "encoder.layer.5" in name or "encoder.layer.6" in name or "encoder.layer.7" in name or "encoder.layer.8" in name or "encoder.layer.9" in name:
param.requires_grad = False
optimizer = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=2e-5)
# 训练循环
model.train()
for batch in train_dataloader:
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
scheduler.step()
optimizer.zero_grad()
实际项目中,我更推荐先用预训练 BERT 做特征提取,然后在上面搭一个比较轻量的模型,而不是直接端到端微调整个网络。这样训练稳定,而且更容易调试。
从 BERT 到 BERTology
BERTology 这个词,本质上是对 BERT 及其变体的一套系统性研究。这包括:
- 架构探索:从 BERT-base 到 BERT-large,再到各种轻量级变体
- 预训练改进:RoBERTa 去掉 NSP、ALBERT 参数共享、ELECTRA 判别式预训练
- 微调策略:Prompt tuning、Adapter、LoRA 等参数高效微调方法
- 扩展规律:模型规模、数据量、计算力之间的权衡
这几年看下来,BERTology 最大的贡献不是某个具体的模型,而是这套方法论:预训练 + 微调。这个范式后来被证明是通用的,不限于 NLP,在 CV、多模态领域同样适用。
RoBERTa 是 BERT 的第一个重要改进,它把训练数据量加大,去掉了 NSP,用更长的训练时间,最终在很多任务上超越了 BERT。
from transformers import RobertaTokenizer, RobertaModel
tokenizer = RobertaTokenizer.from_pretrained('roberta-base')
model = RobertaModel.from_pretrained('roberta-base')
ALBERT 的思路不太一样,它通过参数共享来压缩模型规模,让大模型在有限资源下也能训练。但压缩过头也会影响表达能力,这点在实际应用时要权衡。
ELECTRA 的预训练任务设计得很有意思,它不是预测被 mask 的 token,而是去判断"这个 token 是否被替换过”。这种判别式训练比 MLM 更高效,相同计算资源下能学到更多。
实战中的模型选择
真实项目里,你很少会直接拿原版 BERT 来用。更多的时候,你是根据场景在多个变体之间做选择。
- 资源受限:考虑 DistilBERT、TinyBERT 这类蒸馏版本
- 多语言场景:XLM-RoBERTa、mBERT 跨语言迁移能力更强
- 长文本处理:Longformer、BigBird 解决了 BERT 512 token 的长度限制
- 特定领域:SciBERT、BioBERT 在专业领域上表现更好
我做过一个多语言情感分析项目,一开始用的是 BERT-base,效果一般。后来换成 XLM-RoBERTa-base,同样的训练数据,准确率从 72% 提到 78%,而且支持更多语言。但也因此付出了更大的计算成本,这是一个典型的权衡。
from transformers import XLMRobertaTokenizer, XLMRobertaForSequenceClassification
tokenizer = XLMRobertaTokenizer.from_pretrained('xlm-roberta-base')
model = XLMRobertaForSequenceClassification.from_pretrained('xlm-roberta-base', num_labels=3)
inputs = tokenizer("这是一条中文评论", return_tensors='pt')
outputs = model(**inputs)
从 BERT 到大模型
站在今天回头看 BERT,它更像是一个过渡阶段。它奠定了预训练的范式,但受限于当时的算力和数据,规模上不去。GPT-3 把规模拉大后,整个游戏规则变了。
BERT 这类 encoder 架构擅长理解但不擅长生成,GPT 类 decoder 架构擅长生成但理解能力相对弱。后来的 T5、BART 尝试用 encoder-decoder 架构兼顾两者,但真正把这事做彻底的,还是 ChatGPT 这种对话式的大模型。
现在很多任务上,你其实不再需要单独去微调 BERT。直接调用大模型的 API,用 prompt engineering 也能拿到不错的效果。但在某些场景下,比如低资源环境、隐私敏感数据、或者需要极致延迟控制的场景,BERT 类模型仍然有价值。
一些没写在论文里的判断
这些年用下来,我有几个没怎么在论文里看到、但在实践中很真实的判断:
预训练不是银弹。如果你的下游任务和预训练数据的分布差异很大,预训练模型的效果可能还不如从头训练。比如医学领域的某些细分任务,通用 BERT 未必比专业模型好。
模型规模不是越大越好。在数据量和计算力有限的情况下,盲目扩大模型规模往往是得不偿失的。我见过有人用 BERT-large 做 10 类分类,训练半天不收敛,换成 BERT-base 反而更快出结果。
工程细节比模型选择更关键。学习率调度、数据增强、早停策略、正则化方法,这些东西在实际项目中往往比选 BERT 还是 RoBERTa 影响更大。
不要追新。NLP 这个领域更新太快,今天的新架构明天可能就被别的架构超越。但很多老模型在生产环境里跑得很好,没必要频繁换模型。
写在最后
BERT 的出现,让整个 NLP 领域从手工特征工程转向了预训练 + 微调的范式。这个范式后来扩展到了大模型时代,成了今天 AI 的主流思路。
但从技术演进的视角看,BERT 只是这条路上的一个节点。它证明了这套方法可行,但受限于当时的条件,没能把规模拉上去。后来大模型证明了规模的力量,也带来了新的问题——算力消耗、部署成本、可解释性。
站在今天看 BERTology,它的价值不在于某个具体的模型,而在于这套思考方式:如何设计预训练任务、如何平衡模型规模与性能、如何把基础模型迁移到下游场景。
技术就是这样,每个时代都有自己的主流方法,但那些底层的思考方式,往往会穿越时代留下来。BERT 作为一个过渡性的存在,恰好把这种思考方式展现得很清楚。
版权声明: 本文首发于 指尖魔法屋-AI BERTology踩坑记录(https://blog.thinkmoon.cn/post/251-ai-bertology-deep-analysis/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。