关于BERT实战的几点记录

我用的环境是:

  • Python 3.11.8
  • PyTorch 2.3.0 (CUDA 12.1)
  • Transformers 4.41.2
  • 一块 NVIDIA RTX 4090 (24GB)

显存够用是最大的奢侈,但也意味着我可以放手试一些别人不敢动的参数。

这三天算是白给了,所以这次把整个流程完整走一遍,把坑都记清楚。

从头开始理解 BERT

说直白点,BERT 就是把一块文本切成子词,随机遮掉一些让它猜、随机交换一些句子位置让它判断顺序、或者直接让它判断下一句是不是真下一句,用这些无监督任务"预训练"出一个懂得语言结构和语义的基础模型。然后我们再拿具体任务(比如分类、NER、QA)的数据去"微调"这个基础模型,让它适应我们的场景。

预训练阶段是通用的,微调阶段是专用的。这是大部分人都能说清楚的一点,但实际操作的时候,这两个阶段的边界经常被混淆。

我一开始就把预训练和微调混在一起想,总觉得既然可以从头训练,那为什么不让模型更贴合我的数据?试了两次之后放弃:数据量太小(2 万条),从头预训练一个基础模型得跑一个月,而且过拟合风险巨大。后来换了个思路——用 HuggingFace 上现成的预训练模型作为起点,只用微调来适应具体任务。

环境准备和依赖安装

先把依赖装好,不然后面会有一堆莫名其妙的版本冲突:

pip install torch==2.3.0 torchvision==0.18.0 torchaudio==2.3.0 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.41.2 datasets tokenizers==0.15.0
pip install accelerate==0.30.1
pip install scikit-learn evaluate==0.4.2

注意 tokenizers 的版本。我之前用的 transformers 4.40.0 配 tokenizers 0.15.1,跑的时候一直报 AttributeError: 'PreTrainedTokenizerFast' object has no attribute 'add_special_tokens',查了半天是版本不兼容。transformers 4.41.2 搭配 tokenizers 0.15.0 是稳定的。

再写一个简单的数据加载脚本:

from datasets import load_dataset
from transformers import AutoTokenizer

model_name = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(model_name)

def tokenize_function(examples):
    return tokenizer(
        examples["text"],
        padding="max_length",
        truncation=True,
        max_length=512,
        return_tensors="pt"
    )

# 假设你的数据在 data.csv,字段为 text 和 label
dataset = load_dataset("csv", data_files="data.csv", split="train")
tokenized_datasets = dataset.map(tokenize_function, batched=True)
tokenized_datasets = tokenized_datasets.train_test_split(test_size=0.2)

这里有个坑:如果你的数据里有很多英文代码、URL 或特殊字符,默认的 bert-base-chinese tokenizer 会把它们拆成单个字符。我后来换了 google-bert/bert-base-multilingual-cased,效果好了不少。但这个选择要看你的具体场景——纯中文就继续用 bert-base-chinese,混合语言就上 multilingual,英文为主直接用 bert-base-uncasedcased

微调流程

微调的套路其实很固定,关键在参数调优和监控。我写了一个相对完整的训练脚本:

from transformers import (
    AutoModelForSequenceClassification,
    Trainer,
    TrainingArguments,
    DataCollatorWithPadding
)
from sklearn.metrics import accuracy_score, precision_recall_fscore_support
import numpy as np

model = AutoModelForSequenceClassification.from_pretrained(
    model_name,
    num_labels=3  # 根据你的任务调整
)

def compute_metrics(eval_pred):
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=-1)
    precision, recall, f1, _ = precision_recall_fscore_support(
        labels, predictions, average='weighted'
    )
    acc = accuracy_score(labels, predictions)
    return {
        'accuracy': acc,
        'f1': f1,
        'precision': precision,
        'recall': recall
    }

training_args = TrainingArguments(
    output_dir="./results",
    evaluation_strategy="epoch",
    save_strategy="epoch",
    learning_rate=2e-5,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    num_train_epochs=3,
    weight_decay=0.01,
    load_best_model_at_end=True,
    metric_for_best_model="f1",
    logging_dir="./logs",
    logging_steps=100,
    fp16=True,  # RTX 4090 支持
    dataloader_num_workers=4,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_datasets["train"],
    eval_dataset=tokenized_datasets["test"],
    tokenizer=tokenizer,
    data_collator=DataCollatorWithPadding(tokenizer=tokenizer),
    compute_metrics=compute_metrics,
)

