返回项目列表

LoRA 微调实战

📋 项目概览

难度⭐⭐⭐高级
时长2周
前置知识
Python 基础PyTorch 基础Transformer 基础
关联路线图节点

🎯 学习目标

  • 理解 LoRA 低秩适配的原理
  • 掌握 QLoRA 4-bit 量化微调
  • 学会准备指令微调数据集
  • 实现大模型微调与部署

📦 项目结构

data/# 训练数据必需
scripts/# 训练脚本必需
outputs/# 模型输出
README.md# 项目说明必需

🚀 实现步骤

1

LLM 生态与 LoRA 原理

理解大语言模型生态和 LoRA 低秩适配原理

任务清单
  • 1.1 阅读 LoRA 论文 (https://arxiv.org/abs/2106.09685) 核心章节,理解 W = W_pretrained + B×A 的核心思想
  • 1.2 理解低秩分解数学原理:为什么 r<<d 能大幅减少可训练参数(从 d×d 降到 2×d×r)
  • 1.3 对比 LoRA 与全参数微调的参数量差异,计算参数减少比例
  • 1.4 理解 QLoRA 的核心思想:4-bit 量化 + LoRA 如何实现高效微调
  • 1.5 安装 HuggingFace 生态库:transformers、peft、bitsandbytes、accelerate
  • 1.6 访问 HuggingFace 模型排行榜,选择一个适合微调的基座模型(如 TinyLlama、Qwen-1.8B)
  • 1.7 了解 PEFT 库支持的各种微调方法,对比 LoRA 与 QLoRA、Prompt Tuning 的适用场景
核心代码示例
pip install transformers peft bitsandbytes accelerate

# LoRA 核心思想示意
# 原始全参数微调:可训练参数 = d × d (如 4096 × 4096 = 16M 参数)
# LoRA 低秩适配:可训练参数 = 2 × d × r (如 2 × 4096 × 8 = 65K 参数)
# 参数量减少比例:d / (2r) 倍

import torch

# 模拟 LoRA 的核心公式
d = 4096  # 隐藏层维度
r = 8     # LoRA 秩

# W_pretrained: 预训练权重 (冻结)
W_pretrained = torch.randn(d, d)

# B×A: 低秩更新矩阵 (可训练)
B = torch.randn(d, r)  # (4096, 8)
A = torch.randn(r, d)  # (8, 4096)

# 可训练参数对比
original_params = d * d  # 16,777,216
lora_params = 2 * d * r  # 65,536
print(f"全参数微调参数量: {original_params:,}")
print(f"LoRA 可训练参数量: {lora_params:,}")
print(f"参数量减少: {original_params / lora_params:.1f}x")
环境验证代码
# 验证安装的库版本
import transformers
import peft
import bitsandbytes
import torch

print(f"transformers: {transformers.__version__}")
print(f"peft: {peft.__version__}")
print(f"bitsandbytes: {bitsandbytes.__version__}")
print(f"PyTorch: {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"GPU: {torch.cuda.get_device_name(0)}")

✓ 完成标准:完成LLM 生态与 LoRA 原理的所有任务,达到预期学习目标

2

4-bit 量化加载模型

使用 QLoRA 以 4-bit 量化方式加载大模型

任务清单
  • 2.1 理解 BitsAndBytesConfig 的核心参数:load_in_4bit、bnb_4bit_compute_dtype、bnb_4bit_use_double_quant
  • 2.2 配置 4-bit 量化参数,了解 NF4 量化数据类型的工作原理
  • 2.3 使用 AutoModelForCausalLM.from_pretrained() 加载 TinyLlama-1.1B 或 Qwen-1.8B 模型
  • 2.4 监控模型加载时的显存占用,对比 fp16 和 4-bit 量化后的显存差异
  • 2.5 使用 tokenizer 对测试文本进行分词,验证模型能正常处理输入
  • 2.6 执行简单的文本生成测试,验证模型输出合理性
  • 2.7 分析量化对模型精度的影响,理解精度与显存的权衡
量化配置代码
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
import torch

# 配置 4-bit 量化
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                    # 启用 4-bit 量化加载
    bnb_4bit_compute_dtype=torch.float16,  # 计算时使用 float16
    bnb_4bit_use_double_quant=True,        # 启用双重量化进一步压缩
    bnb_4bit_quant_type="nf4"              # NF4 量化类型(Normal Float 4)
)

# 选择基座模型(建议新手从小型模型开始)
model_id = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"

print("正在加载模型...")
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)

