Skip to content

第 11 章 · 微调 BERT

本章目标:以表示模型(BERT)为核心,系统实践监督分类、冻结层策略、少样本分类(SetFit)、掩码语言建模(MLM)继续预训练以及命名实体识别(NER)五类微调方法。

11.1 环境准备(可选)

如果你在 Google Colab(或其他云平台)上查看本笔记本,需要取消注释并运行下面的代码块来安装本章依赖:

💡 注意:运行本章示例需要 GPU。在 Google Colab 中,进入 Runtime > Change runtime type > Hardware accelerator > GPU > GPU type > T4

python
# %%capture
# !pip install "datasets>=2.18.0,<3" transformers>=4.38.2 sentence-transformers>=2.5.1 setfit>=1.0.3 accelerate>=0.27.2 seqeval>=1.2.2

11.2 数据准备

我们使用经典的 Rotten Tomatoes 电影评论数据集,它已内置于 Hugging Face datasets 库中:

python
from datasets import load_dataset

# Prepare data and splits
tomatoes = load_dataset("rotten_tomatoes")
train_data, test_data = tomatoes["train"], tomatoes["test"]

11.3 监督分类

11.3.1 HuggingFace Trainer

首先加载 bert-base-cased 模型及其分词器,num_labels=2 表示这是一个二分类任务:

python
from transformers import AutoTokenizer, AutoModelForSequenceClassification

# Load Model and Tokenizer
model_id = "bert-base-cased"
model = AutoModelForSequenceClassification.from_pretrained(model_id, num_labels=2)
tokenizer = AutoTokenizer.from_pretrained(model_id)

对数据进行分词。为了让每个 batch 内序列长度一致,我们使用 DataCollatorWithPadding 动态填充到 batch 内最长序列:

python
from transformers import DataCollatorWithPadding

# Pad to the longest sequence in the batch
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

def preprocess_function(examples):
   """Tokenize input data"""
   return tokenizer(examples["text"], truncation=True)

# Tokenize train/test data
tokenized_train = train_data.map(preprocess_function, batched=True)
tokenized_test = test_data.map(preprocess_function, batched=True)

定义评估指标——这里使用 F1 分数:

python
import numpy as np
import evaluate


def compute_metrics(eval_pred):
    """Calculate F1 score"""
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=-1)

    load_f1 = evaluate.load("f1")
    f1 = load_f1.compute(predictions=predictions, references=labels)["f1"]
    return {"f1": f1}

配置训练参数并构建 Trainer 来执行训练流程:

python
from transformers import TrainingArguments, Trainer

# Training arguments for parameter tuning
training_args = TrainingArguments(
   "model",
   learning_rate=2e-5,
   per_device_train_batch_size=16,
   per_device_eval_batch_size=16,
   num_train_epochs=1,
   weight_decay=0.01,
   save_strategy="epoch",
   report_to="none"
)

# Trainer which executes the training process
trainer = Trainer(
   model=model,
   args=training_args,
   train_dataset=tokenized_train,
   eval_dataset=tokenized_test,
   tokenizer=tokenizer,
   data_collator=data_collator,
   compute_metrics=compute_metrics,
)

开始训练:

python
trainer.train()

评估结果:

python
trainer.evaluate()

11.3.2 冻结分类头以外的所有层

全量微调代价高,更经济的做法是只训练分类头、冻结编码器主体。先重新加载模型,然后打印所有参数名看看结构:

python
# Load Model and Tokenizer
model = AutoModelForSequenceClassification.from_pretrained(model_id, num_labels=2)
tokenizer = AutoTokenizer.from_pretrained(model_id)
python
# Print layer names
for name, param in model.named_parameters():
    print(name)

classifier 开头的参数是分类头——保留可训练;其余全部冻结:

python
for name, param in model.named_parameters():

     # Trainable classification head
     if name.startswith("classifier"):
        param.requires_grad = True

      # Freeze everything else
     else:
        param.requires_grad = False