trainer.train()

跑这个脚本的时候我踩了几个坑:

第一个坑是显存。 一开始 batch size 设成 32,直接 OOM。调成 16 还是爆,最后降到 8 才跑起来。但 batch size 太小会导致梯度不稳定,所以后来开启了梯度累积:

training_args = TrainingArguments(
    # ... 其他参数
    per_device_train_batch_size=8,
    gradient_accumulation_steps=4,  # 实际 batch size = 8 * 4 = 32
    # ...
)

这样显存压力小了,有效 batch size 还是 32。

第二个坑是学习率。 2e-5 是 BERT 微调的经典值,但不是万能的。我试过 5e-5,前两个 epoch F1 跑得很快,到第三个突然掉下去——典型的学习率过大导致震荡。最后稳定在 1e-5,收敛慢但稳。

第三个坑是早停。 TrainingArguments 里的 load_best_model_at_end=True 只能在 eval 之后根据指标选最好的模型,但它本身不会自动停止训练。我写了外层监控脚本:

from transformers import EarlyStoppingCallback

training_args = TrainingArguments(
    # ...
    load_best_model_at_end=True,
    metric_for_best_model="f1",
    greater_is_better=True,
)

trainer = Trainer(
    # ...
    callbacks=[EarlyStoppingCallback(early_stopping_patience=2)]
)

patience=2 意味着如果验证集指标连续 2 个 epoch 不提升,就提前终止。这个值要调得太紧会提前停止,太松浪费时间。我试过 1 和 3,最后选 2 是一个折中。

模型保存和部署

训练完了要保存模型,这个简单:

trainer.save_model("./bert_classifier")
tokenizer.save_pretrained("./bert_classifier")

但直接这么保存的模型文件很大(bert-base-chinese 大概 400MB),部署到生产环境有点吃力。如果显存够,可以顺便用 torch.jit.trace 导出为 TorchScript:

import torch

model = AutoModelForSequenceClassification.from_pretrained("./bert_classifier")
model.eval()

dummy_input = {
    "input_ids": torch.randint(0, tokenizer.vocab_size, (1, 512)),
    "attention_mask": torch.ones(1, 512, dtype=torch.long)
}

traced_model = torch.jit.trace(model, (dummy_input["input_ids"], dummy_input["attention_mask"]))
traced_model.save("./bert_classifier/traced_model.pt")

TorchScript 的好处是不依赖 transformers 库,可以直接用纯 PyTorch 加载。但要注意 trace 是静态的,如果你的输入长度变化很大,可能得用 script 而不是 trace。我因为这个踩过坑:导出的模型在推理时报形状不匹配,查了半天是因为测试数据长度超过 512 而 trace 时用的是固定长度。

部署的时候我还试过 ONNX,效果也不错:

from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

tokenizer = AutoTokenizer.from_pretrained("./bert_classifier")
model = AutoModelForSequenceClassification.from_pretrained("./bert_classifier")

# 导出为 ONNX
dummy_input = {
    "input_ids": torch.randint(0, tokenizer.vocab_size, (1, 512)),
    "attention_mask": torch.ones(1, 512, dtype=torch.long)
}

torch.onnx.export(
    model,
    (dummy_input["input_ids"], dummy_input["attention_mask"]),
    "./bert_classifier/model.onnx",
    input_names=["input_ids", "attention_mask"],
    output_names=["logits"],
    dynamic_axes={
        "input_ids": {0: "batch_size", 1: "sequence_length"},
        "attention_mask": {0: "batch_size", 1: "sequence_length"},
        "logits": {0: "batch_size"}
    }
)

ONNX 的好处是跨平台兼容性好,可以部署到不支持 PyTorch 的环境。但要注意 ONNX Runtime 对动态长度的支持有时会有限制,需要仔细测试。

从预训练开始的尝试

前面说的是用现成的预训练模型微调,那如果我们想自己预训练一个呢?我也试过一次,这里把经验分享一下。

