一、问题背景与方案选型

任务是对医疗术语进行专业问答,基座模型(Qwen2-7B)在“阿托伐他汀钙片的用法用量”这类问题上经常输出错误剂量。直接全量微调需要4张A100,预算不允许。我测试了两种方案:

  1. LoRA(秩8):可训练参数4.2M,显存需求12G,8G卡会OOM
  2. QLoRA(4bit+NF4量化):可训练参数4.2M,显存需求6.2G,8G卡能跑

最终选用QLoRA,因为:a)量化后显存占用降低47%;b)通过prepare_model_for_kbit_training保持全量参数可训练;c)实测推理速度比全量微调快1.8倍。

二、环境与版本锁定

torch==2.1.2
transformers==4.36.2
peft==0.7.1
bitsandbytes==0.41.3
datasets==2.16.1
accelerate==0.25.0

注意:bitsandbytes必须用0.41.3以上,否则4bit加载会报CUDA error: no kernel image available。CUDA版本11.8,驱动535.104.05。

三、数据准备与清洗策略

原始数据是爬虫抓取的药品说明书,质量惨不忍睹。我写了三个清洗规则:

def clean_text(text):
    # 规则1:去除空行和全角空格
    text = re.sub(r'[ \t\u3000]+', ' ', text)
    # 规则2:统一剂量单位
    text = text.replace('mg/kg', 'mg每千克').replace('tid', '每日三次')
    # 规则3:截断超过512字符的样本(保留完整句子)
    if len(text) > 512:
        cut_pos = text.rfind('。', 0, 500)
        text = text[:cut_pos + 1]
    return text

清洗后得到12,847条QA对,按8:1:1切分训练/验证/测试集。每条样本格式为:

用户:。请根据药品说明书回答。
助手:

去重时发现一个问题:不同药厂对同一种药品的剂量写法不一致(如“一日一次”vs“qd”),我用正则统一了17种常见医学简写。

四、QLoRA核心配置与训练实现

关键参数配置如下:

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import BitsAndBytesConfig

# 4bit量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16
)

# LoRA配置
lora_config = LoraConfig(
    r=8,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.1,
    bias="none",
    task_type="CAUSAL_LM"
)

# 加载模型
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2-7B",
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16
)
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)

训练超参数:

  • per_device_train_batch_size=1,梯度累积16步(等效batch=16)
  • 学习率2e-4,使用AdamW优化器,warmup_ratio=0.03
  • 训练3个epoch,共2,409步
  • 序列长度512,gradient_checkpointing=True

踩坑记录:第一次训练到第800步时loss突然变成NaN。排查发现是某个样本包含特殊字符“Ⅳ”(罗马数字),tokenizer无法处理。解决方案:在清洗函数中加入text.encode('utf-8', errors='ignore').decode('utf-8'),并过滤掉包含``的样本。

五、Loss曲线分析与调参记录

训练日志显示loss变化如下:

Epoch 1: 1.847 → 1.321 → 1.082
Epoch 2: 1.079 → 0.921 → 0.835  
Epoch 3: 0.831 → 0.764 → 0.712

验证集loss在第2个epoch后下降变缓,我做了两个调整:

  1. 学习率衰减:改用cosine调度器,从2e-4衰减到2e-5,验证loss从0.83降到0.79
  2. 增加lora_dropout到0.15:虽然训练loss略升(0.78),但测试集BLEU分数从42.3提升到44.1

最终训练时间:单卡RTX 3080(8G显存),耗时3小时12分钟。显存峰值监控:6.2G(训练)+ 0.8G(验证),没有OOM。

六、推理效果对比与量化部署

测试集评估(300条样本):

指标 基座Qwen2-7B QLoRA微调后
医疗术语准确率 54.7% 85.3%
剂量单位正确率 38.2% 76.9%
平均回答长度 87字符 124字符
幻觉率(人工抽查) 22% 9%

典型对比:

用户:阿托伐他汀钙片每日最大剂量是多少?
基座:通常为80mg,但需根据患者情况调整(错误,实际最大80mg/日)
微调:成人推荐剂量10-80mg/日,最大剂量80mg/日。严重肾功能不全患者不应超过20mg/日。

部署时将LoRA权重与基座合并(model.merge_and_unload()),导出为fp16格式,显存占用4.3G,推理延迟从0.89s缩短到0.51s。如果还想压缩,可以用torch.quantization进一步转int8,但精度会掉2-3%。

七、总结与反思

这次实践的核心收获:

  1. QLoRA在8G卡上微调7B模型完全可行,关键是4bit量化+gradient checkpointing双管齐下
  2. 数据清洗比模型参数更重要:我花了2天清洗数据,但效果提升远超调整LoRA秩(r=8→16只提升1.2%)
  3. loss曲线要结合业务指标看:训练loss 0.7左右时测试集表现最好,再降反而过拟合

后续优化方向:尝试rsLoRA(秩自适应)+ DoRA(权重分解)组合,预计能在同样显存下再提升5%准确率。另外准备用vLLM做推理加速,目标延迟降到300ms以内。