检查模型是否被正确更新:

python
# We can check whether the model was correctly updated
for name, param in model.named_parameters():
     print(f"Parameter: {name} ----- {param.requires_grad}")

用与之前相同的配置重新训练并评估:

python
from transformers import TrainingArguments, Trainer

# Trainer which executes the training process
trainer = Trainer(
   model=model,
   args=training_args,
   train_dataset=tokenized_train,
   eval_dataset=tokenized_test,
   tokenizer=tokenizer,
   data_collator=data_collator,
   compute_metrics=compute_metrics,
)
trainer.train()
python
trainer.evaluate()

11.3.3 冻结编码块 1–5

比「只训分类头」更进一步的做法:解冻部分编码器块。先打印带索引的参数列表,确定编码块的参数索引范围:

python
# We can check whether the model was correctly updated
for index, (name, param) in enumerate(model.named_parameters()):
     print(f"Parameter: {index}{name} ----- {param.requires_grad}")

实验表明:索引 165 是编码器块 10 的起始位置,冻结它之前的所有参数,只训练最后几个块:

python
# Load model
model_id = "bert-base-cased"
model = AutoModelForSequenceClassification.from_pretrained(model_id, num_labels=2)
tokenizer = AutoTokenizer.from_pretrained(model_id)

# Encoder block 10 starts at index 165 and
# we freeze everything before that block
for index, (name, param) in enumerate(model.named_parameters()):
    if index < 165:
        param.requires_grad = False

# Trainer which executes the training process
trainer = Trainer(
   model=model,
   args=training_args,
   train_dataset=tokenized_train,
   eval_dataset=tokenized_test,
   tokenizer=tokenizer,
   data_collator=data_collator,
   compute_metrics=compute_metrics,
)
trainer.train()
trainer.evaluate()

11.3.4 [BONUS] 系统性实验:逐块冻结

下面这段被注释掉的代码是一个完整的消融实验:从「全部冻结」到「全部解冻」逐级放开编码器块,记录每种配置的 F1 分数,最终画出「可训练编码块数量 vs F1」曲线:

python
# scores = []
# for index in range(12):
#     # Re-load model
#     model = AutoModelForSequenceClassification.from_pretrained("bert-base-cased", num_labels=2)
#     tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

#     # Freeze encoder blocks 0-index
#     for name, param in model.named_parameters():
#         if "layer" in name:
#             layer_nr = int(name.split("layer")[1].split(".")[1])
#             if layer_nr <= index:
#                 param.requires_grad = False
#         else:
#             param.requires_grad = True

#     # Train
#     trainer = Trainer(
#       model=model,
#       args=training_args,
#       train_dataset=tokenized_train,
#       eval_dataset=tokenized_test,
#       tokenizer=tokenizer,
#       data_collator=data_collator,
#       compute_metrics=compute_metrics,
#     )
#     trainer.train()

#     # Evaluate
#     score = trainer.evaluate()["eval_f1"]
#     scores.append(score)
python
# scores

绘图代码同样被注释保留,其中记录了 12 种配置下的实测 F1 值——可以看到解冻到第 4 个块之后性能趋于稳定:

python
# import matplotlib.pyplot as plt
# import numpy as np

# # Create Figure
# plt.figure(figsize=(8,4))

# # Prepare Data
# x = [f"0-{index}" for index in range(12)]
# x[0] = "None"
# x[-1] = "All"
# y = [
#     0.8541862652869239,
#     0.8525519848771267,
#     0.8514664143803217,
#     0.8506616257088847,
#     0.8398104265402844,
#     0.8391345249294448,
#     0.8377358490566037,
#     0.8433962264150944,
#     0.8258801141769743,
#     0.816247582205029,
#     0.7917485265225934,
#     0.7019400352733686
# ][::-1]

# # Stylize Figure
# plt.grid(color='#ECEFF1')
# plt.axvline(x=4, color="#EC407A", linestyle="--")
# plt.title("Effect of Frozen Encoder Blocks on Training Performance")
# plt.ylabel("F1-score")
# plt.xlabel("Trainable encoder blocks")