print("正在加载 tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

print(f"模型加载完成")
print(f"模型参数量: {model.num_parameters() / 1e6:.2f}B")
显存验证代码
import torch

# 检查 GPU 显存占用
if torch.cuda.is_available():
    allocated = torch.cuda.memory_allocated() / 1024**3
    reserved = torch.cuda.memory_reserved() / 1024**3
    print(f"GPU 显存已占用: {allocated:.2f} GB")
    print(f"GPU 显存已预留: {reserved:.2f} GB")

# 对比:fp16 加载需要多少显存
# TinyLlama-1.1B fp16 约需 2.2GB
# TinyLlama-1.1B 4bit 约需 0.6GB
文本生成测试
# 简单的生成测试
test_prompt = "What is machine learning?"

inputs = tokenizer(test_prompt, return_tensors="pt").to(model.device)

with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=50,
        temperature=0.7,
        do_sample=True
    )

response = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"生成结果: {response}")

✓ 完成标准:能成功构建/加载模型,打印模型结构和参数量

3

配置 LoRA 适配器

配置 LoRA 参数并注入到模型中

任务清单
  • 3.1 理解 LoraConfig 的核心参数:r(秩)、lora_alpha(缩放因子)、target_modules(目标模块)
  • 3.2 选择合适的目标模块,通常是 attention 的 q_proj 和 v_proj(有时包括 k_proj、o_proj)
  • 3.3 配置 LoRA dropout(通常 0.05)和 bias 设置(通常 'none')
  • 3.4 使用 get_peft_model() 将 LoRA 适配器注入到预训练模型中
  • 3.5 调用 print_trainable_parameters() 查看可训练参数占比
  • 3.6 理解 lora_alpha 与 r 的比例关系:scaling = lora_alpha / r,影响学习率调节
  • 3.7 尝试不同的 r 值(8、16、32、64),观察参数量变化
  • 3.8 保存 LoRA 适配器配置到 config.json,了解其结构
LoraConfig配置代码
from peft import LoraConfig, get_peft_model, TaskType

# LoRA 配置参数详解
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,      # 任务类型:因果语言模型
    r=16,                               # 秩:越高越灵活,但参数量增加
    lora_alpha=32,                      # 缩放因子:通常设为 r 的 2 倍
    lora_dropout=0.05,                  # Dropout 概率防止过拟合
    bias="none",                        # 是否训练 bias:"none"/"all"/"lora_only"
    target_modules=[                   # 目标模块:通常选择 attention 投影层
        "q_proj",                      # Query 投影
        "v_proj",                      # Value 投影
        # 可选: "k_proj", "o_proj"
    ],
    inference_mode=False,              # 训练模式设为 False
)

# 将 LoRA 注入模型
model = get_peft_model(model, lora_config)

# 查看可训练参数
model.print_trainable_parameters()
# 输出示例: "trainable params: 4,194,304 || all params: 1,100,000,000 || trainable%: 0.3813%"
不同秩对比代码
# 对比不同 r 值的参数量(纯计算方式,不修改模型)
d = 4096  # 隐藏层维度
num_target_modules = 2  # q_proj, v_proj
num_layers = 22  # TinyLlama-1.1B 的层数

