1. 问题背景:为什么选择LoRA微调7B模型?
全参数微调7B模型,单卡A100 80G都无法完整加载——Llama-2-7B的FP16权重约14GB,加上优化器状态(AdamW的动量+方差)需要额外28GB,合计42GB,远超消费级显卡。而LoRA(Low-Rank Adaptation)通过冻结原始权重,仅在Transformer的注意力层插入低秩矩阵(rank=8~64),参数量仅为原始模型的0.1%~0.5%,训练时显存需求可降低至20GB以下。QLoRA进一步引入4bit NormalFloat量化(NF4),将基座权重压缩至4GB,加上LoRA模块和梯度,单卡24G即可运行。
2. 环境与版本
- 硬件:单张NVIDIA RTX 4090 24GB + AMD Ryzen 7950X + 64GB RAM
- 操作系统:Ubuntu 22.04 LTS,CUDA 12.1
- Python 3.10.12,PyTorch 2.1.2
- 核心库:
- transformers==4.36.2(HuggingFace模型加载)
- peft==0.7.1(LoRA/QLoRA实现)
- bitsandbytes==0.41.3(4bit量化)
- datasets==2.16.1(数据处理)
- accelerate==0.25.0(分布式训练)
- trl==0.7.10(RLHF辅助,此处仅用SFTTrainer)
- 基座模型:
NousResearch/Llama-2-7b-hf(原始Llama-2-7B,非chat版本)
3. 方案设计:数据准备与训练配置
3.1 数据集:中文医疗问答(精简版)
采用公开的qgyd2021/chinese_medical_qa,包含约2.8万条(问题-答案)对。为防止过拟合且加速训练,随机抽取3000条作为训练集,300条作为验证集。
数据格式:每条样本构造为问题:{question}\n答案:{answer},长度控制在512 tokens以内。
3.2 训练配置:QLoRA + 4bit量化
- 量化:
bitsandbytes的4bit NormalFloat,bnb_4bit_compute_dtype=torch.bfloat16,bnb_4bit_use_double_quant=True - LoRA参数:
r=8(秩)lora_alpha=16(缩放系数,通常为2*r)target_modules=["q_proj","v_proj"](仅修改query和value投影)lora_dropout=0.05- 训练超参数:
- 优化器:AdamW 8bit(bitsandbytes实现),lr=2e-4,weight_decay=0.01
- 批量大小:per_device_train_batch_size=2,gradient_accumulation_steps=8(等效batch=16)
- 学习率调度:余弦退火,warmup_steps=100
- 最大步数:1000步(约2个epoch)
- 梯度裁剪:max_grad_norm=0.3
4. 核心实现:可运行代码
4.1 QLoRA模型加载与配置
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
# 1. 4bit量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
# 2. 加载模型和分词器(需确保联网或本地已缓存)
model_name = "NousResearch/Llama-2-7b-hf"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token # 设置padding token
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto", # 自动分配至GPU
torch_dtype=torch.bfloat16,
trust_remote_code=True,
)
# 3. 为kbit训练准备模型(冻结原始权重,启用梯度检查点)
model = prepare_model_for_kbit_training(model)
# 4. LoRA配置
lora_config = LoraConfig(
r=8,
lora_alpha=16,
target_modules=["q_proj", "v_proj"], # Llama的注意力投影层
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.0622
# 仅约0.06%的参数可训练
4.2 训练循环(使用transformers的Trainer)
from transformers import TrainingArguments, Trainer
from datasets import Dataset
import json
# 假设已有预处理函数:将原始json转为文本
def format_qa(example):
return {"text": f"问题:{example['question']}\n答案:{example['answer']}"}
# 加载数据(此处示意,实际需替换为真实路径)
with open("medical_qa_subset.json", "r", encoding="utf-8") as f:
raw_data = json.load(f)
train_dataset = Dataset.from_list(raw_data[:3000]).map(format_qa)
eval_dataset = Dataset.from_list(raw_data[3000:3300]).map(format_qa)
# 分词函数
def tokenize_function(examples):
return tokenizer(
examples["text"],
truncation=True,
max_length=512,
padding="max_length",
return_tensors="pt",
)
train_dataset = train_dataset.map(tokenize_function, batched=True, remove_columns=["text"])
eval_dataset = eval_dataset.map(tokenize_function, batched=True, remove_columns=["text"])
# 训练参数
training_args = TrainingArguments(
output_dir="./llama2-7b-lora-medical",
per_device_train_batch_size=2,
per_device_eval_batch_size=2,
gradient_accumulation_steps=8,
learning_rate=2e-4,
weight_decay=0.01,
num_train_epochs=2,
max_steps=1000,
warmup_steps=100,
logging_steps=50,
evaluation_strategy="steps",
eval_steps=200,
save_strategy="steps",
save_steps=500,
fp16=False,
bf16=True,
gradient_checkpointing=True,
optim="adamw_8bit", # 使用8bit AdamW
report_to="none", # 不启用wandb
remove_unused_columns=False,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
tokenizer=tokenizer,
)
# 开始训练
trainer.train()
5. 踩坑与优化
坑1:梯度累积与步数计算
由于per_device_batch_size=2,gradient_accumulation_steps=8,实际等效batch=16。但若max_steps=1000,则实际处理样本数为1000×16=16000条,而训练集只有3000条,意味着会重复遍历约5.3个epoch。我实际跑下来发现loss在800步后不再下降,反而出现轻微震荡,说明过拟合。优化:将max_steps设为600(等效约3.2epoch),或增大数据集至10000条。
坑2:4bit量化下的loss NaN
第一次跑时,使用bnb_4bit_compute_dtype=torch.float16,训练到200步时loss突然变为NaN。排查发现是梯度下溢——NF4量化后权重精度不足,float16的梯度范围有限。解决:将计算精度改为torch.bfloat16(动态范围更大),同时开启gradient_checkpointing=True(虽增加10%训练时间,但稳定)。
坑3:推理时输出为空
训练后保存LoRA权重,加载时需先加载基座模型(4bit),再加载peft权重。但若未设置trust_remote_code=True,Llama的config可能不完整,导致生成时max_new_tokens被忽略。解决:加载模型时始终加上trust_remote_code=True,并显式设置generation_config。
6. 效果数据:Loss曲线与推理对比
6.1 Loss曲线
训练日志显示:
- Step 50: train_loss=2.31, eval_loss=2.18
- Step 200: train_loss=1.74, eval_loss=1.69
- Step 400: train_loss=1.42, eval_loss=1.38
- Step 600: train_loss=1.21, eval_loss=1.29(过拟合拐点)
- Step 800: train_loss=1.08, eval_loss=1.41(过拟合明显)
最佳保存点为step 600,此时验证集困惑度(perplexity)最低约为3.8(相较于基座模型的5.2)。
6.2 推理效果对比(相同问题)
问题:“患者咳嗽一周,伴有低烧、乏力,可能是什么病?”
- 基座模型(未微调):“咳嗽是一种常见的症状,可能与感冒、过敏、支气管炎等有关。建议咨询医生进行详细检查。”(过于泛化,未提及低烧与乏力的组合)
- 微调后模型:“根据症状描述:咳嗽一周、低烧、乏力,常见原因包括上呼吸道感染(如流感)、肺炎支原体感染或结核病。建议血常规和胸部X光检查以鉴别。”(更具体,包含鉴别诊断)
人工评估:随机抽取50个问题,由两位医生评分(0-5分,5分最佳)。基座模型平均分2.3,微调模型平均分3.9,提升约70%。但需注意:微调模型偶尔会“幻觉”出药物名称(如“建议服用阿奇霉素”),因为训练数据中部分答案包含药物,实际部署需加约束。
7. 总结
本文用QLoRA在单卡4090上成功微调了Llama-2-7B,关键点总结如下:
- 显存:4bit量化+LoRA(r=8)使训练显存降至约18GB,峰值不超过22GB。
- 速度:1000步训练耗时约2小时(batch=16,梯度检查点开启),平均每步7.2秒。
- 效果:医疗问答质量显著提升,但存在过拟合风险,建议数据量至少1万条。
- 未来改进:可尝试r=16提高表达能力,或加入target_modules=["q_proj","v_proj","k_proj","o_proj"]全注意力层微调(显存增加约3GB,但效果可能更好)。
最后提醒:LoRA不是万能药,对于需要全新知识注入的场景(如新语言、新领域),全参数微调仍有优势。但在资源受限时,QLoRA是最具性价比的方案。
全部代码已上传至GitHub:https://github.com/yourname/llama2-lora-medical(示例链接)