# # Plot Data
# plt.plot(x, y, color="black")

# # Additional Annotation
# plt.annotate(
#     'Performance stabilizing',
#     xy=(4, y[4]),
#     xytext=(4.5, y[4]-.05),
#     arrowprops=dict(
#         arrowstyle="-|>",
#         connectionstyle="arc3",
#         color="#00ACC1")
# )
# plt.savefig("multiple_frozen_blocks.png", dpi=300, bbox_inches='tight')

11.4 少样本分类

当标注数据稀缺时(每类只有十几个样本),传统微调难以收敛。SetFit 通过对比学习的方式解决了这个问题。先用 sample_dataset 模拟少样本场景——每类采样 16 条:

python
from setfit import sample_dataset

# We simulate a few-shot setting by sampling 16 examples per class
sampled_train_data = sample_dataset(tomatoes["train"], num_samples=16)

加载一个预训练的 SentenceTransformer 模型作为骨干:

python
from setfit import SetFitModel

# Load a pre-trained SentenceTransformer model
model = SetFitModel.from_pretrained("sentence-transformers/all-mpnet-base-v2")

SetFit 的训练分两个阶段:先对文本对做对比学习,再训练分类头。定义训练参数时通过 num_iterations 控制生成多少文本对:

python
from setfit import TrainingArguments as SetFitTrainingArguments
from setfit import Trainer as SetFitTrainer

# Define training arguments
args = SetFitTrainingArguments(
    num_epochs=3, # The number of epochs to use for contrastive learning
    num_iterations=20  # The number of text pairs to generate
)
args.eval_strategy = args.evaluation_strategy

# Create trainer
trainer = SetFitTrainer(
    model=model,
    args=args,
    train_dataset=sampled_train_data,
    eval_dataset=test_data,
    metric="f1"
)

旧版 SetFit 的写法不需要显式传入 args,这里保留在注释中供参考:

python
# from setfit import SetFitTrainer

# # Create trainer
# trainer = SetFitTrainer(
#     model=model,
#     train_dataset=sampled_train_data,
#     eval_dataset=test_data,
#     metric="f1",
#     num_epochs=3, # The number of epochs to use for contrastive learning
# )

执行训练循环:

python
# Training loop
trainer.train()

在测试集上评估:

python
# Evaluate the model on our test data
trainer.evaluate()

查看训练出来的模型头部结构:

python
model.model_head

11.5 MLM 继续预训练

如果你的领域数据与 BERT 预训练语料差异很大,可以先用掩码语言建模(Masked Language Modeling)做领域自适应继续预训练。加载 AutoModelForMaskedLM 而不是序列分类模型:

python
from transformers import AutoTokenizer, AutoModelForMaskedLM

# Load model for Masked Language Modeling (MLM)
model = AutoModelForMaskedLM.from_pretrained("bert-base-cased")
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

MLM 不需要标签,把 label 列移除即可:

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

# Tokenize data
tokenized_train = train_data.map(preprocess_function, batched=True)
tokenized_train = tokenized_train.remove_columns("label")
tokenized_test = test_data.map(preprocess_function, batched=True)
tokenized_test = tokenized_test.remove_columns("label")

使用 DataCollatorForLanguageModeling 自动掩码 token,掩码比例 15%:

python
from transformers import DataCollatorForLanguageModeling

# Masking Tokens
data_collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer,
    mlm=True,
    mlm_probability=0.15
)

除了按 token 掩码,还可以整词掩码(Whole Word Masking),代码保留在注释中:

python
# from transformers import DataCollatorForWholeWordMask

# # Masking Whole Words
# data_collator = DataCollatorForWholeWordMask(
#     tokenizer=tokenizer,
#     mlm=True,
#     mlm_probability=0.15
# )

MLM 训练轮数通常比分类任务更多(这里是 10 个 epoch):

