实验所用的设备是单卡4090 24GB,在验证阶段使用了KV cache所以总共需要至少21GB的显存,才推荐完成这个微调操作。

在10万左右训练数据集、使用Lora方法、半精度主干网络权重、3 Epoch的情况下,单卡微调时间大约3小时半。

0 原始数据预处理

0.1 构造指令微调数据

这一步主要是将原始数据读取进来,整理并替换为大模型要求的指令微调格式,保存为jsonl方便下次读取。

import json
import pandas
import numpy as np

file_path = "./data/外科问诊数据集.csv"
data = pandas.read_csv(file_path, encoding="GBK", encoding_errors="ignore")
data = data.dropna()
# 构建指令微调数据
dataset = []
for i in range(len(data)):
    dataset.append({
        "instruction": data["title"][i],
        "input": data["ask"][i],
        "output": data["answer"][i]
    })

print("数据集大小:", len(dataset))

# 保存为jsonl
with open("./data/外科问诊数据集.jsonl", "w", encoding="utf-8") as f:
    for i in range(len(dataset)):
        f.write(json.dumps(dataset[i], ensure_ascii=False) + "\n")

0.2 分词预处理

from datasets import load_dataset
import torch
from transformers import Qwen2Tokenizer

data_path = "./data/外科问诊数据集.jsonl"
dataset = load_dataset("json", data_files=data_path)["train"] 

torch.cuda.empty_cache()
model_path = "/root/lanyun-tmp/hf/Qwen2.5-1.5B-Instruct"
tokenizer = Qwen2Tokenizer.from_pretrained(model_path, local_files_only=True)


def process_func(example):
    MAX_LENGTH = 384  
    instruction = tokenizer(
        f"<|im_start|>system\n{example['instruction']}<|im_end|>\n<|im_start|>user\n{example['input']}<|im_end|>\n<|im_start|>assistant\n",
        add_special_tokens=False)
    response = tokenizer(f"{example['output']}", add_special_tokens=False)
    input_ids = instruction["input_ids"] + response["input_ids"] + [tokenizer.pad_token_id]
    attention_mask = instruction["attention_mask"] + response["attention_mask"] + [1] 
    labels = [-100] * len(instruction["input_ids"]) + response["input_ids"] + [tokenizer.pad_token_id]
    if len(input_ids) > MAX_LENGTH:  # 截断
        input_ids = input_ids[:MAX_LENGTH]
        attention_mask = attention_mask[:MAX_LENGTH]
        labels = labels[:MAX_LENGTH]
    return {
        "input_ids": torch.LongTensor(input_ids),   
        "attention_mask": torch.LongTensor(attention_mask),
        "labels": torch.LongTensor(labels) 
    }


k_workers = 20
dataset = dataset.map(process_func, batched=False, num_proc=k_workers)
dataset.save_to_disk("./data/外科问诊_processed")

1 数据读取

到这里就可以直接读取已经分词处理好的数据了,直接按照合理比例进行划分数据集,这里总数据大小是11万左右。

import datasets

data_path = "./data/外科问诊_processed"
dataset = datasets.load_from_disk(data_path)

split_dataset = dataset.train_test_split(test_size=0.2, seed=42)
train_data = split_dataset["train"]
test_data = split_dataset["test"]

print(f"训练集大小:{len(train_data)}")
print(f"测试集大小:{len(test_data)}")

训练集大小:92792
测试集大小:23199

2 配置

import torch, transformers
from transformers import Qwen2Tokenizer, Qwen2ForCausalLM
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, TaskType

device = "cuda" if torch.cuda.is_available() else "cpu"
k_workers = 20
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    inference_mode=False,  # 训练模式
    r=8,  # Lora 秩
    lora_alpha=32,  # Lora alaph,具体作用参见 Lora 原理
    lora_dropout=0.1  # Dropout 比例
)

有了上面的lora配置,就可以开始加载lora后的模型了

