MODULE 02 · 模型微调

模型微调与 LoRA:用小成本定制大模型

当提示词无法满足「记住特定知识或风格」的需求时,微调是另一条路。本模块讲清全量微调与参数高效微调(PEFT)的区别,并用 LoRA / QLoRA 落地:从数据集准备、4-bit 加载、LoraConfig 到完整训练脚本与推理验证。

16 节 约 120 分钟 更新于 2026-07

全量微调 vs PEFT

全量微调(Full Fine-tuning)更新模型全部参数,效果上限高但显存与算力开销巨大,且容易灾难性遗忘。对多数团队来说并不划算。

参数高效微调(PEFT)只训练少量新增参数,冻结原模型权重。它在大幅降低成本的同时,保留了预训练学到的通用能力。LoRA 是其中应用最广的一种。

以 7B 模型为例算一笔账:全量微调需要为每个参数保存权重(fp16 占 2 字节)、梯度(2 字节)与 Adam 优化器状态(fp32 一阶 + 二阶动量共 8 字节),仅这三项就超过 84 GB,必须多卡并行。而 LoRA(r=16,只挂注意力投影层)可训练参数通常不到 0.5%,优化器状态只为这一小部分保存,加上 QLoRA 的 4-bit 基座,单张 24 GB 消费级显卡即可完成训练。

什么时候该微调:提示词 + RAG 已尽力但仍无法稳定复现「特定输出风格、领域术语、固定格式」时,才考虑微调。微调改的是「行为方式」,注入新知识优先用 RAG。

用 PEFT 库可以直接打印可训练参数占比,训练前先确认没有误解冻整座模型:

# 查看 LoRA 注入后的可训练参数占比
from peft import get_peft_model, LoraConfig

model = get_peft_model(base_model, LoraConfig(r=16, lora_alpha=32))
model.print_trainable_parameters()
# 输出示例:trainable params: 8,388,608 || all params: 6,746,804,224
# || trainable%: 0.1243  只训练约 0.12% 的参数

LoRA 原理

LoRA(Low-Rank Adaptation)假设权重更新矩阵具有低秩结构。它不直接改 W,而是用两个小矩阵 A、B 的乘积 ΔW = B·A 来近似更新,其中秩 r 远小于原维度。

# 前向:原输出 + 低秩增量(乘缩放系数 alpha / r)
h = W·x + (alpha / r) · (B·A)·x   # A: r×d 高斯初始化, B: d×r 全零初始化
# B 初始为零保证训练起点 ΔW = 0,行为与基座完全一致
# 训练时只优化 A、B,W 保持冻结

下图展示了 LoRA 的旁路结构:冻结的基座权重 W 与可训练的低秩矩阵 A、B 并行计算,输出相加。

输入 x 预训练权重 W d×d · 冻结不更新 矩阵 A r×d 高斯init 矩阵 B d×r 零init 可训练旁路 ΔW = B·A,秩 r ≪ d 输出 h

下面用矩阵视角再看一次 LoRA 的结构:冻结的 W 与可训练的 B、A 各自占什么维度,乘积 B·A 如何得到与 W 同形的增量。

预训练权重 W 维度 d x k 冻结,不更新 B d x r 零初始化 可训练 A r x k 高斯随机初始化,可训练 ΔW = B x A ∈ R^(d x k) 秩 r ≪ min(d, k),缩放系数 α / r 控制增量强度 最终输出 h = W x x + (α/r) x (B x A) x x 冻结 可训练(B 零初始化) 可训练(A 高斯初始化)

推理时可把 B·A 合并回原权重(merge),不增加任何推理延迟;也可以保留适配器,按需切换不同任务的 LoRA,一份基座服务多个场景。适配器文件通常只有几十 MB,分发与版本管理都非常轻。

QLoRA 量化微调

QLoRA 在 LoRA 基础上,把基座模型量化为 4-bit(NF4),再用分页优化器避免显存峰值。它让单张消费级显卡也能微调 7B 乃至 13B 模型。核心是三件事:NF4 量化存储基座、计算时反量化为 bf16、双重量化进一步压缩量化常数。

bitsandbytes 做 4-bit 加载的标准写法如下,注意 prepare_model_for_kbit_training 会做好 LayerNorm 精度与梯度检查点等适配:

