第 11 章 · 微调 BERT
本章目标:以表示模型(BERT)为核心,系统实践监督分类、冻结层策略、少样本分类(SetFit)、掩码语言建模(MLM)继续预训练以及命名实体识别(NER)五类微调方法。
11.1 环境准备(可选)
如果你在 Google Colab(或其他云平台)上查看本笔记本,需要取消注释并运行下面的代码块来安装本章依赖:
💡 注意:运行本章示例需要 GPU。在 Google Colab 中,进入 Runtime > Change runtime type > Hardware accelerator > GPU > GPU type > T4。
# %%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.211.2 数据准备
我们使用经典的 Rotten Tomatoes 电影评论数据集,它已内置于 Hugging Face datasets 库中:
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 表示这是一个二分类任务:
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 内最长序列:
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 分数:
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 来执行训练流程:
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,
)开始训练:
trainer.train()评估结果:
trainer.evaluate()11.3.2 冻结分类头以外的所有层
全量微调代价高,更经济的做法是只训练分类头、冻结编码器主体。先重新加载模型,然后打印所有参数名看看结构:
# Load Model and Tokenizer
model = AutoModelForSequenceClassification.from_pretrained(model_id, num_labels=2)
tokenizer = AutoTokenizer.from_pretrained(model_id)# Print layer names
for name, param in model.named_parameters():
print(name)以 classifier 开头的参数是分类头——保留可训练;其余全部冻结:
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检查模型是否被正确更新:
# We can check whether the model was correctly updated
for name, param in model.named_parameters():
print(f"Parameter: {name} ----- {param.requires_grad}")用与之前相同的配置重新训练并评估:
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()trainer.evaluate()11.3.3 冻结编码块 1–5
比「只训分类头」更进一步的做法:解冻部分编码器块。先打印带索引的参数列表,确定编码块的参数索引范围:
# 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 的起始位置,冻结它之前的所有参数,只训练最后几个块:
# 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」曲线:
# 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)# scores绘图代码同样被注释保留,其中记录了 12 种配置下的实测 F1 值——可以看到解冻到第 4 个块之后性能趋于稳定:
# 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 条:
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 模型作为骨干:
from setfit import SetFitModel
# Load a pre-trained SentenceTransformer model
model = SetFitModel.from_pretrained("sentence-transformers/all-mpnet-base-v2")SetFit 的训练分两个阶段:先对文本对做对比学习,再训练分类头。定义训练参数时通过 num_iterations 控制生成多少文本对:
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,这里保留在注释中供参考:
# 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
# )执行训练循环:
# Training loop
trainer.train()在测试集上评估:
# Evaluate the model on our test data
trainer.evaluate()查看训练出来的模型头部结构:
model.model_head11.5 MLM 继续预训练
如果你的领域数据与 BERT 预训练语料差异很大,可以先用掩码语言建模(Masked Language Modeling)做领域自适应继续预训练。加载 AutoModelForMaskedLM 而不是序列分类模型:
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 列移除即可:
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%:
from transformers import DataCollatorForLanguageModeling
# Masking Tokens
data_collator = DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=True,
mlm_probability=0.15
)除了按 token 掩码,还可以整词掩码(Whole Word Masking),代码保留在注释中:
# from transformers import DataCollatorForWholeWordMask
# # Masking Whole Words
# data_collator = DataCollatorForWholeWordMask(
# tokenizer=tokenizer,
# mlm=True,
# mlm_probability=0.15
# )MLM 训练轮数通常比分类任务更多(这里是 10 个 epoch):
# 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
)保存分词器 → 训练 → 保存更新后的模型:
# Save pre-trained tokenizer
tokenizer.save_pretrained("mlm")
# Train model
trainer.train()
# Save updated model
model.save_pretrained("mlm")训练前,原始 BERT 对填空任务的预测:
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 模型再做同样的预测,对比输出差异:
# 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 相关模块:
from transformers import AutoModelForTokenClassification, AutoTokenizer
from transformers import DataCollatorWithPadding
from transformers import TrainingArguments, Trainer
import numpy as np加载经典的 CoNLL-2003 命名实体识别数据集:
# The CoNLL-2003 dataset for NER
dataset = load_dataset("conll2003", trust_remote_code=True)看一条样例:
example = dataset["train"][848]
example定义标签映射关系(O/B-PER/I-PER/B-ORG 等 9 类):
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 映射传入,这样推理结果的标签会直接可读:
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),而标签是词级别的。先把单词拆开看看子词情况:
# 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_tokensalign_labels 函数负责对齐标签:特殊 token 标为 -100(不参与损失计算);一个词的第一个子词继承原标签;后续子词若是 B-XXX 则改为 I-XXX:
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)对比原始标签与对齐后的标签:
# Difference between original and updated labels
print(f"Original: {example['ner_tags']}")
print(f"Updated: {tokenized['train'][848]['labels']}")NER 用 seqeval 库做序列评估,逐文档、逐 token 收集非 -100 的预测与真实标签:
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 填充器:
from transformers import DataCollatorForTokenClassification
# Token-classification Data Collator
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)训练配置与之前相同,初始化 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"
)
# 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()评估测试集:
# Evaluate the model on our test data
trainer.evaluate()保存微调后的模型,并用 token-classification pipeline 在新句子上推理:
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?
🛠️ 动手实践
- 把 11.3.2 节的冻结策略反过来:只冻结
classifier分类头之外的前半部分参数(例如以bert.encoder.layer.0.到layer.4.开头的参数),训练后与「仅训练分类头」的 F1 对比。 - 将 SetFit 的
num_samples从 16 分别改为 8 和 32,其余不变,绘制三种设置下的 F1 分数曲线,验证少样本数量对性能的影响。 - 参考 11.6 节流程,把 CoNLL-2003 数据集换成
wnut_17,检查其标签集合与label2id的差异并调整映射后完成训练与评估。