python
# Training arguments for parameter tuning
training_args = TrainingArguments(
   "model",
   learning_rate=2e-5,
   per_device_train_batch_size=16,
   per_device_eval_batch_size=16,
   num_train_epochs=10,
   weight_decay=0.01,
   save_strategy="epoch",
   report_to="none"
)


# Initialize Trainer
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_train,
    eval_dataset=tokenized_test,
    tokenizer=tokenizer,
    data_collator=data_collator
)

保存分词器 → 训练 → 保存更新后的模型:

python
# Save pre-trained tokenizer
tokenizer.save_pretrained("mlm")

# Train model
trainer.train()

# Save updated model
model.save_pretrained("mlm")

训练前,原始 BERT 对填空任务的预测:

python
from transformers import pipeline

# Load and create predictions
mask_filler = pipeline("fill-mask", model="bert-base-cased")
preds = mask_filler("What a horrible [MASK]!")

# Print results
for pred in preds:
    print(f">>> {pred['sequence']}")

用领域适配后的 mlm 模型再做同样的预测,对比输出差异:

python
# Load and create predictions
mask_filler = pipeline("fill-mask", model="mlm")
preds = mask_filler("What a horrible [MASK]!")

# Print results
for pred in preds:
    print(f">>> {pred['sequence']}")

11.6 命名实体识别

以下是一些值得探索的 NER 数据集:

  • tner/mit_movie_trivia
  • tner/mit_restaurant
  • wnut_17
  • conll2003

导入 NER 相关模块:

python
from transformers import AutoModelForTokenClassification, AutoTokenizer
from transformers import DataCollatorWithPadding
from transformers import TrainingArguments, Trainer
import numpy as np

加载经典的 CoNLL-2003 命名实体识别数据集:

python
# The CoNLL-2003 dataset for NER
dataset = load_dataset("conll2003", trust_remote_code=True)

看一条样例:

python
example = dataset["train"][848]
example

定义标签映射关系(O/B-PER/I-PER/B-ORG 等 9 类):

python
label2id = {
    'O': 0, 'B-PER': 1, 'I-PER': 2, 'B-ORG': 3, 'I-ORG': 4,
    'B-LOC': 5, 'I-LOC': 6, 'B-MISC': 7, 'I-MISC': 8
}
id2label = {index: label for label, index in label2id.items()}
label2id

加载 token 分类模型,并把 id2label/label2id 映射传入,这样推理结果的标签会直接可读:

python
from transformers import AutoModelForTokenClassification

# Load tokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

# Load model
model = AutoModelForTokenClassification.from_pretrained(
    "bert-base-cased",
    num_labels=len(id2label),
    id2label=id2label,
    label2id=label2id
)

NER 的核心难点:单词会被拆成子词(sub-token),而标签是词级别的。先把单词拆开看看子词情况:

python
# Split individual tokens into sub-tokens
token_ids = tokenizer(example["tokens"], is_split_into_words=True)["input_ids"]
sub_tokens = tokenizer.convert_ids_to_tokens(token_ids)
sub_tokens

align_labels 函数负责对齐标签:特殊 token 标为 -100(不参与损失计算);一个词的第一个子词继承原标签;后续子词若是 B-XXX 则改为 I-XXX:

python
def align_labels(examples):
    token_ids = tokenizer(examples["tokens"], truncation=True, is_split_into_words=True)
    labels = examples["ner_tags"]

    updated_labels = []
    for index, label in enumerate(labels):

        # Map tokens to their respective word
        word_ids = token_ids.word_ids(batch_index=index)
        previous_word_idx = None
        label_ids = []
        for word_idx in word_ids:

            # The start of a new word
            if word_idx != previous_word_idx:

                previous_word_idx = word_idx
                updated_label = -100 if word_idx is None else label[word_idx]
                label_ids.append(updated_label)

            # Special token is -100
            elif word_idx is None:
                label_ids.append(-100)

            # If the label is B-XXX we change it to I-XXX
            else:
                updated_label = label[word_idx]
                if updated_label % 2 == 1:
                    updated_label += 1
                label_ids.append(updated_label)

        updated_labels.append(label_ids)

    token_ids["labels"] = updated_labels
    return token_ids