预训练需要大量无标注文本,我用的是公开的中文语料(Wikipedia、新闻数据集等),大概 100GB。预训练有两个任务:Masked Language Modeling (MLM) 和 Next Sentence Prediction (NSP)。

数据准备阶段比较麻烦:

from datasets import load_dataset
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")

def preprocess_function(examples):
    return tokenizer(
        examples["text"],
        truncation=True,
        max_length=512,
        return_special_tokens_mask=True,
    )

raw_datasets = load_dataset("text", data_files={"train": "corpus.txt"})
tokenized_datasets = raw_datasets.map(
    preprocess_function,
    batched=True,
    num_proc=4,
    remove_columns=["text"],
)

然后是 Data Collator,这是预训练的核心——它负责生成 MLM 的 mask:

from transformers import DataCollatorForLanguageModeling

data_collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer,
    mlm_probability=0.15,  # 15% 的 token 会被 mask
)

预训练的脚本和微调差不多,但换成了 MLM 任务:

from transformers import AutoModelForMaskedLM, TrainingArguments, Trainer

model = AutoModelForMaskedLM.from_pretrained("bert-base-chinese")

training_args = TrainingArguments(
    output_dir="./mlm_results",
    overwrite_output_dir=True,
    num_train_epochs=10,
    per_device_train_batch_size=16,
    save_steps=5000,
    save_total_limit=2,
    prediction_loss_only=True,
    fp16=True,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_datasets["train"],
    data_collator=data_collator,
)

trainer.train()

这里有个很容易踩的坑:如果你从头预训练(from_pretrained 用一个随机初始化的空模型而不是 bert-base-chinese),收敛会非常慢。我第一次试的时候跑了 5 个 epoch loss 还在 10 以上,后来意识到应该用 BertForMaskedLM.from_config(BertConfig()) 才是真正从头开始。

从头预训练的性价比很低。100GB 语料跑 10 个 epoch,在 RTX 4090 上大概要一周,而最后微调的效果并不比直接用 HuggingFace 上的模型好多少。除非你的领域非常特殊(比如医疗、金融、法律),而且有大量高质量领域内语料,否则不要轻易尝试从头预训练。

性能优化和调试

微调过程中,监控和调优占了一半的时间。我常用的几个手段:

一是日志分析。 我会先把 TensorBoard 打开:

tensorboard --logdir=./logs

然后盯着 train loss 和 eval loss 的曲线。如果 train loss 降得很快但 eval loss 反弹,说明过拟合了——可以加 dropout、减小模型或者增加数据。如果两者都降得很慢,可能是学习率太小或者数据质量有问题。

二是梯度裁剪。 有时候会出现梯度爆炸,loss 直接变成 nan。在 TrainingArguments 里加一句:

training_args = TrainingArguments(
    # ...
    max_grad_norm=1.0,
    # ...
)

这会把梯度范数裁剪到 1.0 以下,避免数值爆炸。

三是混合精度训练。 开启 fp16=True 以后,训练速度能提升 1.5-2 倍,显存占用也能减半。但要注意:有些 GPU 对 fp16 支持不好,可能出现梯度下溢。如果 loss 突然变成 0,可以试试关闭 fp16 或者用 bf16(需要 Ampere 架构的 GPU)。

四是学习率调度。 默认的线性预热可能不够灵活。我试过余弦退火:

from transformers import get_cosine_schedule_with_warmup

optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
num_training_steps = len(train_dataset) // training_args.per_device_train_batch_size * training_args.num_train_epochs
lr_scheduler = get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps=int(0.1 * num_training_steps),
    num_training_steps=num_training_steps
)

trainer = Trainer(
    # ...
    optimizers=(optimizer, lr_scheduler),
)

余弦退火在后期会逐渐减小学习率,有时候能找到更好的局部最优。

数据增强和对抗训练

如果数据量不够,可以考虑数据增强。我试过几种方法:

同义词替换。nlpaug 库随机替换一些词:

import nlpaug.augmenter.word as naw

aug = naw.SynonymAug(aug_src='wordnet')
augmented_text = aug.augment("这是一段文本")

回译。 用翻译 API 把中文译成英文再译回中文,能产生一些语义相近但表达不同的样本:

