全量微调 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 消费级显卡即可完成训练。
用 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 并行计算,输出相加。
下面用矩阵视角再看一次 LoRA 的结构:冻结的 W 与可训练的 B、A 各自占什么维度,乘积 B·A 如何得到与 W 同形的增量。
推理时可把 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
下面的完整脚本把上面所有步骤串成一条可直接启动的训练流水线:
#!/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"])
PEFT + TRL 实战
Hugging Face 的 peft 负责注入适配器,trl 提供 SFTTrainer 等训练封装。先看整体流水线,再给出可直接运行的完整脚本。
完整训练脚本(接着上面 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 回升即应停止 |
合并、推理与部署
关键超参:学习率(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.product 对 r=[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 正常但生成乱套;验证集混入训练样本导致指标虚高。逐条排查后再下结论。