for r_value in [8, 16, 32, 64]:
    # 每个目标模块的参数:2 * d * r(A矩阵 + B矩阵)
    params_per_module = 2 * d * r_value
    total_params = params_per_module * num_target_modules * num_layers
    print(f"r={r_value}: 约 {total_params/1e6:.1f}M 可训练参数")

print("
规律:r 增加 2 倍,可训练参数增加 2 倍")
保存配置代码
# 保存 LoRA 适配器
model.save_pretrained("./lora_adapter")

# 查看保存的文件
import os
print(os.listdir("./lora_adapter"))
# 输出: ['adapter_config.json', 'adapter_model.bin']

# 加载保存的适配器
from peft import PeftModel
loaded_model = PeftModel.from_pretrained(model, "./lora_adapter")

✓ 完成标准:完成配置 LoRA 适配器的所有任务,达到预期学习目标

4

准备指令微调数据

准备 ChatML 格式的指令微调数据集

任务清单
  • 4.1 理解 ChatML 对话格式:system/user/assistant 角色的作用和标记方式
  • 4.2 准备 100-500 条高质量的指令-回答对作为训练数据
  • 4.3 使用 tokenizer.apply_chat_template() 将对话转换为模型可接受的格式
  • 4.4 实现数据清洗函数:去除特殊字符、处理多轮对话、截断过长样本
  • 4.5 使用 train_test_split 划分训练集和验证集(比例通常 9:1)
  • 4.6 创建自定义 Dataset 类,处理大文件流式读取
  • 4.7 验证格式化后的数据:解码几个样本确认格式正确
  • 4.8 了解对话模板的 special tokens(如 <|im_start|>、<|im_end|>)
数据格式转换代码
from transformers import AutoTokenizer
import json

# 对话示例
messages = [
    {"role": "system", "content": "你是一个有用的AI助手"},
    {"role": "user", "content": "请解释什么是机器学习"},
    {"role": "assistant", "content": "机器学习是人工智能的一个分支..."}
]

# 加载 tokenizer
model_id = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)

# 使用 chat template 格式化
formatted_text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,        # 返回字符串而非 token IDs
    add_generation_prompt=True  # 为生成添加起始标记
)