# pip install transformers peft trl bitsandbytes accelerate datasets
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import prepare_model_for_kbit_training

model_id = "Qwen/Qwen2.5-7B-Instruct"

# 4-bit 量化配置:NF4 数据类型 + 双重量化 + bf16 计算精度
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                      # 基座权重以 4-bit 存储
    bnb_4bit_quant_type="nf4",              # NormalFloat4,对正态分布权重更友好
    bnb_4bit_use_double_quant=True,         # 量化常数再量化一次,再省约 0.4 bit/参数
    bnb_4bit_compute_dtype=torch.bfloat16,  # 反量化后的矩阵乘用 bf16
)

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",                      # 自动放置到可用 GPU
)

# 为 k-bit 训练做准备:冻结基座、LayerNorm 转 fp32、开启梯度检查点
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False              # 训练时关闭 KV cache
选型建议:显存紧张选 QLoRA(4-bit);追求质量且显存充足,用 LoRA(fp16 / bf16)。rank 通常取 8-64,alpha 取 rank 的 1-2 倍。QLoRA 相比 fp16 LoRA 质量损失很小,但训练速度会慢 20% 到 40%。

下面的完整脚本把上面所有步骤串成一条可直接启动的训练流水线:

#!/usr/bin/env python
# train_qlora_full.py -- QLoRA 完整训练启动脚本(可一键运行)
# pip install transformers peft trl bitsandbytes accelerate datasets
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset

# ===== 第 0 步:确认 GPU 与 bf16 支持 =====
assert torch.cuda.is_available(), "需要 CUDA GPU"
assert torch.cuda.get_device_capability()[0] >= 8, "需要 Ampere 及以上架构以支持 bf16"

# ===== 第 1 步:4-bit 量化配置与模型加载 =====
model_id = "Qwen/Qwen2.5-7B-Instruct"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                     # 基座权重以 4-bit NF4 存储
    bnb_4bit_quant_type="nf4",              # NormalFloat4:对正态分布权重更友好
    bnb_4bit_use_double_quant=True,         # 量化常数再量化一次,每参数额外省约 0.4 bit
    bnb_4bit_compute_dtype=torch.bfloat16,  # 反量化后矩阵乘用 bf16
)

tokenizer = AutoTokenizer.from_pretrained(
    model_id, trust_remote_code=True)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token  # 大部分模型需要手动设 pad_token

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",                     # 自动分配层到可用 GPU
    trust_remote_code=True,
)

# k-bit 训练适配:冻结基座、LayerNorm 转 fp32、开启梯度检查点
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False             # 训练时关闭 KV cache
model.gradient_checkpointing_enable()    # 用计算时间换显存

# ===== 第 2 步:LoRA 配置 =====
lora_config = LoraConfig(
    r=16,                                  # 低秩矩阵的秩:容量与显存的平衡点
    lora_alpha=32,                         # 缩放系数,实际增益 = alpha / r = 2.0
    lora_dropout=0.05,                     # 旁路 dropout:小数据集防过拟合
    bias="none",                           # 不训练 bias,保持行为可合并
    task_type="CAUSAL_LM",
    target_modules=[                       # 注意力全投影 + MLP 全挂,效果通常最好
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
)

# 注入 LoRA 并确认可训练参数占比
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 预期输出:trainable% 约 0.1-0.3%

# ===== 第 3 步:数据集加载与预处理 =====
def format_chat(example):
    # 将 instruction/input/output 转为 messages 列表
    user_content = example["instruction"]
    if example.get("input"):
        user_content += "\n\n" + example["input"]
    return {"messages": [
        {"role": "system", "content": "你是一名严谨的中文办公助手。"},
        {"role": "user", "content": user_content},
        {"role": "assistant", "content": example["output"]},
    ]}

dataset = load_dataset("json", data_files="data/train.jsonl", split="train")
dataset = dataset.map(format_chat, remove_columns=dataset.column_names)
dataset = dataset.train_test_split(test_size=0.05, seed=42)  # 留 5% 验证

# ===== 第 4 步:训练参数 =====
args = SFTConfig(
    output_dir="out/qlora-finetune",
    num_train_epochs=3,                     # 小数据集 2 至 4 轮足够
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,          # 有效 batch = 2x8 = 16
    learning_rate=2e-4,                     # LoRA 常用 1e-4 至 3e-4
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    bf16=True,                              # Ampere 及以上显卡用 bf16
    logging_steps=10,
    eval_strategy="epoch",                  # 每轮结束时验证
    save_strategy="epoch",
    load_best_model_at_end=True,            # 训练结束时加载验证 loss 最低的 checkpoint
    metric_for_best_model="eval_loss",
    max_length=1024,                        # 按样本长度分布设定
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    report_to="none",
    seed=42,
    dataloader_num_workers=0,               # Windows 兼容性
)

# ===== 第 5 步:启动训练 =====
trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    processing_class=tokenizer,            # 自动应用 chat 模板并只对 assistant 部分算 loss
)

