一、为什么要微调,以及为什么选LoRA
先说场景。我手头有个垂域的问答任务,模型需要按固定格式输出结构化结果,同时要理解一批领域术语。直接拿Qwen2.5-7B-Instruct跑,格式经常飘,术语也理解不到位,准确率只有43%左右。试过prompt engineering,把few-shot示例塞到8个,准确率能到58%,但推理成本上去了,而且长尾case还是不行。
全量微调7B模型?单卡4090想都别想,光是优化器状态和梯度就要吃掉70GB以上。所以LoRA是自然选择——只训练低秩旁路矩阵,参数量从7B降到几千万级别,显存和存储都友好。QLoRA更进一步,把基座模型量化成4bit,冻结权重显存直接砍到1/4,代价是训练速度略慢、精度有极小损失,但对24GB卡来说是刚需。
我最终用的是QLoRA(NF4量化)+ LoRA适配器组合。
二、环境与版本
环境这块我踩过版本坑,先把确定能跑通的组合列出来:
Python 3.10.13
torch 2.3.1 + cu121
transformers 4.44.2
peft 0.12.0
bitsandbytes 0.43.3
accelerate 0.33.0
datasets 2.21.0
trl 0.9.6
GPU: RTX 4090 24GB
重点提醒:bitsandbytes 和 torch 的 CUDA 版本必须匹配,我一开始装了cu118的torch配0.43.3的bnb,加载4bit模型直接报 CUDA error: no kernel image is available。换成cu121就好了。另外peft 0.12.0对Qwen2的支持是OK的,再老的版本会有 target_modules 匹配不到的问题。
三、方案设计
整体思路:
- 数据格式统一成Alpaca风格的instruction/input/output,用chat template包一层。
- 基座加载时用
BitsAndBytesConfig做NF4双重量化。 - LoRA挂在q_proj、k_proj、v_proj、o_proj、gate_proj、up_proj、down_proj上——注意,只挂attention的q/v是很多教程的做法,但对垂域任务,MLP层也参与知识存储,实测挂全了效果更好。
- 训练用trl的SFTTrainer,配合cosine学习率、warmup 3%。
关键超参:
lora_r = 16
lora_alpha = 32
lora_dropout = 0.05
learning_rate = 2e-4
num_train_epochs = 3
per_device_train_batch_size = 2
gradient_accumulation_steps = 8 # 等效batch size 16
max_seq_length = 1024
optim = paged_adamw_8bit
lr_scheduler = cosine
warmup_ratio = 0.03
bf16 = True
等效batch size 16对1.2万条数据来说,一个epoch约750步,3个epoch约2250步,4090上大概2小时40分跑完。
四、核心实现
4.1 数据准备
数据是自建的1.2万条问答对,JSONL格式,每条长这样:
{"instruction": "根据以下病历摘要提取诊断和用药", "input": "患者男,58岁,主诉...", "output": "诊断:2型糖尿病\n用药:二甲双胍 0.5g bid"}]
用chat template转换,这里要注意Qwen2.5用的是自己的template,别用默认的:
from datasets import load_dataset
from transformers import AutoTokenizer
model_path = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right" # 训练必须right padding
def format_sample(sample):
messages = [
{"role": "system", "content": "你是一个专业的医疗信息抽取助手,严格按格式输出。"},
{"role": "user", "content": sample["instruction"] + "\n" + sample["input"]},
{"role": "assistant", "content": sample["output"]},
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
return {"text": text}
dataset = load_dataset("json", data_files="train.jsonl", split="train")
dataset = dataset.map(format_sample, remove_columns=dataset.column_names)
dataset = dataset.train_test_split(test_size=0.02, seed=42)
padding_side 一定要设成right,left padding训练时会算错loss,这个坑我后面细说。
4.2 加载4bit基座 + 配置LoRA
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
model_path,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
attn_implementation="flash_attention_2",
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False # 训练时必须关,否则和gradient checkpointing冲突
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出:trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.5273
可训练参数约4037万,占比0.53%,显存和存储都极其友好——最终adapter文件才160MB左右。
4.3 训练
from transformers import TrainingArguments
from trl import SFTTrainer
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-lora-med",
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
report_to="tensorboard",
max_grad_norm=0.3,
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora-med/final")
跑起来后nvidia-smi显示峰值显存18.7GB,还有余量,说明batch size还能再往上提一点。
五、踩坑与优化
坑1:loss从第一步就是0或者nan。 原因是padding_side设成了left,且没有正确处理label的padding。改成right padding后正常。
坑2:target_modules只挂q_proj/v_proj,效果明显差。 我做了对比实验,只挂q/v时准确率到78%,挂全attention+MLP到86%。垂域知识确实存在MLP里。
坑3:学习率2e-4一开始震荡。 前50步loss在1.5~2.1之间跳,把max_grad_norm从1.0降到0.3,warmup提到3%后平稳了。LoRA的学习率可以比全量微调大(因为只更新低秩矩阵),但也不是越大越好。
坑4:save的时候adapter和基座混在一起。 用 trainer.save_model() 存的是adapter,推理时要先load基座再load adapter,别指望直接from_pretrained那个目录能跑。
六、效果数据
6.1 loss曲线
训练loss从初始的1.82稳步下降,第1个epoch结束到0.94,第2个epoch到0.72,第3个epoch收敛到0.61。验证loss最低点出现在第2.7个epoch附近(0.68),第3个epoch略有回升到0.70,说明稍微过拟合了一点点,但幅度很小,可以接受。整体曲线没有明显震荡,cosine scheduler的尾部平滑下降特征很明显。
6.2 推理效果对比
测试集500条,逐条人工核对。同一个输入,基线模型和微调后模型对比:
输入: 患者女,45岁,反复咳嗽3月,夜间加重,既往过敏性鼻炎史。胸片未见异常。
基线Qwen2.5-7B-Instruct输出:
根据描述,患者可能患有咳嗽变异性哮喘,建议进一步检查,如支气管激发试验。用药方面可考虑吸入性糖皮质激素。
微调后输出:
诊断:咳嗽变异性哮喘(待排)
鉴别:过敏性鼻炎相关咳嗽
建议检查:支气管激发试验、FeNO
用药:布地奈德福莫特罗吸入剂 1吸 bid
格式完全对齐训练数据,还补了鉴别和具体用药。这就是微调的价值——不是让模型更聪明,而是让它更"听话"、更符合业务格式。
准确率统计:
| 指标 | 基线 | few-shot(8) | LoRA微调 |
|---|---|---|---|
| 格式合规率 | 61% | 89% | 99.2% |
| 任务准确率 | 43% | 58% | 86% |
| 平均推理延迟 | 1.9s | 3.4s | 1.9s |
延迟没变,因为adapter可以merge进基座,推理时零额外开销。
七、总结
LoRA/QLoRA在7B模型上做垂域微调,单卡24GB完全够用,2~3小时能跑完一轮完整实验。几个核心经验:数据格式比超参更重要,target_modules别偷懒只挂q/v,padding_side和学习率是最容易翻车的地方。如果你也在做类似的垂域适配,建议先用500条数据跑一个epoch验证pipeline,通了再上全量数据,能省不少时间。