print("格式化后的文本:")
print(formatted_text)
print("
" + "="*50)

# 验证:解码验证格式
tokens = tokenizer(formatted_text, return_tensors="pt")
print(f"Token 数量: {len(tokens['input_ids'][0])}")
数据集处理代码
from datasets import Dataset
import pandas as pd
from sklearn.model_selection import train_test_split

# 从 JSON/CSV 加载原始数据
# 假设数据格式: {"instruction": "...", "output": "..."}
def load_instruction_data(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        data = json.load(f)
    return data

# 转换为 ChatML 格式
def format_instruction_dataset(raw_data, tokenizer):
    formatted_data = []
    for item in raw_data:
        messages = [
            {"role": "system", "content": "你是一个有用的助手"},
            {"role": "user", "content": item["instruction"]},
            {"role": "assistant", "content": item["output"]}
        ]
        text = tokenizer.apply_chat_template(messages, tokenize=False)
        formatted_data.append({"text": text})
    return formatted_data

# 划分数据集
train_data, val_data = train_test_split(formatted_data, test_size=0.1, random_state=42)

# 创建 HuggingFace Dataset
train_dataset = Dataset.from_list(train_data)
val_dataset = Dataset.from_list(val_data)

print(f"训练集大小: {len(train_dataset)}")
print(f"验证集大小: {len(val_dataset)}")
数据清洗代码
import re

def clean_text(text):
    # 去除多余空白
    text = re.sub(r'\s+', ' ', text)
    # 去除特殊控制字符
    text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', text)
    # 去除 HTML 标签
    text = re.sub(r'<[^>]+>', '', text)
    return text.strip()

def filter_long_samples(sample, max_length=2048):
    """过滤过长的样本"""
    if len(sample) > max_length:
        return False
    return True

# 应用清洗
cleaned_data = [clean_text(item) for item in raw_data]
filtered_data = [item for item in cleaned_data if filter_long_samples(item)]

✓ 完成标准:能成功加载数据集,打印出数据样本和统计信息

5

SFTTrainer 训练与评估

使用 SFTTrainer 进行监督微调

任务清单
  • 5.1 配置 TrainingArguments:设置 learning_rate、num_train_epochs、batch_size 等核心参数
  • 5.2 配置 gradient_accumulation_steps 实现有效更大的 batch_size(4×8=32)
  • 5.3 启用 fp16/BF16 混合精度训练减少显存占用
  • 5.4 配置 logging_steps 和 eval_steps 定期监控训练状态
  • 5.5 使用 SFTTrainer 创建训练器,设置 max_seq_length 控制序列长度
  • 5.6 启动 trainer.train() 开始训练,观察 loss 曲线变化
  • 5.7 训练完成后使用 trainer.save_model() 保存最佳模型
  • 5.8 将 LoRA 权重合并到基座模型,实现纯 FP16 的完整模型
  • 5.9 测试合并后模型的生成效果,对比微调前后的差异
训练配置代码
from transformers import TrainingArguments
from trl import SFTTrainer

# 训练参数配置
training_args = TrainingArguments(
    output_dir="./lora_output",              # 输出目录
    num_train_epochs=3,                      # 训练轮数
    per_device_train_batch_size=4,           # 每个 GPU 的 batch size
    gradient_accumulation_steps=4,           # 梯度累积(有效 batch = 4×4=16)
    learning_rate=2e-4,                      # 学习率(LoRA 通常用较大学习率)
    weight_decay=0.001,                      # 权重衰减
    fp16=True,                               # 启用 FP16 混合精度
    logging_steps=10,                        # 每 10 步记录日志
    save_steps=100,                          # 每 100 步保存模型
    eval_steps=100,                          # 每 100 步运行评估
    save_total_limit=3,                      # 最多保存 3 个 checkpoints
    warmup_ratio=0.03,                       # 预热比例
    lr_scheduler_type="cosine",              # 学习率调度器
    report_to="tensorboard",                 # 记录到 TensorBoard
    gradient_checkpointing=True,             # 梯度检查点节省显存
)

# 创建 SFTTrainer
trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=val_dataset,
    tokenizer=tokenizer,
    max_seq_length=512,                      # 最大序列长度
    dataset_text_field="text",               # 数据集中的文本字段名
)

# 开始训练
trainer.train()
模型合并代码
# 保存 LoRA 适配器
trainer.save_model("./lora_finetuned")

# 合并 LoRA 权重到基座模型
from peft import PeftModel
from transformers import AutoModelForCausalLM
import torch

# 重新加载原始基座模型(FP16)
base_model = AutoModelForCausalLM.from_pretrained(
    "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
    torch_dtype=torch.float16,
    device_map="auto",
)

# 加载 LoRA 适配器并合并
lora_model = PeftModel.from_pretrained(base_model, "./lora_finetuned")
merged_model = lora_model.merge_and_unload()

# 保存合并后的完整模型
merged_model.save_pretrained("./merged_model")
tokenizer.save_pretrained("./merged_model")

print("模型合并完成,已保存到 ./merged_model")
生成测试代码
import torch

# 测试微调后的模型
def generate_response(prompt, model, tokenizer, max_new_tokens=100):
    messages = [
        {"role": "system", "content": "你是一个有用的助手"},
        {"role": "user", "content": prompt}
    ]
    
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            temperature=0.7,
            top_p=0.9,
            do_sample=True
        )
    
    response = tokenizer.decode(outputs[0], skip_special_tokens=True)
    # 只返回 assistant 的回答部分
    return response.split("assistant
")[-1].strip()

# 测试
test_prompt = "解释一下什么是过拟合"
response = generate_response(test_prompt, merged_model, tokenizer)
print(f"回答: {response}")

✓ 完成标准:能成功跑通训练循环,loss正常下降

📚 参考资源