# 打印训练前关键信息
print(f"训练样本数: {len(dataset['train'])}, 验证样本数: {len(dataset['test'])}")
print(f"有效 batch size: {args.per_device_train_batch_size * args.gradient_accumulation_steps}")
trainer.train()

# ===== 第 6 步:保存适配器 =====
trainer.save_model("out/qlora-finetune/final")
tokenizer.save_pretrained("out/qlora-finetune/final")
print("训练完成,适配器已保存至 out/qlora-finetune/final")

数据集准备

微调效果的上限由数据决定。指令微调(SFT)最常用 JSONL 格式,每行一个样本,包含 instruction / input / output 三个字段。几百到几千条高质量样本,往往胜过几万条噪声数据。

# data/train.jsonl 每行一个 JSON 对象
{"instruction": "把下面的口语化反馈改写成正式的工单描述", "input": "登录老是转圈圈进不去", "output": "问题描述:用户登录时页面持续加载,无法进入系统。影响范围:登录流程。期望:定位加载阻塞原因并修复。"}
{"instruction": "把下面的口语化反馈改写成正式的工单描述", "input": "导出的表格数字全乱了", "output": "问题描述:导出 Excel 后数值列显示异常,与页面数据不一致。影响范围:报表导出功能。期望:核对导出序列化逻辑。"}
{"instruction": "总结这条用户评价的情感倾向,只输出 正面/负面/中性", "input": "客服响应挺快的,就是问题没彻底解决", "output": "中性"}

训练前要把结构化样本渲染成模型的对话模板。不同模型模板不同,统一用 tokenizer 的 apply_chat_template 最稳妥:

# build_dataset.py 把 JSONL 渲染成带 chat 模板的训练文本
from datasets import load_dataset

def to_messages(example):
    """把 instruction/input/output 拼成 messages 列表"""
    user_content = example["instruction"]
    if example.get("input"):
        user_content += "\n\n" + example["input"]
    return {"messages": [
        {"role": "system", "content": "你是一名严谨的中文办公助手。"},
        {"role": "user", "content": user_content},
        {"role": "assistant", "content": example["output"]},
    ]}

dataset = load_dataset("json", data_files="data/train.jsonl", split="train")
dataset = dataset.map(to_messages, remove_columns=dataset.column_names)
dataset = dataset.train_test_split(test_size=0.05, seed=42)  # 留 5% 做验证
# TRL 的 SFTTrainer 能直接消费 messages 字段,自动套用 chat 模板
print(dataset["train"][0]["messages"])
数据三查:查重复(近似去重,避免模型背题)、查泄漏(验证集样本不得出现在训练集)、查格式(每条 output 都应是你希望模型「原样学会」的理想答案,含标点与换行)。

PEFT + TRL 实战

Hugging Face 的 peft 负责注入适配器,trl 提供 SFTTrainer 等训练封装。先看整体流水线,再给出可直接运行的完整脚本。

JSONL 数据 instruction 格式 4-bit 加载 NF4 量化基座 注入 LoRA LoraConfig r/alpha SFTTrainer 只更新 A/B 保存适配器 几十 MB

完整训练脚本(接着上面 4-bit 加载与数据集准备的代码):

# train_qlora.py QLoRA 指令微调完整脚本
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig

# 1. LoRA 配置:秩、缩放、挂载哪些模块
lora_config = LoraConfig(
    r=16,                        # 低秩矩阵的秩,容量与显存的平衡点
    lora_alpha=32,               # 缩放系数,实际增益 = alpha / r = 2.0
    lora_dropout=0.05,           # 旁路 dropout,小数据集防过拟合
    bias="none",                 # 不训练 bias,保持行为可合并
    task_type="CAUSAL_LM",
    target_modules=[             # 注意力 + MLP 全挂,效果通常最好
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
)

# 2. 训练参数:小数据集的稳妥起点
args = SFTConfig(
    output_dir="out/qwen7b-ticket-lora",
    num_train_epochs=3,                  # 小数据集 2-4 轮足够
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,       # 有效 batch = 2×8 = 16
    learning_rate=2e-4,                  # LoRA 常用 1e-4 到 3e-4
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    bf16=True,                           # Ampere 及以上显卡用 bf16
    logging_steps=10,
    eval_strategy="epoch",               # 每轮跑一次验证集
    save_strategy="epoch",
    max_length=1024,                     # 按样本长度分布设定,别浪费显存
    gradient_checkpointing=True,         # 用时间换显存
    report_to="none",
)

# 3. SFTTrainer 自动应用 chat 模板并只对 assistant 部分算 loss
trainer = SFTTrainer(
    model=model,                         # 上文 4-bit 加载后的模型
    args=args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    peft_config=lora_config,             # 传入即自动 get_peft_model
    processing_class=tokenizer,
)

trainer.train()

# 4. 只保存适配器权重(adapter_model.safetensors,几十 MB)
trainer.save_model("out/qwen7b-ticket-lora/final")
tokenizer.save_pretrained("out/qwen7b-ticket-lora/final")

训练监控重点看两条曲线:训练 loss 应平滑下降到 1.0 上下(视任务而定);验证 loss 一旦回升就是过拟合信号,取回升前的 checkpoint 即可。

训练完成后,用下面的脚本合并适配器并对比微调前后的输出差异,直观验证效果:

#!/usr/bin/env python
# verify.py -- 合并适配器并对比微调前后输出
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

model_id = "Qwen/Qwen2.5-7B-Instruct"
adapter_path = "out/qlora-finetune/final"

# 加载基座并挂载适配器
base_model = AutoModelForCausalLM.from_pretrained(
    model_id, torch_dtype=torch.bfloat16, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(adapter_path)

# 微调后模型(带适配器)
peft_model = PeftModel.from_pretrained(base_model, adapter_path)

# 合并 B·A 到 W:得到与普通模型无异的单一权重文件
merged_model = peft_model.merge_and_unload()
merged_model.eval()

# 同时加载一个纯粹的基座用于对比
base_only = AutoModelForCausalLM.from_pretrained(
    model_id, torch_dtype=torch.bfloat16, device_map="auto")
base_only.eval()

# 测试用例:训练集之外的输入
test_cases = [
    "把下面的口语化反馈改写成正式的工单描述\n\n登录老是转圈圈进不去",
    "把下面的口语化反馈改写成正式的工单描述\n\n导出的表格数字全乱了没法看",
    "总结这条用户评价的情感倾向,只输出 正面/负面/中性\n\n挺好的就是加载有点慢",
]

def generate_one(model, user_input, label):
    messages = [
        {"role": "system", "content": "你是一名严谨的中文办公助手。"},
        {"role": "user", "content": user_input},
    ]
    inputs = tokenizer.apply_chat_template(
        messages, add_generation_prompt=True, return_tensors="pt"
    ).to(model.device)
    with torch.no_grad():
        out = model.generate(
            inputs, max_new_tokens=200, temperature=0.3,
            do_sample=True, top_p=0.95)
    result = tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True)
    print(f"{label:=^50}")
    print(result)
    print()
    return result

for idx, user_input in enumerate(test_cases):
    print(f"\n{'■'} 样本 {idx+1} 输入:{user_input[:40]}...")
    generate_one(base_only, user_input, 微调前(基座))
    generate_one(merged_model, user_input, 微调后(合并))

# 检查合并后的模型权重一致性
# 合并后 h = W_merged x x 应与 peft_model 输出一致(误差 < 1e-5)
test_input = tokenizer("测试", return_tensors="pt").to(merged_model.device)
with torch.no_grad():
    out_merged = merged_model(**test_input).logits
    out_peft = peft_model(**test_input).logits
diff = (out_merged - out_peft).abs().max().item()
print(f"\n合并后与适配器推理的最大误差:{diff:.2e}")
assert diff < 1e-3, f"合并不一致!diff={diff}"
print("合并验证通过")

超参对照表

LoRA 的超参不多,但相互牵连。下表是常用取值与调整方向,建议先用推荐值跑通基线,再单变量调整:

超参 推荐起点 常见范围 调大的影响
r(秩) 16 8 至 64 容量更大、更能学复杂模式,但显存上升且小数据集易过拟合
lora_alpha 32(= 2r) r 的 1 至 2 倍 增量 ΔW 的实际权重变大,学得更「猛」,过大会破坏基座能力
learning_rate 2e-4 1e-4 至 3e-4 收敛更快但易震荡;比全量微调(约 2e-5)大一个数量级是正常的
lora_dropout 0.05 0 至 0.1 正则更强、抗过拟合,数据量大时可设为 0
target_modules 注意力 + MLP 全挂 最少 q_proj / v_proj 挂的模块越多效果越接近全量微调,可训练参数与显存也随之增加
epochs 3 1 至 5 轮数过多模型开始「背诵」训练集,验证 loss 回升即应停止
经验法则:先动 learning_rate 与 epochs,再动 r 与 target_modules。alpha 保持 2r 基本不用改。任何一次只改一个变量,并固定随机种子对比。

合并、推理与部署

关键超参:学习率(LoRA 通常用 1e-4 量级,比全量微调大)、rank / alpha目标模块(注意力投影层最常用)。训练后有两条部署路线:合并导出成单一模型,或保留适配器动态加载。

# inference.py 加载适配器推理,验证微调效果
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct", torch_dtype=torch.bfloat16, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained("out/qwen7b-ticket-lora/final")

# 路线一:挂适配器推理(可随时卸载或换其他任务的 LoRA)
model = PeftModel.from_pretrained(base, "out/qwen7b-ticket-lora/final")

messages = [
    {"role": "system", "content": "你是一名严谨的中文办公助手。"},
    {"role": "user", "content": "把下面的口语化反馈改写成正式的工单描述\n\n手机端图片死活传不上去"},
]
inputs = tokenizer.apply_chat_template(
    messages, add_generation_prompt=True, return_tensors="pt").to(model.device)
out = model.generate(inputs, max_new_tokens=256, temperature=0.3)
print(tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True))

