阶段三 · BERT 微调与意图识别

HuggingFace Transformers 实战

一句话总结

HuggingFace Transformers 是预训练模型的"标准库"--三行代码加载 BERT,十行代码启动训练.
本篇完整走一遍:加载预训练模型 → 准备数据集 → 微调训练 → 评估效果,在群聊数据上跑出第一个 BERT 分类器.

前置回顾

上一篇讲了 BERT 的原理:Transformer Encoder + MLM/NSP 预训练,用少量标注数据微调到具体任务. 本篇把理论落地--用 HuggingFace 库实际操作,体验从"下载模型"到"训练完成"的完整流程.

HuggingFace 生态概览

HuggingFace 不只是一个库,而是一整套生态:

组件 作用 Go 类比
transformers 模型加载,推理,训练 核心框架(如 Gin)
datasets 数据集加载和预处理 数据层(如 GORM)
tokenizers 高性能分词器(Rust 实现) 序列化工具(如 protobuf)
evaluate 指标计算(F1, Accuracy 等) 测试/基准工具
Model Hub 预训练模型仓库 包管理中心(如 pkg.go.dev)

安装:

pip install transformers datasets evaluate accelerate
pip install torch # PyTorch 后端

Tokenizer:文本到模型输入的桥梁

Tokenizer 负责把原始文本转换为模型能接受的数字序列. 阶段一讲过分词原理,现在看实际 API:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("hfl/chinese-roberta-wwm-ext")

text = "管理员禁言了发广告的用户"
encoded = tokenizer(text, return_tensors="pt")

print(encoded)
# {
# 'input_ids': tensor([[101, 绠, 理, 1447, 4850, 6241, 749, ...]]),
# 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, ...]]),
# 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, ...]])
# }

三个输出各自的含义:

字段 含义 值的范围
input_ids 每个 token 对应的词表 ID 0 ~ vocab_size
token_type_ids 属于第一句(0)还是第二句(1) 0 或 1
attention_mask 是真实 token(1)还是 padding(0) 0 或 1

处理不等长文本

一个 batch 中的文本长度不同,需要统一长度:

texts = [
"滚",
"管理员禁言了发广告的用户",
"今天天气真好适合出去玩"
]

encoded = tokenizer(
texts,
padding=True, # 短文本补 [PAD] 到最长文本的长度
truncation=True, # 超过 max_length 的截断
max_length=128, # 最大序列长度
return_tensors="pt" # 返回 PyTorch tensor
)

# attention_mask 的作用:
# "滚" → [1, 1, 1, 0, 0, 0, 0, 0, ...] ← 后面都是 padding
# "管理员..." → [1, 1, 1, 1, 1, 1, 1, 1, ...] ← 全是真实 token
# Attention 计算时,mask=0 的位置会被忽略,不影响结果

max_length 的选择

群聊消息通常很短(< 50 字,对应约 50-70 个 token). 设 max_length=128 能覆盖 99%+ 的消息. 设太大会浪费显存和计算量(Attention 的计算量和序列长度的平方成正比).

实际操作:先统计训练集的 token 长度分布,取 95 分位或 99 分位作为 max_length.

Model:加载预训练模型

对分类任务,用 AutoModelForSequenceClassification:

from transformers import AutoModelForSequenceClassification

model = AutoModelForSequenceClassification.from_pretrained(
"hfl/chinese-roberta-wwm-ext",
num_labels=3 # 分类数:对骂 / 擦边 / 正常
)

这行代码做了两件事:

  1. 下载并加载预训练的 BERT 权重(12 层 Transformer Encoder)
  2. 在 [CLS] 输出上自动接了一个 Linear(768, 3) 分类头
# 看模型结构的最后几层
print(model.classifier)
# Linear(in_features=768, out_features=3, bias=True)

# 参数量
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"总参数: {total:,}") # → 约 102,269,187
print(f"可训练: {trainable:,}") # → 全部可训练(微调模式)

快速推理测试

加载后可以直接推理(虽然分类头还是随机初始化,结果没意义):

import torch

inputs = tokenizer("你脑子有问题吧", return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)

logits = outputs.logits # shape: [1, 3]
probs = torch.softmax(logits, dim=-1)
print(probs) # → tensor([[0.33, 0.34, 0.33]])
# 分类头未训练,所以概率接近均匀分布--符合预期