torch.cuda.empty_cache()
# torch.cuda.set_device(0)
model_path = "/root/lanyun-tmp/hf/Qwen2.5-1.5B-Instruct"
tokenizer = Qwen2Tokenizer.from_pretrained(model_path, local_files_only=True)
model = Qwen2ForCausalLM.from_pretrained(
    model_path,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    local_files_only=True
)

model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)
model = get_peft_model(model, lora_config)
model.enable_input_require_grads()
model.print_trainable_parameters()
model.train()

trainable params: 9,232,384 || all params: 1,552,946,688 || trainable%: 0.5945

3 训练

from transformers import TrainingArguments, Trainer 

args = TrainingArguments(
    output_dir="./output/Qwen2.5_instruct_lora",
    per_device_train_batch_size=16,
    gradient_accumulation_steps=4,
    gradient_checkpointing=True,
    dataloader_num_workers=k_workers,
    bf16=True,
    logging_steps=50,
    num_train_epochs=3,
    save_steps=100,
    learning_rate=1e-4,
    optim="adamw_torch_fused",
    ddp_find_unused_parameters=False,
    label_names=["labels"],
)

trainer = Trainer(
    model=model,
    args=args,
    train_dataset=train_data,
    eval_dataset=test_data,
    data_collator=transformers.DataCollatorForSeq2Seq(tokenizer, return_tensors="pt"),
    compute_metrics=compute_metrics
)

trainer.train()

由于是分步来训练统计loss的,所以会因为数据的分布不同,导致loss出现小范围的抖动,但是总体上在3000个step之后是趋于稳定了,接下来就可以对比baseline测试一下实际效果

image-hrwl.png

为了方便保存和下次读取,可以将lora参数合并

# 合并lora参数
from transformers import Qwen2ForCausalLM, Qwen2Tokenizer
import torch
from peft import PeftModel

model_path = "/root/lanyun-tmp/hf/Qwen2.5-1.5B-Instruct"
model = Qwen2ForCausalLM.from_pretrained(
    model_path,
    torch_dtype=torch.float16,
    device_map="auto",
    local_files_only=True
)
model = PeftModel.from_pretrained(model, "./output/Qwen2.5_instruct_lora/checkpoint-4350").merge_and_unload()
tokenizer = Qwen2Tokenizer.from_pretrained(model_path, local_files_only=True)
model.save_pretrained("./output/Qwen2.5_instruct_lora_merged")
tokenizer.save_pretrained("./output/Qwen2.5_instruct_lora_merged")

4 测试

接下来可以和base模型做一个输出对比:

from vllm import LLM, SamplingParams
import datasets, torch

base_model = "/root/lanyun-tmp/hf/Qwen2.5-1.5B-Instruct"
lora_model = "./output/Qwen2.5_instruct_lora_merged"
model = LLM(
    model=base_model,
    trust_remote_code=True,
    tensor_parallel_size=1,
)
 
sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
    repetition_penalty=1.1,
)
data_path = "./data/外科问诊_processed"
dataset = datasets.load_from_disk(data_path)

split_dataset = dataset.train_test_split(test_size=0.2, seed=42)
test_data = split_dataset["test"]

nums = [1, 10, 20, 100, 200, 500, 1000, 2000, 5000, 10000]
inputs = []
for i, num in enumerate(nums):
    example = test_data[num]
    instruction = example["instruction"]
    input_value = example["input"]
    text = f"<|im_start|>system\n{instruction}<|im_end|>\n<|im_start|>user\n{input_value}<|im_end|>\n<|im_start|>assistant\n"
    print(f"第 {i + 1} 段对话:")
    print(text)

    output = model.generate([text], sampling_params=sampling_params)
    print(output[0].outputs[0].text)

    # input_ids = tokenizer(text, return_tensors="pt").input_ids.to(model.device)
    inputs.append(text)
    print("\n\n")
    torch.cuda.empty_cache()

以下是未经过微调的base模型生成的结果:
image-ovcq.png
image-fnky.png

以下是通过PEFT的模型生成的结果:
image-psit.png
image-dtxg.png

可以看出,通过PEFT的模型生成的语句就有点医生的味道了,而不是死板地列出一条条字段。