LLM 后训练实践
第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,消融实验可课后补做