tokenized = dataset.map(align_labels, batched=True)

对比原始标签与对齐后的标签:

python
# Difference between original and updated labels
print(f"Original: {example['ner_tags']}")
print(f"Updated: {tokenized['train'][848]['labels']}")

NER 用 seqeval 库做序列评估,逐文档、逐 token 收集非 -100 的预测与真实标签:

python
import evaluate

# Load sequential evaluation
seqeval = evaluate.load("seqeval")

def compute_metrics(eval_pred):
    # Create predictions
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=2)

    true_predictions = []
    true_labels = []

    # Document-level iteration
    for prediction, label in zip(predictions, labels):

      # token-level iteration
      for token_prediction, token_label in zip(prediction, label):

        # We ignore special tokens
        if token_label != -100:
          true_predictions.append([id2label[token_prediction]])
          true_labels.append([id2label[token_label]])

    results = seqeval.compute(predictions=true_predictions, references=true_labels)
    return {"f1": results["overall_f1"]}

token 分类任务要用专门的 DataCollatorForTokenClassification 填充器:

python
from transformers import DataCollatorForTokenClassification

# Token-classification Data Collator
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)

训练配置与之前相同,初始化 Trainer 并训练:

python
# Training arguments for parameter tuning
training_args = TrainingArguments(
   "model",
   learning_rate=2e-5,
   per_device_train_batch_size=16,
   per_device_eval_batch_size=16,
   num_train_epochs=1,
   weight_decay=0.01,
   save_strategy="epoch",
   report_to="none"
)

# Initialize Trainer
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized["train"],
    eval_dataset=tokenized["test"],
    tokenizer=tokenizer,
    data_collator=data_collator,
    compute_metrics=compute_metrics,
)
trainer.train()

评估测试集:

python
# Evaluate the model on our test data
trainer.evaluate()

保存微调后的模型,并用 token-classification pipeline 在新句子上推理:

python
from transformers import pipeline

# Save our fine-tuned model
trainer.save_model("ner_model")

# Run inference on the fine-tuned model
token_classifier = pipeline(
    "token-classification",
    model="ner_model",
)
token_classifier("My name is Maarten.")

本章小结

  • 监督微调的标准链路:加载模型 → 分词(动态填充)→ 定义指标 → Trainer 训练评估;
  • 冻结策略能大幅降低训练成本:只训分类头最省,解冻部分编码块可换回性能——消融实验显示解冻约 4 个编码块后 F1 即趋于稳定;
  • 少样本场景首选 SetFit:每类仅 16 个样本也能通过对比学习取得可观效果;
  • 领域差异大时先做 MLM 继续预训练再做下游微调,DataCollatorForLanguageModeling 按 15% 概率自动掩码;
  • NER 的关键在子词-词标签对齐:特殊 token 记 -100、首子词继承标签、后续子词 B→I。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 在 HuggingFace Trainer 微调流程中,DataCollatorWithPadding 的作用是?

2. 关于冻结层策略,下列说法正确的是?

3. SetFit 少样本分类的两个训练阶段是?

4. NER 标签对齐时,为什么特殊 token 的标签要设为 -100?

🛠️ 动手实践

  1. 把 11.3.2 节的冻结策略反过来:只冻结 classifier 分类头之外的前半部分参数(例如以 bert.encoder.layer.0.layer.4. 开头的参数),训练后与「仅训练分类头」的 F1 对比。
  2. 将 SetFit 的 num_samples 从 16 分别改为 8 和 32,其余不变,绘制三种设置下的 F1 分数曲线,验证少样本数量对性能的影响。
  3. 参考 11.6 节流程,把 CoNLL-2003 数据集换成 wnut_17,检查其标签集合与 label2id 的差异并调整映射后完成训练与评估。