LoRA微调BERT模型实践

前言

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_macroMacro‑F1 ,每个类别 F1 取算术平均,不考虑样本数量。适合类别不均衡场景,每个意图同等权重。
  • eval_f1_weightedWeighted‑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}
相关推荐
精彩AI1 小时前
昆明 AI 营销数字化服务体系,如何解决数字化转型各类难题?
大数据·人工智能
0566461 小时前
RAG 向量检索:从“查字“到“查意“
数据库·人工智能·学习·oracle
断眉的派大星1 小时前
CLIP原理详解:图像与文本的跨模态学习
人工智能·深度学习·机器学习
码云之上1 小时前
Loop Engineering:把模型调用变成可控的多步任务
人工智能·前端框架·前端工程化
未来智慧谷1 小时前
从 Atlas 关停复盘:AI 应用的三种载体形态怎么选(独立应用 / 浏览器扩展 / 桌面宿主)
前端·人工智能·ai·架构·浏览器
dogstarhuang1 小时前
Kimi K3 本地部署实战:从 1.56TB 权重到推理服务的完整成本分析
java·人工智能·后端·ai·开源·接口·程序员创富
去伪存真20251 小时前
数字工厂与产线孪生的平台能力深度拆解
大数据·运维·人工智能·机器人
人工智能培训1 小时前
中小企业低成本落地AI工具实操方案
大数据·人工智能·gpt·机器学习·agi
胡耀超1 小时前
AI出事后,怎么查?——读《AI Forensics》
人工智能·python·数字取证·ai取证·