准备数据集

用 HuggingFace datasets 库管理训练数据. 假设标注数据是 CSV 格式:

data/train.csv:
text,label
"你脑子有问题吧",0
"管理员帮忙踢一下广告",2
"废物一个",0
"今天活动几点开始",2
"你妈的滚出去",0
"这个功能有bug",2

label 映射: 0=对骂, 1=擦边, 2=正常
from datasets import load_dataset

dataset = load_dataset("csv", data_files={
"train": "data/train.csv",
"test": "data/test.csv"
})

print(dataset)
# DatasetDict({
# train: Dataset({features: ['text', 'label'], num_rows: 3000})
# test: Dataset({features: ['text', 'label'], num_rows: 500})
# })

Tokenize 整个数据集

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

tokenized_dataset = dataset.map(tokenize_function, batched=True)

# map 会自动给数据集加上 input_ids, attention_mask, token_type_ids 列
print(tokenized_dataset["train"].column_names)
# ['text', 'label', 'input_ids', 'attention_mask', 'token_type_ids']

mapbatched=True 参数让分词器一次处理一批文本,比逐条处理快 10-50 倍(底层 tokenizer 是 Rust 实现).

Trainer:一站式训练

HuggingFace 的 Trainer 类封装了训练循环,省去了手写 DataLoader, 优化器, 梯度更新等样板代码:

from transformers import TrainingArguments, Trainer
import evaluate
import numpy as np

metric = evaluate.load("f1")

def compute_metrics(eval_pred):
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
return metric.compute(
predictions=predictions,
references=labels,
average="macro" # 多分类用 macro F1
)

training_args = TrainingArguments(
output_dir="./results",
eval_strategy="epoch", # 每个 epoch 评估一次
save_strategy="epoch",
learning_rate=2e-5, # BERT 微调的经典学习率
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, # 训练结束加载最优 checkpoint
metric_for_best_model="f1",
)

trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset["train"],
eval_dataset=tokenized_dataset["test"],
compute_metrics=compute_metrics,
)

trainer.train()

训练输出大致长这样:

Epoch  Training Loss  Validation Loss  F1
1 0.4521 0.3102 0.7845
2 0.2103 0.2456 0.8523
3 0.1245 0.2301 0.8712

TrainingArguments 关键参数

  • learning_rate=2e-5:BERT 微调的安全区间是 2e-5 ~ 5e-5. 太大会灾难性遗忘
  • num_train_epochs=3:BERT 微调通常 2-4 个 epoch 就够. 过多会过拟合
  • per_device_train_batch_size:显存允许的情况下尽量大. V100 16GB 通常能跑 batch_size=32(max_length=128 时)
  • weight_decay=0.01:L2 正则化,防止过拟合
  • warmup_ratio=0.1:训练前 10% 的步数线性增长学习率,避免开始时梯度震荡

超参数调优实战

微调效果对超参数比较敏感. 以下是经过实验验证的推荐配置和调优策略:

学习率:最敏感的参数

学习率 特点 适用场景
1e-5 收敛慢,但稳定 数据量少(< 1000 条)
2e-5 最常用的默认值 多数场景表现良好
5e-5 收敛快,容易过拟合 数据量大(> 10000 条)
1e-4 几乎必定灾难性遗忘 不要用

建议第一轮用 1e-5, 2e-5, 3e-5, 5e-5 各跑 1 epoch,看 validation loss 趋势.

Batch Size 与显存的权衡

max_length=128 时的显存估算(BERT-base):batch_size 8 约 3GB(GTX 1060), 16 约 5GB(RTX 3060), 32 约 9GB(V100/RTX 3090), 64 约 16GB(V100 刚好).

batch size 太小的问题

batch_size < 8 时梯度噪声大,训练不稳定. 显存不够时优先降 max_length(群聊消息很短,64 通常够),其次用梯度累积(gradient_accumulation_steps=4 让等效 batch_size = 8×4 = 32).

Epoch 数:少即是多

BERT 微调过拟合很快. 观察 validation loss 的变化:

Epoch 1: val_loss 0.31 (下降中,继续)
Epoch 2: val_loss 0.24 (还在下降,继续)
Epoch 3: val_loss 0.23 (几乎不变,可以停了)
Epoch 4: val_loss 0.25 (开始上升 = 过拟合,应该早停)