from deep_translator import GoogleTranslator

def back_translate(text):
    en = GoogleTranslator(source='zh-CN', target='en').translate(text)
    zh = GoogleTranslator(source='en', target='zh-CN').translate(en)
    return zh

回译的效果有时会很好,有时会很奇怪——翻译 API 偶尔会把意思改得太远,需要人工过滤。

还有一种方法是对抗训练(FGM/PGD),原理是在输入上加入微小扰动,强迫模型学习更鲁棒的特征。transformers-contrib 库里有现成的实现:

from transformers import BertForSequenceClassification, BertTokenizer
from transformers_contrib import FGM

model = BertForSequenceClassification.from_pretrained("bert-base-chinese")
tokenizer = BertTokenizer.from_pretrained("bert-base-chinese")
fgm = FGM(model)

for batch in dataloader:
    loss = model(**batch).loss
    loss.backward()  # 正常反向传播
    fgm.attack()  # 对抗扰动
    loss_adv = model(**batch).loss
    loss_adv.backward()  # 对抗损失反向传播
    fgm.restore()  # 恢复原始参数
    optimizer.step()
    optimizer.zero_grad()

对抗训练能提升 1-2 个百分点的鲁棒性,但训练时间会增加 30% 左右。如果你的模型已经达到 0.9+ F1,对抗训练可能意义不大;但如果还在 0.7-0.8 之间挣扎,可以试一试。

常见问题和解决方案

最后整理几个我遇到过的问题,省得以后再重复掉坑。

问题一:tokenizer 和模型不匹配。 现象:RuntimeError: CUDA error: device-side assert triggered 原因:用了 bert-base-chinese 的 tokenizer 但载入的是 bert-base-uncased 的模型,或者反过来。 解决:确保 from_pretrained 的名字一致。

问题二:标签 ID 超出范围。 现象:ValueError: Received a label_value of X which is outside the valid range of [0, 2] 原因:你的数据标签是从 1 开始的,但模型期望从 0 开始。 解决:预处理的时候减 1,或者在配置里改 num_labels

问题三:推理结果和训练结果差异巨大。 现象:训练集 F1 0.95,测试集 F1 0.6 原因:数据分布差异、过拟合、或者预处理不一致。 解决:检查测试数据的预处理流程是否和训练一致,确认没有数据泄露。

问题四:ONNX 导出后精度下降。 现象:PyTorch 推理 F1 0.85,ONNX Runtime 只有 0.82 原因:浮点精度问题或算子转换不完全。 解决:尝试用 opset_version 17 或更高版本,或者检查是否有自定义算子未正确转换。

问题五:多 GPU 训练时显存占用异常高。 现象:单 GPU 8GB 能跑,多 GPU 时每块都要 8GB 原因:DataParallel 会复制模型到每块 GPU,但数据也会复制。 解决:用 DistributedDataParallel 替代 DataParallel,或者调整 per_device_train_batch_size

写在最后

这篇文章记录的是一次 BERT 从预训练到微调的完整实践。中间踩了很多坑,有些是版本问题,有些是参数调优问题,有些是思维惯性带来的误判。但我最大的感受是:BERT 不是一个"即插即用"的黑盒,你至少需要理解它的基本原理、数据预处理流程、训练参数和常见问题,才能真正用好它。

预训练模型确实是 NLP 的一场革命,但它解决的是通用语言理解问题,而不是你的具体业务问题。微调的目的就是把通用能力迁移到具体场景,这个迁移过程的质量,最终取决于你的数据质量、参数调优和对问题的理解。

如果你只是想跑通一个 demo,用 HuggingFace 的 Trainer 和现成的预训练模型,半小时就能搞定。但如果你想在生产环境里拿到真正可用的模型,就得花时间去理解数据、调优参数、监控训练曲线,然后反复迭代。这部分工作没有什么捷径,经验和判断比文档和教程更重要。

模型不是万能的,但理解模型怎么用,有时候比模型本身更重要。

可用性说明:本文发布于 2021 年 1 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-关于BERT实战的几点记录https://blog.thinkmoon.cn/post/166-bert-practice-pretraining-finetuning/) 转载或引用必须申明原指尖魔法屋来源及源地址!