1、核心知识点(VIP)
(1)PEFT LoRA插入target modules的方法
它不会修改磁盘上原始模型代码/权重文件,全部是运行时内存层面动态修改模型对象。
对于q_proj、k_proj、v_proj、o_proj,原始是普通nn.Linear,比如:
python
q_proj = nn.Linear(hidden_size, num_heads * head_dim)
磁盘上的模型文件 (.bin/.safetensors) 保存的就是这些 Linear 的 weight、bias。
对于get_peft_model(model, lora_config)操作,
PEFT 内部逻辑:
- 遍历模型所有子模块,递归寻找名字匹配
target_modules的层(q_proj/k_proj/v_proj/o_proj) - 把原来的
torch.nn.Linear对象,用一个LoraLinear包装类原地替换掉(内存替换) - 保存原始 Linear 权重不改动、冻结 ;新增两套小矩阵:
lora_A、lora_B。
python
内存中替换过程:
原来: q_proj = nn.Linear(in_dim, out_dim)
替换后: q_proj = LoraLinear( 原始的nn.Linear(冻结), lora_A, lora_B )
前向传播同时跑两路:基座输出 + LoRA 分支输出相加。
反向传播只更新 A、B;基座 Wbase 梯度关闭,不更新。
(2)CausalLM交叉熵Loss计算流程
-
因果LLM训练,模型每个位置输出词表维度logits
-
内部自动错位,用第 i 位置logits预测 i+1 的真实token
-
对每个位置,先对该位置完整词表logits做softmax归一化,得到词表上的概率分布,取出真实标签对应的预测概率
-
每个有效token计算负对数似然
,labels=-100的token直接跳过不参与计算
-
整个batch把所有有效token的loss求和,除以batch全部有效token总数得到最终标量loss
-
loss.backward回传梯度更新LoRA参数
-
训练阶段不采样生成token,直接基于logits概率计算交叉熵
(3)采样/解码方法
- 贪心搜索:每一步直接选取当前概率最大 token,速度快,输出容易重复;
2.beam‑search 束搜索,维护 num_beams 条候选序列,选择整体概率最优序列,输出通顺但多样性不足;
- 随机采样 基于 softmax 概率做随机采样;temperature 控制随机程度,top‑p 核采样保留累积概率 p 的候选集,是对话模型主流解码方式。
(4)SFT交叉熵逐token计算的问题
在 PEFT 做 SFT 训练时,交叉熵是逐 token 计算负对数似然。假如模型输出的回答和真实标签 ground‑truth 语义相近,但句式、表达方式差别很大,token 序列并不相同,是不是就会产生很大的 loss?为什么会出现这种现象,该问题如何缓解?
【回答】
PEFT‑SFT 用的是 token‑level 交叉熵损失,只比对 token ID,不理解语义。即使模型输出语义接近、表达方式差异很大,只要 token 序列和 ground‑truth 不一致,对应位置负对数损失就会很大。
这是 SFT 天然短板:训练阶段强制字面对齐,但推理阶段通过采样可以输出同义改写。SFT loss 代表对训练集文本的拟合程度,不等于语义效果;loss 太低容易发生过拟合背诵原文。
RLHF/DPO 偏好对齐就是用来缓解该问题:不再把单条 ground‑truth 当作唯一标准答案,而是学习回答之间相对优劣,容纳多种合理表达方式。注意 DPO 依旧基于 token 概率,不是直接计算语义向量相似度。
2、环境准备(AutoDL)
1)modelscope相关
pip install modelscope
下载Qwen2.5-7B-Instruct模型
modelscope download --model Qwen/Qwen2.5-7B-Instruct
2)安装相关依赖包
pip install transformers peft accelerate datasets trl bitsandbytes sentencepiece wandb
3)测试qwen2.5模型
python
from modelscope import AutoModelForCausalLM, AutoTokenizer
model_name = "Qwen/Qwen2.5-7B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype="auto",
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
def chat_infer(messages, max_new_tokens=512, temperature=0.7, top_p=0.8):
"""
:param messages: 对话list [{"role":"system/user/assistant", "content":str}]
:return: model回复字符串
"""
# 套用模型对话模板,推理务必 add_generation_prompt=True
text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
print(f"\n==== 模型输入完整模板文本 ====\n{text}\n==== 模型输出 ====\n")
model_inputs = tokenizer([text], return_tensors="pt").to(model.device)
input_len = model_inputs.input_ids.shape[1]
generated_ids = model.generate(
**model_inputs,
max_new_tokens=max_new_tokens,
temperature=temperature,
top_p=top_p,
do_sample=True,
)
# 切掉输入部分,只保留新生成token
output_ids = generated_ids[:, input_len:]
response = tokenizer.batch_decode(output_ids, skip_special_tokens=True)[0]
return response
if __name__ == "__main__":
prompt = "Give me a short introduction to large language model."
messages = [
{"role": "system", "content": "You are Qwen, created by Alibaba Cloud. You are a helpful assistant."},
{"role": "user", "content": prompt}
]
resp = chat_infer(messages)
print(resp)
显示结果:
3、准备SFT数据集
数据示例:
python
{"instruction": "你好,医生,我不知道自己怀孕的情况下,感冒了输了利巴韦林和头孢,我怀孕有四十多天了,请问我可以要这小孩吗?", "input": "", "output": "首先,请您放心,感冒时使用利巴韦林和头孢是常规治疗方式,一般不会对胎儿产生影响。不过,如果您确实怀孕了,建议您尽快去医院进行产前检查,让医生评估胎儿的情况,确定是否可以继续妊娠。如果胎儿的发育和健康状况正常,您可以选择继续妊娠,如果存在风险或者胎儿异常,您可以考虑终止妊娠。最好在专业医生的指导下做出决策。"}
这个是Alpaca格式(instruction/input/output),不能直接进行训练。
我们的SFT需要的格式是:{"messages": system,user,assistant} ChatML 格式
需要转换成Qwen要求的message格式:
python
{
"messages": [
{"role":"system","content":"你是专业的医疗咨询助手,请给出严谨、客观的医疗建议,提醒用户以线下医生诊断为准。"},
{"role":"user","content":"你好,医生,我不知道自己怀孕的情况下,感冒了输了利巴韦林和头孢,我怀孕有四十多天了,请问我可以要这小孩吗?"},
{"role":"assistant","content":"首先,请您放心,感冒时使用利巴韦林和头孢是常规治疗方式......做出决策。"}
]
}
格式转换脚本(convert_medical.py):
python
import json
src_file = r"C:\Users\benbe\Downloads\med_zh\med_dev_zh.json"
sys_text = "你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。"
out_list = []
with open(src_file,"r",encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
d = json.loads(line)
user_content = d["instruction"]
# 如果input不为空,拼接到用户提问后面
if d.get("input","").strip():
user_content += "\n" + d["input"].strip()
new_item = {
"messages":[
{"role":"system","content":sys_text},
{"role":"user","content":user_content},
{"role":"assistant","content":d["output"]}
]
}
out_list.append(new_item)
# 8:2划分训练集、验证集
split_pos = int(len(out_list)*0.8)
with open("train.jsonl","w",encoding="utf-8") as fw:
for x in out_list[:split_pos]:
fw.write(json.dumps(x,ensure_ascii=False)+"\n")
with open("val.jsonl","w",encoding="utf-8") as fw:
for x in out_list[split_pos:]:
fw.write(json.dumps(x,ensure_ascii=False)+"\n")
print(f"总样本:{len(out_list)},训练集{split_pos}条,验证集{len(out_list)-split_pos}条")
拿出一条转化后的样本进行测试,确认模板格式是否满足基座模型的格式要求,测试脚本:
python
from transformers import AutoTokenizer
tokenizer=AutoTokenizer.from_pretrained("/root/autodl-tmp/models/Qwen2.5-7B-Instruct",trust_remote_code=True)
sample = {"messages": [{"role": "system", "content": "你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。"}, {"role": "user", "content": "哺乳后胸太小,自己个字较高,体重偏瘦,胸部太平"}, {"role": "assistant", "content": "哺乳后胸部变小是正常的生理现象,因为哺乳会导致乳房内的脂肪组织减少。如果您希望增大胸部,可以考虑以下方法:\n\n1. 进行胸部锻炼,如俯卧撑、卧推等可以增强胸肌,使胸部看起来更丰满。\n\n2. 保持良好的饮食习惯,摄入足够的蛋白质、脂肪和维生素,帮助胸部增加脂肪组织。\n\n3. 考虑使用胸部增大产品,如丰胸霜、胸部按摩等。\n\n4. 如果您认为自己的胸部大小影响了自信心,可以考虑进行整形手术,如隆胸手术。\n\n另外,身高和体重偏瘦也可能会影响胸部的大小。如果您希望增加体重,可以适当增加饮食量,选择高蛋白、高热量的食物,并进行适当的运动锻炼,增加肌肉量。"}]}
text = tokenizer.apply_chat_template(sample["messages"],tokenize=False,add_generation_prompt=False)
print(text)
输出:
python
root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes# python test_sample_convert.py
<|im_start|>system
你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。<|im_end|>
<|im_start|>user
哺乳后胸太小,自己个字较高,体重偏瘦,胸部太平<|im_end|>
<|im_start|>assistant
哺乳后胸部变小是正常的生理现象,因为哺乳会导致乳房内的脂肪组织减少。如果您希望增大胸部,可以考虑以下方法:
1. 进行胸部锻炼,如俯卧撑、卧推等可以增强胸肌,使胸部看起来更丰满。
2. 保持良好的饮食习惯,摄入足够的蛋白质、脂肪和维生素,帮助胸部增加脂肪组织。
3. 考虑使用胸部增大产品,如丰胸霜、胸部按摩等。
4. 如果您认为自己的胸部大小影响了自信心,可以考虑进行整形手术,如隆胸手术。
另外,身高和体重偏瘦也可能会影响胸部的大小。如果您希望增加体重,可以适当增加饮食量,选择高蛋白、高热量的食物,并进行适当的运动锻炼,增加肌肉量。<|im_end|>
打印结果可以看到 <|im_start|>system、<|im_start|>user、<|im_start|>assistant标记,格式正确,就可以喂给 SFTTrainer 训练。
4、PEFT-QLoRA SFT训练
训练代码:
python
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments,
Trainer
)
from peft import LoraConfig, get_peft_model
# ========= 配置项 =========
model_name = "/root/autodl-tmp/models/Qwen2.5-7B-Instruct"
train_data_path = "/root/autodl-tmp/datas/med_sft/med_train.jsonl"
val_data_path = "/root/autodl-tmp/datas/med_sft/med_val.jsonl"
output_dir = "./lora-med-ckpt"
lora_save_path = "./lora-med-adapter"
MAX_LEN = 2048
# QLoRA 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
)
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
tokenizer.model_max_length = MAX_LEN
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
torch_dtype=torch.bfloat16
)
model.gradient_checkpointing_enable()
# PEFT LoRA配置 Qwen2.5
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 加载数据集
train_ds = load_dataset("json", data_files=train_data_path, split="train")
val_ds = load_dataset("json", data_files=val_data_path, split="train")
RESPONSE_TPL = "<|im_start|>assistant\n"
tpl_ids = tokenizer(RESPONSE_TPL, add_special_tokens=False)["input_ids"]
def process_fn(sample):
# 1. apply chat template
full_text = tokenizer.apply_chat_template(
sample["messages"],
tokenize=False,
add_generation_prompt=False
)
# 2. tokenize
out = tokenizer(
full_text,
truncation=True,
max_length=MAX_LEN,
padding="max_length"
)
input_ids = out["input_ids"]
attention_mask = out["attention_mask"]
labels = input_ids.copy()
# 3. 找到assistant模板位置,前面全部置-100不计算loss
for i in range(len(input_ids) - len(tpl_ids) + 1):
if input_ids[i:i+len(tpl_ids)] == tpl_ids:
for pos in range(i + len(tpl_ids)):
labels[pos] = -100
break
return {"input_ids":input_ids, "attention_mask":attention_mask, "labels":labels}
train_ds = train_ds.map(process_fn)
val_ds = val_ds.map(process_fn)
# 设置格式,只保留模型需要的key
train_ds.set_format("torch", columns=["input_ids","attention_mask","labels"])
val_ds.set_format("torch", columns=["input_ids","attention_mask","labels"])
training_args = TrainingArguments(
output_dir=output_dir,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
per_device_eval_batch_size=2,
learning_rate=2e-5,
num_train_epochs=2,
logging_steps=10,
save_steps=100,
eval_steps=100,
fp16=True,
optim="paged_adamw_8bit",
report_to=[],
eval_strategy="steps",
save_total_limit=3,
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
)
# ✅ 使用原生Trainer,不再使用SFTTrainer!
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_ds,
eval_dataset=val_ds,
)
trainer.train()
# 保存LoRA适配器
trainer.model.save_pretrained(lora_save_path)
tokenizer.save_pretrained(lora_save_path)
print(f"✅LoRA适配器保存完成:{lora_save_path}")
代码解读:
- 导入库
AutoModelForCausalLM:因果大模型加载,对话生成模型
peft:LoRA轻量化微调库
- 配置项
lora_save_path:最终产出,只有LoRA小权重,不是完整模型,体积几十MB,推理时要挂载基座模型使用
MAX_LEN=2048,单条样本最大token长度
- QLoRA 4bit量化配置BitsAndBytesConfig
QLoRA核心,把7B模型压缩到4bit加载,大幅降低显存占用。权重存储 4bit,运算 bf16,兼顾显存和训练效果。
- load_in_4bit=True:以 4bit 加载模型权重
- bnb_4bit_use_double_quant=True:二次量化,进一步省显存。权重只量化一次到 NF4;二次量化对象是scale 缩放因子。
- bnb_4bit_quant_type="nf4":Normalized‑float4,大模型微调推荐量化类型,效果优于普通 fp4。NF4为非均匀格点量化。
- bnb_4bit_compute_dtype=torch.bfloat16:计算的时候转回 bfloat16 精度做前向 / 反向传播,保证训练精度;权重存储是 4bit。
- Tokenizer初始化
padding_side="right":向右 padding,因果语言模型必须右补零,左 padding 会干扰生成
- 加载基座模型+开启梯度检查点
- quantization_config=bnb_config:启用 4bit QLoRA 加载
- torch_dtype=torch.bfloat16:模型运算精度 bf16。Ampere架构的显卡,硬件原生支持BF16矩阵乘法Tensor Core,速度很快。
- gradient_checkpointing_enable():梯度检查点,以时间换显存,节省大量显存;代价:训练速度会变慢一点。等同于deepspeed的激活检查点。核心思想:不保存中间激活值;反向传播的时候,重新跑一遍前向,重新算出需要的激活,以计算时间换取显存。
- LoRA配置
- r=16:LoRA秩,越大拟合能力越强,参数量越大
- target_modules="q_proj","k_proj","v_proj","o_proj":Qwen2.5 注意力层要加 LoRA 的模块,只对注意力做 LoRA,mlp 不动。
- 加载数据集
- RESPONSE_TPL = "<|im_start|>assistant\n":Qwen ChatML 标记,注意末尾换行符不能丢。
- tpl_ids:把 assistant 开头标记转为 token id,后续用来定位回答起始位置。
- process_fn:单样本预处理函数
- apply_chat_template:把messages=system,user,assistant按照 Qwen ChatML 模板拼接完整字符串。
add_generation_prompt=False:训练阶段关闭;推理时要 True,训练样本已经包含完整 assistant 回答。"<|im_start|>assistant\n"这个字符串,正是add_generation_prompt=True追加的那一段文本;
add_generation_prompt控制是否在对话模板末尾追加 assistant 起始标记<|im_start|>assistant\n。训练数据集已经包含完整 assistant 回答,设置 False;推理仅有 system+user,设置 True,用来引导模型从 assistant 位置开始续写输出。 - attention_mask:注意力层控制哪些 token 可以互相看见;0 屏蔽 padding
- 遍历 token 序列,匹配<|im_start|>assistant\n对应的 id 片段;该标记之前所有 token label 赋值为‑100。PyTorch CrossEntropyLoss 特性:label=-100 会自动忽略该位置不计入损失。也就是:system+user+<|im_start|>assistant\n这些 token 不参与 loss,只有 assistant 后面的回答文字参与 loss 更新权重。
- TrainingArguments 训练超参
- gradient_accumulation_steps=4梯度累积:每 4 步更新一次权重;实际等效 batch_size = 2 *4 =8;显存不足调小 batch,调大累积步数。
- metric_for_best_model="eval_loss"以验证集 loss 作为评判好坏标准
总结:
- QLoRA:权重存储 4bit,计算 bf16;gradient_checkpoint 进一步省显存,代价速度下降。
- Loss 掩码:必须把 system/user 部分 label=-100,仅 assistant 回答算 loss;模板末尾换行不可丢,否则匹配失败掩码失效。
- LoRA 只训练 q/k/v/o;基座完全冻结,可训练参数占比 0.1‑0.3%。
- load_best_model_at_end非常重要:自动选 eval_loss 最优模型,对抗医疗小数据集过拟合。
5、运行结果
【运行环境:3090 24G 2卡】
(1)运行日志
python
root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes# python train_med_sft.py
[transformers] `torch_dtype` is deprecated! Use `dtype` instead!
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 339/339 [00:02<00:00, 130.71it/s]
trainable params: 10,092,544 || all params: 7,625,709,056 || trainable%: 0.1323
{'loss': '9.681', 'grad_norm': '40.3', 'learning_rate': '1.972e-05', 'epoch': '0.04'}
{'loss': '3.945', 'grad_norm': '38.34', 'learning_rate': '1.936e-05', 'epoch': '0.08'}
{'loss': '1.344', 'grad_norm': '9.147', 'learning_rate': '1.896e-05', 'epoch': '0.12'}
6%|██████████▏ | 32/500 [09:30<2:19:11, 17.85s/it]
{'loss': '0.3227', 'grad_norm': '1.623', 'learning_rate': '1.856e-05', 'epoch': '0.16'}
9%|███████████████ | 47/500 [13:57<2:14:28, 17.81s/it]
{'loss': '0.1129', 'grad_norm': '0.5479', 'learning_rate': '1.816e-05', 'epoch': '0.2'}
{'loss': '0.08933', 'grad_norm': '0.2654', 'learning_rate': '1.776e-05', 'epoch': '0.24'}
{'loss': '0.0747', 'grad_norm': '0.2404', 'learning_rate': '1.736e-05', 'epoch': '0.28'}
{'loss': '0.06829', 'grad_norm': '0.06401', 'learning_rate': '1.696e-05', 'epoch': '0.32'}
{'loss': '0.0762', 'grad_norm': '0.07949', 'learning_rate': '1.656e-05', 'epoch': '0.36'}
{'loss': '0.0701', 'grad_norm': '0.05436', 'learning_rate': '1.616e-05', 'epoch': '0.4'}
{'eval_loss': '0.06643', 'eval_runtime': '358.6', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '0.4'}
{'loss': '0.06733', 'grad_norm': '0.04594', 'learning_rate': '1.576e-05', 'epoch': '0.44'}
{'loss': '0.06793', 'grad_norm': '0.04274', 'learning_rate': '1.536e-05', 'epoch': '0.48'}
{'loss': '0.06278', 'grad_norm': '0.07219', 'learning_rate': '1.496e-05', 'epoch': '0.52'}
{'loss': '0.06734', 'grad_norm': '0.04364', 'learning_rate': '1.456e-05', 'epoch': '0.56'}
{'loss': '0.06567', 'grad_norm': '0.04943', 'learning_rate': '1.416e-05', 'epoch': '0.6'}
{'loss': '0.0649', 'grad_norm': '0.04054', 'learning_rate': '1.376e-05', 'epoch': '0.64'}
{'loss': '0.06346', 'grad_norm': '0.1199', 'learning_rate': '1.336e-05', 'epoch': '0.68'}
{'loss': '0.06456', 'grad_norm': '0.04834', 'learning_rate': '1.296e-05', 'epoch': '0.72'}
{'loss': '0.06505', 'grad_norm': '0.04073', 'learning_rate': '1.256e-05', 'epoch': '0.76'}
{'loss': '0.06118', 'grad_norm': '0.03702', 'learning_rate': '1.216e-05', 'epoch': '0.8'}
{'eval_loss': '0.06243', 'eval_runtime': '358.7', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '0.8'}
42%|█████████████████████████████████████████████████████████████████▎ | 208/500 [1:13:41<2:09:49, 26.68s/it]
{'loss': '0.06143', 'grad_norm': '0.04803', 'learning_rate': '1.176e-05', 'epoch': '0.84'}
{'loss': '0.06285', 'grad_norm': '0.05561', 'learning_rate': '1.136e-05', 'epoch': '0.88'}
{'loss': '0.0619', 'grad_norm': '0.0374', 'learning_rate': '1.096e-05', 'epoch': '0.92'}
{'loss': '0.06739', 'grad_norm': '0.0474', 'learning_rate': '1.056e-05', 'epoch': '0.96'}
{'loss': '0.05766', 'grad_norm': '0.04297', 'learning_rate': '1.016e-05', 'epoch': '1'}
51%|███████████████████████████████████████████████████████████████████████████████▊ | 254/500 [1:27:20<1:13:04, 17.82s/it]
{'loss': '0.05751', 'grad_norm': '0.07466', 'learning_rate': '9.76e-06', 'epoch': '1.04'}
{'loss': '0.05832', 'grad_norm': '0.04197', 'learning_rate': '9.36e-06', 'epoch': '1.08'}
{'loss': '0.06342', 'grad_norm': '0.0499', 'learning_rate': '8.96e-06', 'epoch': '1.12'}
{'loss': '0.06125', 'grad_norm': '0.04082', 'learning_rate': '8.56e-06', 'epoch': '1.16'}
{'loss': '0.05576', 'grad_norm': '0.04777', 'learning_rate': '8.16e-06', 'epoch': '1.2'}
{'eval_loss': '0.06149', 'eval_runtime': '358.7', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '1.2'}
{'loss': '0.0637', 'grad_norm': '0.05606', 'learning_rate': '7.76e-06', 'epoch': '1.24'}
{'loss': '0.05827', 'grad_norm': '0.04508', 'learning_rate': '7.36e-06', 'epoch': '1.28'}
{'loss': '0.06752', 'grad_norm': '0.06225', 'learning_rate': '6.96e-06', 'epoch': '1.32'}
{'loss': '0.06071', 'grad_norm': '0.04637', 'learning_rate': '6.56e-06', 'epoch': '1.36'}
{'loss': '0.0603', 'grad_norm': '0.05329', 'learning_rate': '6.16e-06', 'epoch': '1.4'}
{'loss': '0.06119', 'grad_norm': '0.04567', 'learning_rate': '5.76e-06', 'epoch': '1.44'}
{'loss': '0.05596', 'grad_norm': '0.0464', 'learning_rate': '5.36e-06', 'epoch': '1.48'}
{'loss': '0.06642', 'grad_norm': '0.04317', 'learning_rate': '4.96e-06', 'epoch': '1.52'}
{'loss': '0.05975', 'grad_norm': '0.05931', 'learning_rate': '4.56e-06', 'epoch': '1.56'}
{'loss': '0.06347', 'grad_norm': '0.0577', 'learning_rate': '4.16e-06', 'epoch': '1.6'}
{'eval_loss': '0.06129', 'eval_runtime': '358.8', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '1.6'}
{'loss': '0.06557', 'grad_norm': '0.05656', 'learning_rate': '3.76e-06', 'epoch': '1.64'}
{'loss': '0.06327', 'grad_norm': '0.06272', 'learning_rate': '3.36e-06', 'epoch': '1.68'}
{'loss': '0.06514', 'grad_norm': '0.04247', 'learning_rate': '2.96e-06', 'epoch': '1.72'}
{'loss': '0.0689', 'grad_norm': '0.05644', 'learning_rate': '2.56e-06', 'epoch': '1.76'}
88%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████▏ | 441/500 [2:34:51<17:31, 17.83s/it]
{'loss': '0.06636', 'grad_norm': '0.0459', 'learning_rate': '2.16e-06', 'epoch': '1.8'}
{'loss': '0.06554', 'grad_norm': '0.05689', 'learning_rate': '1.76e-06', 'epoch': '1.84'}
{'loss': '0.05621', 'grad_norm': '0.04422', 'learning_rate': '1.36e-06', 'epoch': '1.88'}
{'loss': '0.06446', 'grad_norm': '0.06602', 'learning_rate': '9.6e-07', 'epoch': '1.92'}
{'loss': '0.05942', 'grad_norm': '0.04792', 'learning_rate': '5.6e-07', 'epoch': '1.96'}
{'loss': '0.06235', 'grad_norm': '0.05635', 'learning_rate': '1.6e-07', 'epoch': '2'}
{'eval_loss': '0.06112', 'eval_runtime': '358.8', 'eval_samples_per_second': '1.394', 'eval_steps_per_second': '0.697', 'epoch': '2'}
{'train_runtime': '1.07e+04', 'train_samples_per_second': '0.374', 'train_steps_per_second': '0.047', 'train_loss': '0.3659', 'epoch': '2'}
100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 500/500 [2:58:22<00:00, 21.41s/it]
✅LoRA适配器保存完成:./lora-med-adapter
(2)grad_norm
它是梯度范数,把所有可训练参数(LoRA_A, LoRA_B)梯度拼接之后的 L2‑范数。
把全部可训练参数的梯度摊平拼成一个超大一维向量,计算 L2:
含义:
grad_norm越大:梯度整体幅度大,参数更新步子大;
grad_norm越小:梯度整体很小,更新微弱,接近收敛;
grad_norm ≈ 0:几乎不更新,模型不再学东西。
(3)结果目录lora-med-adapter解读
python
root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes/lora-med-adapter# ls
README.md adapter_config.json adapter_model.safetensors chat_template.jinja tokenizer.json tokenizer_config.json
1)adapter_config.json
核心配置
内容示例:
peft_type: LORA
target_modules: "q_proj","k_proj","v_proj","o_proj"
r、lora_alpha、lora_dropout、bias、task_type
记录你训练时全部 LoRA 超参。PeftModel.from_pretrained(base_model, 这个文件夹)会读取该配置,知道哪些层要包装 LoraLinear。
没有这个文件,peft 无法加载适配器。
2)adapter_model.safetensors
LoRA 权重本体
真正保存训练出来的 lora_A、lora_B 矩阵,只有 LoRA 小权重,不含基座 Qwen 权重。
safetensors 安全格式,防止恶意代码。
里面 key 类似:
model.layers.0.self_attn.q_proj.lora_A.default.weight
model.layers.0.self_attn.q_proj.lora_B.default.weight
训练得到的可训练参数全部在这里;39MB 。基座原始权重仍然在你原始 Qwen 模型目录。
3)chat_template.jinja
jinja2 模板文件,存储ChatML 对话模板。
tokenizer.apply_chat_template()底层读取这个 jinja 模板,把 messages 数组转成模型输入文本。
⚠️重要:DPO、推理、RM 训练必须使用完全一致 chat_template。
加载 tokenizer 的时候会自动读取这个 jinja,如果丢失,模板会错乱,训练 / 推理效果暴跌。
4)tokenizer_config.json
分词器配置:pad_token、eos_token、bos_token,special token 映射,max_context 等。
保存分词器的各种配置参数。
5)tokenizer.json
真正的分词词典、BPE/Byte‑Pair 编码规则,vocab 合并表。
决定怎么把文字转 input_ids。
6、推理测试
代码:
python
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
base_model_name = "/root/autodl-tmp/models/Qwen2.5-7B-Instruct"
lora_path = "./lora-med-adapter"
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
torch_dtype="auto",
device_map="auto",
trust_remote_code=True
)
model = PeftModel.from_pretrained(base_model, lora_path)
tokenizer = AutoTokenizer.from_pretrained(base_model_name, trust_remote_code=True)
def chat_infer(messages, max_new_tokens=512, temperature=0.7, top_p=0.8):
text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
print(f"\n====输入模板====\n{text}\n====输出====\n")
model_inputs = tokenizer([text], return_tensors="pt").to(model.device)
input_len = model_inputs.input_ids.shape[1]
generated_ids = model.generate(
**model_inputs,
max_new_tokens=max_new_tokens,
temperature=temperature,
top_p=top_p,
do_sample=True
)
output_ids = generated_ids[:, input_len:]
resp = tokenizer.batch_decode(output_ids, skip_special_tokens=True)[0]
return resp
if __name__ == "__main__":
# 测试一条医疗提问(尽量用训练集没见过的问题)
test_msg = [
{"role": "system", "content": "你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。"},
{"role": "user", "content":"怀孕40多天,不知情下输了利巴韦林和头孢,孩子能不能要?"}
]
print(chat_infer(test_msg))
运行结果:
python
root@autodl-container-be044ebe99-a8225ede:~/autodl-tmp/codes/sft# python test_med_lora.py
Loading weights: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 339/339 [00:03<00:00, 91.42it/s]
====输入模板====
<|im_start|>system
你是专业的医疗咨询助手,回答仅供科普参考,不能替代执业医师面诊,诊疗请遵从线下医生的专业意见。<|im_end|>
<|im_start|>user
怀孕40多天,不知情下输了利巴韦林和头孢,孩子能不能要?<|im_end|>
<|im_start|>assistant
====输出====
利巴韦林和头孢类抗生素都属于对胎儿有潜在危害的药物。如果您在怀孕初期已经使用了这些药物,建议您尽快咨询医生,以了解可能的风险以及如何降低风险。
一般来说,如果怀孕早期使用了这些药物,医生可能会建议您进行产前检查,以确定胎儿是否受到药物的影响。如果您选择继续妊娠,医生可能会建议您进行更多的产前检查,以确保胎儿的健康。