# 路线二:合并权重导出,得到与普通模型无异的目录
merged = model.merge_and_unload()          # 把 B·A 加回 W,移除旁路
merged.save_pretrained("out/qwen7b-ticket-merged")
tokenizer.save_pretrained("out/qwen7b-ticket-merged")
# vLLM 也支持不合并直接服务多个 LoRA:
# vllm serve Qwen/Qwen2.5-7B-Instruct --enable-lora \
#   --lora-modules ticket=out/qwen7b-ticket-lora/final

想了解上线阶段的吞吐与显存优化,接着看 部署与推理优化 模块。

动手练习

以下三个练习按难度递进,都能在单张 24 GB 显卡(或云端按小时租用)上完成。每个练习都有明确的验收标准,做完再看下一模块。

练习 1:构造你自己的指令数据集。选一个你熟悉的窄任务(如口语反馈转工单、周报要点提炼、SQL 注释生成),手写或半自动构造 100 至 300 条 instruction / input / output 样本,按本页「数据集准备」的脚本渲染成 messages 并切分训练/验证集。
验收标准:JSONL 每行可被 json.loads 解析;近似去重后重复率低于 5%;随机抽 20 条人工检查,output 全部是你愿意让模型原样学会的理想答案。

练习 2:在自有小数据集上微调一个 7B 模型并推理验证。用本页 QLoRA 脚本(4-bit 加载 + LoraConfig r=16 + SFTTrainer)在练习 1 的数据集上训练 3 轮,保存适配器,再用推理脚本对 10 条训练集之外的新输入生成结果,与微调前的基座输出并排对比。
验收标准:训练全程无 OOM,验证 loss 相比第 1 轮下降;10 条新输入中至少 8 条在「格式遵循度、术语准确性」上肉眼可见优于基座;适配器目录中存在 adapter_model.safetensors 且小于 500 MB。

练习 3:超参消融实验。固定随机种子与数据,只改一个变量各跑一次:r=8 vs r=32、学习率 1e-4 vs 3e-4。记录四组的验证 loss 曲线与显存峰值,写一段 200 字结论说明该任务下哪组性价比最高。
验收标准:能给出四组实验的最终验证 loss 数值表;结论中明确指出过拟合或欠拟合的证据(如某组验证 loss 回升的具体轮次)。