load_best_model_at_end=True 会自动保留 validation 指标最好的那个 checkpoint.

评估与分析

训练完成后,详细评估模型表现:

from sklearn.metrics import classification_report

predictions = trainer.predict(tokenized_dataset["test"])
preds = np.argmax(predictions.predictions, axis=-1)
labels = predictions.label_ids

label_names = ["对骂", "擦边", "正常"]
print(classification_report(labels, preds, target_names=label_names))

输出示例:

              precision    recall  f1-score   support

对骂 0.89 0.91 0.90 180
擦边 0.72 0.68 0.70 120
正常 0.93 0.94 0.93 200

accuracy 0.86 500
macro avg 0.85 0.84 0.84 500
weighted avg 0.86 0.86 0.86 500

重点关注:

  1. 擦边类的 F1 最低(0.70):这是预期中的--"擦边"本身定义模糊,标注不一致性最高
  2. 对骂的 recall(0.91):漏放率 9%. 对管理系统来说,漏放比误杀更危险--可能需要调低判定阈值
  3. 正常的 precision(0.93):误杀率 7%. 这个数字要看业务容忍度

错误分析

挑出分错的 case 做定性分析:

wrong_indices = np.where(preds != labels)[0]
for i in wrong_indices[:10]:
print(f"文本: {dataset['test'][int(i)]['text']}")
print(f"真实: {label_names[labels[i]]}, 预测: {label_names[preds[i]]}")
print()

常见错误模式:

错误类型 示例 原因
反讽误判 "你可真行啊" 判为正常 缺少反讽标注样本
引用混淆 "他骂了一句废物" 判为对骂 模型没区分引用和直接表达
擦边模糊 "有病吧你" 在对骂和擦边之间 标注标准不统一

这些分析结果会指导下一步:补充特定类型的训练数据,或调整标注规范.

保存与加载

# 保存模型和 tokenizer
model.save_pretrained("./chat-moderation-bert")
tokenizer.save_pretrained("./chat-moderation-bert")

# 生成的文件:
# ./chat-moderation-bert/
# config.json ← 模型架构
# model.safetensors ← 权重(约 400MB)
# tokenizer_config.json ← 分词器配置
# vocab.txt ← 词表

# 加载回来
from transformers import AutoModelForSequenceClassification, AutoTokenizer

model = AutoModelForSequenceClassification.from_pretrained("./chat-moderation-bert")
tokenizer = AutoTokenizer.from_pretrained("./chat-moderation-bert")

模型文件体积

BERT-base 的 model.safetensors 约 400MB. 对比阶段二的传统方法(TF-IDF + LogisticRegression 模型文件只有几 MB),这是一个量级的差距. 阶段三最后一篇(第 14 篇)会讲如何通过蒸馏和量化把体积压到 100MB 以内.

快速回顾

  • Tokenizer:AutoTokenizer 处理分词,padding,截断. 群聊场景 max_length=128 足够
  • Model:AutoModelForSequenceClassification 自动在 BERT 上接分类头
  • Dataset:datasets 库的 map 方法高效批量 tokenize
  • Trainer:封装训练循环,关键参数是 learning_rate=2e-5, epochs=3, batch_size=16
  • 评估:关注 macro F1,做错误分析找到标注和数据的改进方向
  • 保存:模型文件约 400MB,后续通过蒸馏/量化压缩

动手练习

  1. 跑通训练:用上面的完整脚本,准备至少 500 条标注数据(可以用第 6 篇的种子数据扩充),跑一次完整的训练-评估流程
  2. 学习率实验:分别用 1e-5, 2e-5, 5e-5 训练,画出三条 validation loss 曲线,找到最佳学习率
  3. max_length 影响:统计训练集的 token 长度分布,尝试 max_length=64 和 max_length=128,对比 F1 差异
  4. 错误分析报告:挑出 20 个分错的 case,按错误类型分类,写出改进建议(补充数据?调整标注规范?加规则后处理?)
  5. 对比 baseline:把 BERT 微调的 F1 和第 7 篇 TF-IDF + LogisticRegression 的 F1 对比,量化深度模型带来的提升