前言
Agent开发中,意图识别是Agent的关键节点,实践中往往使用BERT模型进行分类,以降低意图分类环境的延迟/耗时。
本文简要介绍基于LoRA的BERT模型的训练。
PEFT和LoRA介绍
EFT和PEFT
EFT(Efficient Fine-Tuning) 是"高效微调"的统称,指所有在预训练大模型上只更新少量参数(或插入少量新参数)的微调方法,目的是节省显存、加速训练、方便存储和切换任务。它不是某一种具体技术,而是一类方法。
PEFT(Parameter-Efficient Fine-Tuning) 是 EFT 中最主流的一类,即"参数高效微调"。HuggingFace 甚至有一个同名的库 peft 专门集成这些方法。核心思想是:冻结大部分预训练参数,只训练极少量额外参数或选择性地微调极少数原参数。
LoRA
LoRA(Low-Rank Adaptation):目前最火的 PEFT 方法。
全参数微调需要为每个下游任务保存一份完整的大模型副本(比如 BERT-base 约 110M 参数)。对 Agent 来说,可能要同时服务多个意图模型,存储和切换成本很高。LoRA 通过低秩分解,使得每个任务只需要额外保存几 MB 的参数。
LoRA 核心思想
对于预训练模型中的一个权重矩阵 W ∈ R^d×k ,LoRA 假设它的更新量 ΔW 是低秩的,可以分解为两个小矩阵的乘积:
ΔW = B A
其中 B ∈ R^d×r,A ∈ R^r×k,且秩 r ≪ min(d, k)。
前向计算变为:
h = W₀x + ΔW x = W₀x + B A x
训练时,W₀ 被冻结,只训练 A 和 B。秩 r 通常取 8、16 等,因此额外参数量极小。
训练和评估集
训练前需要先准备训练集和评估集,text表示输入,label表示意图类别
train.jsonl
jsonl
{"id": "kq-0001", "text": "CS模式的联仿通信接口模型只执行一步就停止时,应如何调整步长?", "label": "knowledge_question"}
{"id": "sm-0120", "text": "降低 CosineWave 的幅值", "label": "simulation_message"}
{"id": "sr-0024", "text": "帮我创建一个换热器旁通阀温度控制仿真", "label": "simulation_request"}
eval.jsonl
jsonl
{"id": "sm-0030", "text": "多项式改成二次项主导", "label": "simulation_message"}
{"id": "kq-0029", "text": "什么是FMU,它主要描述哪类系统?", "label": "knowledge_question"}
{"id": "sr-0027", "text": "帮我创建一个注塑机合模保压时序仿真", "label": "simulation_request"}
训练集至少要有100条,并尽可能保证各类别均衡分布,训练集的数量直接影响模型训练效果。
训练过程简要代码
训练过程核心代码就是通过get_peft_model获取PEFT模型,然后使用Trainer训练,通过Trainer的回调函数compute_metrics进行评估
python
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
from datasets import DatasetDict, load_dataset
from peft import LoraConfig, TaskType, get_peft_model
from sklearn.metrics import accuracy_score, f1_score, precision_recall_fscore_support
from transformers import (
AutoModelForSequenceClassification,
AutoTokenizer,
Trainer,
TrainingArguments,
set_seed,
)
# ---------- 1. 数据集 ----------
INTENT_LABELS: tuple[str, ...] = (
"knowledge_question",
"simulation_request",
"simulation_message",
)
label2id: dict[str, int] = {label: idx for idx, label in enumerate(INTENT_LABELS)}
id2label: dict[int, str] = {idx: label for idx, label in enumerate(INTENT_LABELS)}
# ---------- 2. 加载模型和分词器 ----------
def build_tokenizer():
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.sep_token or tokenizer.eos_token
return tokenizer
def build_base_classifier():
model = AutoModelForSequenceClassification.from_pretrained(
BASE_MODEL,
num_labels=len(INTENT_LABELS),
id2label=id2label,
label2id=label2id,
problem_type="single_label_classification",
)
if getattr(model.config, "pad_token_id", None) is None:
model.config.pad_token_id = model.config.eos_token_id
return model
def build_peft_model():
tokenizer = build_tokenizer()
base = build_base_classifier()
if base.config.pad_token_id is None and tokenizer.pad_token_id is not None:
base.config.pad_token_id = tokenizer.pad_token_id
try:
task_type = TaskType[LORA_TASK_TYPE]
except KeyError as exc:
raise ValueError(f"Unsupported LoRA task_type: {LORA_TASK_TYPE!r}") from exc
peft_model = get_peft_model(
base,
LoraConfig(
...
),
)
peft_model.print_trainable_parameters()
return peft_model, tokenizer
# ---------- 3. 数据编码 ----------
def prepare_datasets(tokenizer):
raw = load_dataset(
"json",
data_files={"train": str(TRAIN_FILE), "validation": str(EVAL_FILE)},
)
def tokenize_batch(batch):
encoded = tokenizer(
batch[TEXT_COLUMN],
truncation=True,
padding="max_length",
max_length=MAX_LENGTH,
)
encoded["labels"] = [label2id[label] for label in batch[LABEL_COLUMN]]
return encoded
return DatasetDict({
name: split.map(tokenize_batch, batched=True, remove_columns=split.column_names)
for name, split in raw.items()
})
# ---------- 4. 评估 ----------
def compute_metrics(eval_pred) -> dict[str, float]:
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
precision, recall, f1, _ = precision_recall_fscore_support(
labels,
predictions,
average=None,
labels=list(range(len(INTENT_LABELS))),
zero_division=0,
)
metrics: dict[str, float] = {
"accuracy": float(accuracy_score(labels, predictions)),
"f1_macro": float(f1_score(labels, predictions, average="macro", zero_division=0)),
"f1_weighted": float(
f1_score(labels, predictions, average="weighted", zero_division=0)
),
}
for index, label in enumerate(INTENT_LABELS):
metrics[f"precision_{label}"] = float(precision[index])
metrics[f"recall_{label}"] = float(recall[index])
metrics[f"f1_{label}"] = float(f1[index])
return metrics
# ---------- 5. Main ----------
def main() -> None:
set_seed(SEED)
model, tokenizer = build_peft_model()
datasets = prepare_datasets(tokenizer)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
training_args = TrainingArguments(
...
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=datasets["train"],
eval_dataset=datasets["validation"],
processing_class=tokenizer,
compute_metrics=compute_metrics,
)
train_result = trainer.train()
trainer.save_model(str(OUTPUT_DIR))
tokenizer.save_pretrained(str(OUTPUT_DIR))
metrics = dict(train_result.metrics)
metrics.update(trainer.evaluate())
label_meta = {
"labels": list(INTENT_LABELS),
"base_model": BASE_MODEL,
"max_length": MAX_LENGTH,
"lora": {
//lora 配置
},
}
(OUTPUT_DIR / "label_config.json").write_text(
json.dumps(label_meta, ensure_ascii=False, indent=2),
encoding="utf-8",
)
(OUTPUT_DIR / "train_metrics.json").write_text(
json.dumps(metrics, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(json.dumps({"output_dir": str(OUTPUT_DIR), "metrics": metrics}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
关键训练参数
LoRA参数
ini
LoraConfig(
r=rank,秩,
lora_alpha=缩放系数 α,
lora_dropout=LoRA 分支上的 dropout 概率,
task_type=指定任务类型枚举来自`peft.TaskType`,TaskType.SEQ_CLS表示意图分类,
target_modules=指定哪些层注入 LoRA(最重要配置项),
modules_to_save=额外需要完整训练、保存的模块,
),
训练参数
ini
TrainingArguments(
output_dir=模型、checkpoint 保存文件夹,
num_train_epochs=训练轮次;小数据集 3‑5 轮,大数据集 1‑3 轮,过大容易过拟合,
per_device_train_batch_size=每张 GPU 训练 batch 大小;显存不足调小为 8/4,
per_device_eval_batch_size=验证集 batch,
learning_rate=学习率;BERT 全参:1e‑5 ~ 3e‑5;LoRA:1e‑4 ~1e‑3。学习率太大震荡,太小收敛慢,
weight_decay=权重衰减,L2 正则,抑制过拟合,一般 0.01;不对 bias、LayerNorm 生效,
warmup_steps=warmup_steps,
logging_steps=每多少 step 打印 loss 日志,
eval_strategy=epoch每轮结束验证;steps按步数验证;no不验证,
save_strategy=什么时候保存 checkpoint,epoch 每轮存,
load_best_model_at_end=训练结束自动加载验证集指标最好的 checkpoint,防止过拟合
and "validation" in datasets,
metric_for_best_model=cfg.training.metric_for_best_model,
greater_is_better=cfg.training.greater_is_better,
seed=cfg.training.seed,
fp16=混合精度训练,开启后减半显存,GPU 支持就打开,
bf16=cfg.training.bf16,
gradient_accumulation_steps=cfg.training.gradient_accumulation_steps,
max_grad_norm=cfg.training.max_grad_norm,
report_to=关闭 wandb 日志,避免报错,
remove_unused_columns=False,
)
训练结果和解读
比如某次训练结果如下:
json
{
"metrics": {
"train_runtime": 126.5603,
"train_samples_per_second": 4.836,
"train_steps_per_second": 0.316,
"total_flos": 40396773482496.0,
"train_loss": 1.0207823753356933,
"epoch": 2.0,
"eval_loss": 0.8103731870651245,
"eval_accuracy": 0.8703703703703703,
"eval_f1_macro": 0.8697691197691197,
"eval_f1_weighted": 0.8697691197691197,
"eval_precision_knowledge_question": 1.0,
"eval_recall_knowledge_question": 0.9444444444444444,
"eval_f1_knowledge_question": 0.9714285714285714,
"eval_precision_simulation_request": 0.8666666666666667,
"eval_recall_simulation_request": 0.7222222222222222,
"eval_f1_simulation_request": 0.7878787878787878,
"eval_precision_simulation_message": 0.7727272727272727,
"eval_recall_simulation_message": 0.9444444444444444,
"eval_f1_simulation_message": 0.85,
"eval_runtime": 2.1105,
"eval_samples_per_second": 25.587,
"eval_steps_per_second": 0.948
}
}
以上指标分为训练侧(train开头)和评估侧(eval开头),训练侧主要关注train_loss,一般和eval_loss一起比较查看是否过拟合。
重点关注验证侧以下指标:
- eval_loss :验证集交叉熵损失。本案例中对比 train_loss=1.02:验证损失更低,不存在过拟合;甚至可以尝试增加 epoch 继续训练,看是否还能涨精度。
- eval_accuracy:验证集准确率。
- eval_f1_macro : Macro‑F1 ,每个类别 F1 取算术平均,不考虑样本数量。适合类别不均衡场景,每个意图同等权重。
- eval_f1_weighted :Weighted‑F1 :每个类别 F1 按该类样本数量加权平均。样本多的类别权重更高。 本案例中 macro 和 weighted 几乎相等,说明:验证集各个意图类别样本分布比较均衡。如果类别严重不均衡,weighted 和 macro 会差距很大。
分类效果判断
分类效果有以下三种指标:
- P(Precision,精确率):模型预测成某一类时,有多少是对的。
→ "判成这个意图有多靠谱?" 高 = 假阳性少。 - R(Recall,召回率):真实属于某一类时,有多少被找出来。
→ "这个意图有多容易漏?" 高 = 假阴性少。 - F1:P 和 R 的调和平均,兼顾两者。
→ 一类的综合表现;只看准确率不够时,常用 F1。
| 类别 | P | R | F1 | 解读 |
|---|---|---|---|---|
| knowledge_question | 1.00 | 0.94 | 0.97 | 几乎不误报;偶尔漏判问答 |
| simulation_request | 0.87 | 0.72 | 0.79 | 相对最弱:创建仿真有时被漏掉(可能判成改方案或问答) |
| simulation_message | 0.77 | 0.94 | 0.85 | 很少漏掉改参/替换,但有时会把别的也判成 message |
过拟合
通俗讲就是 "死记硬背标准答案,导致考试时题目一变形就懵了"。通过评估集合训练集的对比,我们可以判断模型是否存在过拟合。
| 现象 | 含义 |
|---|---|
| train 很好,eval 也差不多 | 正常/略欠拟合 |
| train 很好,eval 明显差,且差距随 epoch 拉大 | 过拟合 |
| train、eval 都差 | 欠拟合 / 数据不够 / 标签噪声 |
简单来说,train 远好于 eval,且 eval 随训练变差 = 过拟合
训练产物
训练产物 outputs/intent-macbert-lora/ 是个 LoRA 适配器,其关键产物是adapter_config.json和adapter_model.safetensors
python
outputs/ 目录结构
outputs/
└── intent-macbert-lora/ # 主输出目录(训练产物)
├── README.md # 训练元数据说明
├── adapter_config.json # LoRA 配置
├── adapter_model.safetensors # LoRA 权重,旧版本是adapter_model.bin
├── label_config.json # 标签元数据(labels / base_model / lora 配置)
├── tokenizer.json # tokenizer 词表
├── tokenizer_config.json # tokenizer 元数据
├── train_metrics.json # 训练/评估指标汇总
├── training_args.bin # TrainingArguments pickle
├── checkpoint-5/ # 中间 checkpoint(epoch/step)
├── checkpoint-10/
├── checkpoint-20/
├── checkpoint-40/
└── checkpoint-60/
checkpoint-N是训练过程中的中间快照,每 save_strategy间隔保存一次,主要用于继续训练:
ini
trainer.train(resume_from_checkpoint="./output/checkpoint‑100")
模型合并
LoRA 实际上把模型拆分成了两部分:
- 底座模型
W₀(原来的 BERT 权重) - 任务相关的增量
ΔW = B A(你刚训练出的 adapter)
直接部署,运行时须按顺序加载 base model + adapter 两份资源
ini
base = AutoModelForSequenceClassification.from_pretrained(BASE_MODEL)
model = PeftModel.from_pretrained(base, ADAPTER_DIR)
运行时,模型底座参数不变,计算时会额外算一次小矩阵算法。
这种部署方式这本质上是将adapter作为插件,即插即用,如果有多种训练,可以随时更换这个adapter。
当然也能合并为一个完整的、标准的 HuggingFace 模型
makefile
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./intent_merged_model")
tokenizer.save_pretrained("./intent_merged_model")
生产环境一般部署合并后的模型。
模型运行
可以用pipeline运行,再套上FastApi,就可以提供服务了。
ini
from transformers import pipeline
classifier = pipeline(
"text-classification",
model="./intent_merged_model",
tokenizer="./intent_merged_model",
device=0 if torch.cuda.is_available() else -1 # 自动用 GPU 或 CPU
# Mac MPS 暂时不能直接填 "mps",填 -1 会用 CPU;想用 MPS 参考方法一
)
result = classifier("今天天气怎么样")
print(result)
# 输出示例: [{'label': 'weather', 'score': 0.97}]
也可以手动写预处理和后处理,
python
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
# 加载模型和分词器(和加载任何一个微调好的模型完全相同)
model_path = "./intent_merged_model"
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForSequenceClassification.from_pretrained(model_path)
# 如果有 GPU / MPS,挪过去
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
model.to(device)
model.eval()
# 推理函数
def predict(text):
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=64)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model(**inputs)
logits = outputs.logits
pred_id = torch.argmax(logits, dim=-1).item()
label = model.config.id2label[pred_id]
confidence = torch.softmax(logits, dim=-1)[0][pred_id].item()
return {"intent": label, "confidence": confidence}