练习 4:超参扫网格。itertools.productr=[4,8,16,32]lora_alpha=[8,16,32,64] 做小规模扫网格,每个组合训练 50 步后记录 eval loss,输出一张 r x alpha 的 loss 热力矩阵,并指出哪个组合在你的任务上最优。注意每个组合需要重新加载一份干净基座以消除残留,训练步数不必多,关键是比较相对顺序。
验收标准:提供完整扫网格脚本;输出矩阵中每个单元格都有 eval loss 数值;标出最低 loss 的组合并附 2 至 3 句结论说明该组合为何最优、是否出现过拟合。

扫网格脚本参考实现:

#!/usr/bin/env python
# sweep_lora.py -- LoRA 超参扫网格,找最优 r 与 alpha 组合
import itertools
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset

# 扫网格空间
r_values = [4, 8, 16, 32]
alpha_values = [8, 16, 32, 64]

# 量化配置(复用一份)
model_id = "Qwen/Qwen2.5-7B-Instruct"
bnb = BitsAndBytesConfig(
    load_in_4bit=True, bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# 加载数据集(只需一次)
dataset = load_dataset("json", data_files="data/train.jsonl", split="train")
dataset = dataset.train_test_split(test_size=0.1, seed=42)

results = {}  # {(r, alpha): eval_loss}

for r, alpha in itertools.product(r_values, alpha_values):
    print(f"\n{'='*50}")
    print(f"扫描 r={r}, alpha={alpha}")

    # 每个组合重新加载一份干净基座,避免参数残留
    model = AutoModelForCausalLM.from_pretrained(
        model_id, quantization_config=bnb, device_map="auto", trust_remote_code=True)
    model = prepare_model_for_kbit_training(model)
    model.config.use_cache = False

    lora_config = LoraConfig(
        r=r, lora_alpha=alpha, lora_dropout=0.05, bias="none",
        task_type="CAUSAL_LM",
        target_modules=["q_proj","k_proj","v_proj","o_proj",
                        "gate_proj","up_proj","down_proj"],
    )
    model = get_peft_model(model, lora_config)

    args = SFTConfig(
        output_dir=f"out/sweep-r{r}-a{alpha}",
        num_train_epochs=1,
        max_steps=50,                           # 每个组合只跑 50 步
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,          # 有效 batch = 8
        learning_rate=2e-4,
        bf16=True,
        logging_steps=10,
        eval_strategy="steps",
        eval_steps=50,                          # 训练 50 步后做一次验证
        save_strategy="no",
        max_length=512,
        gradient_checkpointing=True,
        report_to="none",
        seed=42,
    )

    trainer = SFTTrainer(
        model=model, args=args,
        train_dataset=dataset["train"],
        eval_dataset=dataset["test"],
        processing_class=tokenizer,
    )
    trainer.train()

    metrics = trainer.evaluate()
    eval_loss = metrics["eval_loss"]
    results[(r, alpha)] = eval_loss
    print(f"r={r}, alpha={alpha}: eval_loss={eval_loss:.4f}")

    # 释放 GPU 显存,为下一组合腾空间
    del model, trainer
    torch.cuda.empty_cache()

# 打印热力矩阵
print("\n======================================")
print("热力矩阵:eval_loss 矩阵 (行=秩 r, 列=alpha)")
print("======================================")
print(f"{'r \\ alpha':>10}", end="")
for a in alpha_values:
    print(f"{a:>10}", end="")
print()
for r in r_values:
    print(f"{r:>10}", end="")
    for a in alpha_values:
        loss = results.get((r, a))
        if loss is not None:
            print(f"{loss:>10.4f}", end="")
        else:
            print(f"{'N/A':>10}", end="")
    print()

# 找出最优组合
best_combo = min(results, key=results.get)
print(f"\n最优组合:r={best_combo[0]}, alpha={best_combo[1]}, eval_loss={results[best_combo]:.4f}")
print("建议:以该组合为基线,再微调 learning_rate 和 epochs 做第二级优化。")
常见翻车点:忘记 model.config.use_cache = False 导致梯度检查点报警;chat 模板与基座不匹配导致 loss 正常但生成乱套;验证集混入训练样本导致指标虚高。逐条排查后再下结论。
已复制。