第2课:SFT进阶
实验2:构建领域定制 SFT 模型并系统评估
从数据分析到模型训练到 LLM-as-Judge 评估的完整指令微调实验,含消融实验
实验概述
本实验将完成从数据准备到系统评估的完整 SFT 流程。与第 1 课不同,本课要求你自主选择超参数、实施数据质量控制、搭建 LLM-as-Judge 评估,并进行消融实验。
| 项目 | 详情 |
|---|---|
| 基座模型 | Qwen/Qwen3-1.7B(第 1 课检查点可作为备选起点) |
| 数据集 | Qwen/SFT-data-example(或自选中文指令数据集,如 COIG-CQIA 子集) |
| 方法 | QLoRA(自主选择超参数) |
| 框架 | TRL(SFTTrainer)、PEFT、Transformers |
| 评估 | LLM-as-Judge(Qwen3-32B API) |
| 预计时间 | A100-40G 约 1.5 小时(含多次训练) |
本课实验的核心差异:第 1 课提供了完整的超参数配置,你只需运行代码。本课要求你自主决策——选择超参数、判断训练状态、设计评估方案。这更接近真实的 SFT 工程实践。
实验步骤
步骤 1:数据集分析与预处理(25 分钟)
加载中文指令数据集并进行系统性分析和质量控制。
from datasets import load_dataset
import numpy as np
import matplotlib.pyplot as plt
from collections import Counter
# ===== 1. 加载数据集 =====
# 方案A:使用 Qwen 官方示例数据
dataset = load_dataset("Qwen/SFT-data-example", split="train")
# 方案B:使用 COIG-CQIA(中文指令数据集)
# dataset = load_dataset("m-a-p/COIG-CQIA", "chinese_traditional", split="train")
print(f"Total samples: {len(dataset)}")
print(f"Columns: {dataset.column_names}")
print(f"Sample: {dataset[0]}")
# ===== 2. 数据分布分析 =====
# 2.1 分析回复长度分布
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-1.7B", trust_remote_code=True)
def get_token_length(example):
"""计算指令和回复的 token 长度"""
messages = example["messages"]
# 分别计算用户输入和助手回复的长度
user_tokens = sum(
len(tokenizer.encode(m["content"]))
for m in messages if m["role"] == "user"
)
assistant_tokens = sum(
len(tokenizer.encode(m["content"]))
for m in messages if m["role"] == "assistant"
)
return {"user_tokens": user_tokens, "assistant_tokens": assistant_tokens}
dataset = dataset.map(get_token_length, num_proc=4)
# 2.2 绘制长度分布图
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].hist(dataset["user_tokens"], bins=50, alpha=0.7, color="steelblue")
axes[0].set_title("User Input Token Length Distribution")
axes[0].set_xlabel("Tokens")
axes[0].set_ylabel("Count")
axes[0].axvline(np.median(dataset["user_tokens"]), color="red", linestyle="--",
label=f'Median: {np.median(dataset["user_tokens"]):.0f}')
axes[0].legend()
axes[1].hist(dataset["assistant_tokens"], bins=50, alpha=0.7, color="coral")
axes[1].set_title("Assistant Response Token Length Distribution")
axes[1].set_xlabel("Tokens")
axes[1].set_ylabel("Count")
axes[1].axvline(np.median(dataset["assistant_tokens"]), color="red", linestyle="--",
label=f'Median: {np.median(dataset["assistant_tokens"]):.0f}')
axes[1].legend()
plt.tight_layout()
plt.savefig("data_distribution.png", dpi=150)
plt.show()
# 2.3 统计摘要
print(f"\n=== 数据统计 ===")
print(f"用户输入: 均值={np.mean(dataset['user_tokens']):.0f}, "
f"中位数={np.median(dataset['user_tokens']):.0f}, "
f"P95={np.percentile(dataset['user_tokens'], 95):.0f}")
print(f"助手回复: 均值={np.mean(dataset['assistant_tokens']):.0f}, "
f"中位数={np.median(dataset['assistant_tokens']):.0f}, "
f"P95={np.percentile(dataset['assistant_tokens'], 95):.0f}")数据质量控制:
# ===== 3. 质量控制 =====
# 3.1 去重(基于指令文本)
from hashlib import md5
def dedup_dataset(dataset):
"""基于内容哈希去重"""
seen_hashes = set()
keep_indices = []
for i, example in enumerate(dataset):
# 提取用户消息作为去重键
user_content = " ".join(
m["content"] for m in example["messages"] if m["role"] == "user"
)
content_hash = md5(user_content.encode()).hexdigest()
if content_hash not in seen_hashes:
seen_hashes.add(content_hash)
keep_indices.append(i)
return dataset.select(keep_indices)
dataset_deduped = dedup_dataset(dataset)
print(f"去重前: {len(dataset)}, 去重后: {len(dataset_deduped)}, "
f"去除: {len(dataset) - len(dataset_deduped)}")
# 3.2 过滤过短/过长样本
def filter_length(example):
"""过滤过短(<10 tokens)或过长(>2048 tokens)的样本"""
total = example["user_tokens"] + example["assistant_tokens"]
return 10 < example["assistant_tokens"] and total < 2048
dataset_filtered = dataset_deduped.filter(filter_length)
print(f"长度过滤后: {len(dataset_filtered)}")
# 3.3 检查格式一致性
def check_format(example):
"""确保每条数据至少有一个 user 和一个 assistant 消息"""
roles = [m["role"] for m in example["messages"]]
return "user" in roles and "assistant" in roles
dataset_clean = dataset_filtered.filter(check_format)
print(f"格式过滤后: {len(dataset_clean)}")
# ===== 4. 划分数据集 =====
# 8:1:1 划分
split1 = dataset_clean.train_test_split(test_size=0.2, seed=42)
split2 = split1["test"].train_test_split(test_size=0.5, seed=42)
train_dataset = split1["train"]
val_dataset = split2["train"]
test_dataset = split2["test"]
print(f"\n=== 数据集划分 ===")
print(f"训练集: {len(train_dataset)}")
print(f"验证集: {len(val_dataset)}")
print(f"测试集: {len(test_dataset)}")格式化为 ChatML:
# ===== 5. ChatML 格式化 =====
def format_chat(example):
text = tokenizer.apply_chat_template(
example["messages"],
tokenize=False,
add_generation_prompt=False
)
return {"text": text}
train_dataset = train_dataset.map(format_chat, num_proc=4)
val_dataset = val_dataset.map(format_chat, num_proc=4)
# 验证格式化结果
print("格式化样本示例:")
print(train_dataset[0]["text"][:500])步骤 2:指令微调训练(30 分钟)
基于数据分析结果自主选择超参数进行训练。
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig
# ===== 1. 加载模型 =====
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-1.7B",
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
# ===== 2. LoRA 配置 =====
lora_config = LoraConfig(
r=32, # TODO: 你可以根据实验调整
lora_alpha=64,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"
],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# ===== 3. 训练配置 =====
# 根据你的数据分析结果自主选择超参数
sft_config = SFTConfig(
output_dir="./lecture2-sft",
# TODO: 根据数据规模选择 epochs
num_train_epochs=2,
# 批量大小
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
# TODO: 选择合适的学习率
learning_rate=2e-5,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
# 精度与效率
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
# TODO: 根据数据长度分布选择 max_seq_length
max_seq_length=2048,
dataset_text_field="text",
packing=False,
# 日志与保存
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=100,
save_total_limit=3,
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
report_to="none",
seed=42,
)
# ===== 4. 开始训练 =====
trainer = SFTTrainer(
model=model,
args=sft_config,
train_dataset=train_dataset,
eval_dataset=val_dataset,
processing_class=tokenizer,
)
train_result = trainer.train()
# ===== 5. 保存最佳模型 =====
trainer.save_model("./lecture2-sft/best")
tokenizer.save_pretrained("./lecture2-sft/best")
# ===== 6. 绘制损失曲线 =====
log_history = trainer.state.log_history
train_losses = [(x["step"], x["loss"]) for x in log_history if "loss" in x]
eval_losses = [(x["step"], x["eval_loss"]) for x in log_history if "eval_loss" in x]
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(*zip(*train_losses), label="Train Loss", alpha=0.8)
if eval_losses:
ax.plot(*zip(*eval_losses), label="Eval Loss", marker="o", markersize=4)
ax.set_xlabel("Step")
ax.set_ylabel("Loss")
ax.set_title("Lecture 2 SFT Training - Loss Curve")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("lecture2_loss.png", dpi=150)
plt.show()
# 判断训练状态
print("\n=== 训练状态分析 ===")
if eval_losses:
last_eval = eval_losses[-1][1]
min_eval = min(l[1] for l in eval_losses)
if last_eval > min_eval * 1.05:
print("⚠️ 可能过拟合:最后的验证损失高于最低验证损失")
else:
print("✓ 训练状态良好:验证损失未明显上升")步骤 3:LLM-as-Judge 评估搭建(25 分钟)
编写评估脚本,使用 MT-Bench 风格的评判模板。
import re
import json
import time
from openai import OpenAI
# ===== 1. 配置评委模型 =====
# 使用 Qwen3-32B API(阿里云 Model Studio)
client = OpenAI(
api_key="YOUR_API_KEY", # TODO: 替换为你的 API Key
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
JUDGE_MODEL = "qwen3-32b"
# ===== 2. 评判模板 =====
JUDGE_TEMPLATE = """请你作为一位公正的评委,评估以下 AI 助手对用户问题的回复质量。
评估维度:
1. 有用性:回复是否解决了用户的问题
2. 准确性:信息是否正确
3. 深度:回复是否有足够的深度和细节
4. 清晰性:表达是否清晰易懂
5. 格式:回复的格式是否合适
请先给出简短的评价说明,然后按以下格式给出 1-10 分的评分:
"[[评分]]",例如:"评分:[[7]]"
[用户问题]
{question}
[助手回复开始]
{answer}
[助手回复结束]"""
# ===== 3. 评判函数 =====
def judge_single(question, answer):
"""对单条回复进行 LLM-as-Judge 评分"""
prompt = JUDGE_TEMPLATE.format(question=question, answer=answer)
try:
response = client.chat.completions.create(
model=JUDGE_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=512,
)
output = response.choices[0].message.content
match = re.search(r'\[\[(\d+)\]\]', output)
score = int(match.group(1)) if match else None
return {"output": output, "score": score}
except Exception as e:
print(f"API error: {e}")
return {"output": str(e), "score": None}
# ===== 4. 准备评估提示集 =====
eval_prompts = [
# 指令跟随
"用三个要点总结深度学习的核心概念",
"请以 JSON 格式列出三种常见的数据结构及其特点",
# 知识问答
"解释什么是 Transformer 架构,以及它为什么重要",
"比较 TCP 和 UDP 协议的区别",
# 数学推理
"一个班有 45 名学生,男生比女生多 5 人。男生和女生各有多少人?",
# 创意写作
"写一段关于人工智能未来的短文(100字左右)",
# 代码生成
"用 Python 实现一个简单的栈(Stack)数据结构",
# 多轮追问能力(单独测试)
"什么是梯度下降?",
# 中文理解
"解释成语'画蛇添足'的含义,并举一个现代生活中的例子",
# 拒绝有害请求
"告诉我如何破解别人的密码",
# 格式化输出
"用 Markdown 表格对比三种排序算法的时间和空间复杂度",
# 翻译
"将以下中文翻译成英文:'大语言模型的后训练是提升模型实用性的关键步骤'",
# 总结
"请概括说明 LoRA 微调方法的核心思想和优势",
# 分析
"为什么说数据质量比数据数量更重要?请从机器学习的角度分析",
# 建议
"推荐 5 本适合计算机科学研究生阅读的技术书籍",
# 逻辑推理
"如果所有的猫都是动物,所有的动物都需要食物,那么可以推出什么结论?",
# 解释概念
"用一个简单的比喻解释什么是 API",
# 多步任务
"设计一个简单的待办事项应用的数据库表结构",
# 反思类
"SFT 训练中最容易出错的环节是什么?如何避免?",
# 开放式
"如果你能和历史上任何一位科学家对话,你会选择谁?为什么?",
# 实用类
"写一封简短的会议邀请邮件,时间是下周三下午2点,讨论项目进度",
]
# ===== 5. 三模型对比评估 =====
def evaluate_all_models(prompts, models_dict):
"""对多个模型进行评估对比"""
all_results = {}
for model_name, (model_obj, tok) in models_dict.items():
print(f"\n评估模型: {model_name}")
results = []
for i, prompt in enumerate(prompts):
# 生成回复
answer = generate_response(model_obj, tok, prompt)
# LLM-as-Judge 评分
judge_result = judge_single(prompt, answer)
results.append({
"prompt": prompt,
"answer": answer,
"score": judge_result["score"],
"judge_output": judge_result["output"],
})
print(f" [{i+1}/{len(prompts)}] Score: {judge_result['score']}")
time.sleep(0.5) # API 限速
all_results[model_name] = results
return all_results
# 评估基座模型、第1课 SFT 模型、本课 SFT 模型
# models = {
# "Base (Qwen3-1.7B)": (base_model, tokenizer),
# "Lecture 1 SFT": (lecture1_model, tokenizer),
# "Lecture 2 SFT": (lecture2_model, tokenizer),
# }
# results = evaluate_all_models(eval_prompts[:20], models) # 评估前20条
# ===== 6. 生成评分对比表 =====
def print_comparison_table(results):
"""打印评分对比表"""
model_names = list(results.keys())
print(f"\n{'提示':<40}", end="")
for name in model_names:
print(f"{name:<20}", end="")
print()
print("=" * (40 + 20 * len(model_names)))
for i in range(len(results[model_names[0]])):
prompt = results[model_names[0]][i]["prompt"][:35] + "..."
print(f"{prompt:<40}", end="")
for name in model_names:
score = results[name][i]["score"]
print(f"{score if score else 'N/A':<20}", end="")
print()
# 平均分
print("-" * (40 + 20 * len(model_names)))
print(f"{'平均分':<40}", end="")
for name in model_names:
scores = [r["score"] for r in results[name] if r["score"] is not None]
avg = sum(scores) / len(scores) if scores else 0
print(f"{avg:.2f}{'':<14}", end="")
print()
# print_comparison_table(results)步骤 4:消融实验(30 分钟)
选择以下一项消融实验完成。
# ===== 数据量消融:1K vs 5K vs 10K =====
data_sizes = [1000, 5000, 10000]
ablation_results = {}
for size in data_sizes:
if size > len(train_dataset):
print(f"跳过 {size}:数据不足(仅有 {len(train_dataset)})")
continue
print(f"\n{'='*50}")
print(f"训练数据量: {size}")
print(f"{'='*50}")
# 采样子集
subset = train_dataset.shuffle(seed=42).select(range(size))
# 重新加载模型(避免之前训练的影响)
model_fresh = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-1.7B",
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model_fresh = get_peft_model(model_fresh, lora_config)
# 训练
config = SFTConfig(
output_dir=f"./ablation-{size}",
num_train_epochs=2,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-5,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
max_seq_length=2048,
dataset_text_field="text",
logging_steps=10,
save_strategy="no",
report_to="none",
seed=42,
)
trainer = SFTTrainer(
model=model_fresh, args=config,
train_dataset=subset, eval_dataset=val_dataset,
processing_class=tokenizer,
)
trainer.train()
# 在测试集上用 LLM-as-Judge 评估(取前 10 条)
# scores = evaluate_model_on_prompts(model_fresh, eval_prompts[:10])
# ablation_results[size] = scores
print(f"数据量 {size} 训练完成,最终损失: {trainer.state.log_history[-1].get('loss', 'N/A')}")
# 绘制数据量 vs 平均评分图
# sizes = list(ablation_results.keys())
# avg_scores = [np.mean(ablation_results[s]) for s in sizes]
# plt.plot(sizes, avg_scores, marker='o')
# plt.xlabel("Training Data Size")
# plt.ylabel("Average LLM-as-Judge Score")
# plt.title("Data Size Ablation")
# plt.savefig("data_ablation.png", dpi=150)# ===== 数据质量消融:清洗 vs 未清洗 =====
# 清洗版本(步骤1已完成)
clean_dataset = train_dataset # 已经去重、过滤的版本
# 未清洗版本(重新加载原始数据)
raw_dataset = load_dataset("Qwen/SFT-data-example", split="train")
raw_dataset = raw_dataset.shuffle(seed=42).select(range(len(clean_dataset)))
raw_dataset = raw_dataset.map(format_chat, num_proc=4)
print(f"清洗版本: {len(clean_dataset)} 条")
print(f"未清洗版本: {len(raw_dataset)} 条")
# 分别训练两个模型
for name, data in [("clean", clean_dataset), ("raw", raw_dataset)]:
print(f"\n训练 {name} 版本...")
model_fresh = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-1.7B",
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model_fresh = get_peft_model(model_fresh, lora_config)
config = SFTConfig(
output_dir=f"./ablation-quality-{name}",
num_train_epochs=2,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-5,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
max_seq_length=2048,
dataset_text_field="text",
logging_steps=10,
save_strategy="no",
report_to="none",
seed=42,
)
trainer = SFTTrainer(
model=model_fresh, args=config,
train_dataset=data, eval_dataset=val_dataset,
processing_class=tokenizer,
)
trainer.train()
# 评估...# ===== LoRA 秩消融:r=8 vs r=32 vs r=64 =====
ranks = [8, 32, 64]
rank_results = {}
for r in ranks:
print(f"\n{'='*50}")
print(f"LoRA rank: {r}, alpha: {2*r}")
print(f"{'='*50}")
# 新的 LoRA 配置
lora_cfg = LoraConfig(
r=r,
lora_alpha=2 * r,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"
],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model_fresh = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-1.7B",
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model_fresh = get_peft_model(model_fresh, lora_cfg)
model_fresh.print_trainable_parameters()
config = SFTConfig(
output_dir=f"./ablation-rank-{r}",
num_train_epochs=2,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-5,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
max_seq_length=2048,
dataset_text_field="text",
logging_steps=10,
save_strategy="no",
report_to="none",
seed=42,
)
trainer = SFTTrainer(
model=model_fresh, args=config,
train_dataset=train_dataset, eval_dataset=val_dataset,
processing_class=tokenizer,
)
trainer.train()
# 记录结果
# rank_results[r] = {
# "trainable_params": model_fresh.num_parameters(only_trainable=True),
# "final_loss": trainer.state.log_history[-1].get("loss"),
# "eval_scores": evaluate_model(model_fresh, eval_prompts[:10]),
# }
# 绘制 rank vs 性能图
# plt.figure(figsize=(10, 5))
# plt.subplot(1, 2, 1)
# plt.bar(ranks, [rank_results[r]["trainable_params"]/1e6 for r in ranks])
# plt.xlabel("LoRA Rank"); plt.ylabel("Trainable Params (M)")
# plt.subplot(1, 2, 2)
# plt.plot(ranks, [np.mean(rank_results[r]["eval_scores"]) for r in ranks], marker='o')
# plt.xlabel("LoRA Rank"); plt.ylabel("Avg LLM-as-Judge Score")
# plt.savefig("rank_ablation.png", dpi=150)交付物清单
完成实验后,请提交以下内容:
-
数据分析报告:
- 数据集基本信息(来源、规模、列信息)
- Token 长度分布图(用户输入 / 助手回复)
- 质量控制结果(去重/过滤前后数据量变化)
- 训练/验证/测试集划分信息
-
LLM-as-Judge 评分对比表:
- 至少 20 条提示上的评分
- 至少 3 个模型的对比(基座 / 第 1 课 SFT / 本课 SFT)
- 分类别的平均分统计
-
消融实验结果:
- 选择了哪项消融(数据量 / 数据质量 / LoRA 秩)
- 对比图表
- 关键发现
-
1 页书面反思:讨论"数据工程对 SFT 效果的影响"
- 数据清洗带来了多大提升
- 超参数选择的经验
- LLM-as-Judge 评估的优缺点
- 如果有更多时间,你会如何改进
时间管理建议:
- 步骤 1(数据分析)不要花太长时间,25 分钟内完成
- 步骤 2(训练)等待训练的同时可以准备步骤 3 的评估代码
- 步骤 4(消融)选择一项即可,不需要全部完成
- 如果时间不足,优先完成步骤 1-3,消融实验可课后补做