人工智能实践(语言智能)
第5讲:数据工程

实验 5:构建一个中文领域 SFT 数据集

从零构建 500-1000 条中文垂直领域(法律咨询 / 学术写作润色)的 SFT 数据集,覆盖种子 → 合成 → 过滤 → 格式化 → 评估全流程

实验概述

本实验将带你独立完成一支可用于真实 SFT 训练的小型领域数据集(500-1000 条),并交付一份可复现的构建文档。

项目详情
领域(任选其一)A. 中文法律咨询问答;B. 中文学术写作润色
目标规模500-1000 条
生成模型GPT-4o / Claude-3.5-Sonnet / DeepSeek-V3(任选)
Judge 模型应强于生成模型,或至少同级别
最终格式ShareGPT(或 OpenAI messages)
预计时间90-120 分钟

选题建议:如果没有特殊偏好,建议选择 "学术写作润色" ——容易界定、评测直观、与研究生工作场景高度相关。


实验步骤

步骤 1:领域定位与种子设计(15 分钟)

定位清单(必须先写完再开工):

领域: 中文学术写作润色
覆盖任务类型:
  - T1: 句子级润色(去冗余、改被动、调语序)
  - T2: 段落衔接(补充过渡、修改承接关系)
  - T3: 术语一致性(专有名词首次全称 + 缩写)
  - T4: 学术风格改写(口语化 → 学术化)
  - T5: 参考文献格式转换(GB/T 7714 ↔ APA)
输出长度: assistant 200-800 字
禁忌: 不改变原意、不删除关键信息、保留作者署名引用

种子编写:为每个任务类型手写 3-5 条高质量种子样本。总计 15-25 条种子,这是后续合成的"骨架"。

示例种子(T1 句子级润色):

{
  "user_msg": "请润色以下句子,使其更符合学术写作规范:\n\n本研究使用了我们自己开发的一个新方法,这个方法很新颖,可以解决以前的一些问题。",
  "assistant_msg": "修改建议:\n\n**原句**:本研究使用了我们自己开发的一个新方法,这个方法很新颖,可以解决以前的一些问题。\n\n**润色**:本研究提出了一种新颖的方法,可有效解决现有工作中的若干关键问题。\n\n**修改理由**:\n1. 删除"我们自己开发"——在学术语境下隐含于"本研究提出";\n2. "很新颖"改为"新颖的"——避免口语化副词;\n3. "以前的一些问题"替换为"现有工作中的若干关键问题"——更准确、更学术。"
}

步骤 2:用 LLM 生成候选(30 分钟)

调用 API 批量扩增。建议采用 Self-Instruct + 显式任务类型指定 的混合策略。

import json, time
from openai import OpenAI

client = OpenAI()  # 或 DeepSeek 兼容端点

GEN_PROMPT = """你是一位帮助构建 SFT 训练数据的专家。现在需要为【中文学术写作润色】领域生成一条高质量训练样本。

任务类型:{task_type}
任务说明:{task_desc}

要求:
1. user 消息:构造一个真实研究生可能提出的润色请求(50-300 字);
2. assistant 消息:给出结构化的润色方案,包含"原句/润色/修改理由"三段;
3. 风格严肃、学术、准确;不要以"作为 AI"开头。

仅返回 JSON:{{"user_msg": "...", "assistant_msg": "..."}}

参考示例:
{few_shot}
"""

TASK_TYPES = {
    "T1": "句子级润色(去冗余、改被动、调语序)",
    "T2": "段落衔接",
    "T3": "术语一致性",
    "T4": "口语化 → 学术化",
    "T5": "参考文献格式转换",
}

def generate_one(task_type, seeds_for_type):
    few_shot = "\n\n".join([json.dumps(s, ensure_ascii=False) for s in seeds_for_type[:2]])
    prompt = GEN_PROMPT.format(
        task_type=task_type,
        task_desc=TASK_TYPES[task_type],
        few_shot=few_shot,
    )
    resp = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.9,
        response_format={"type": "json_object"},
    )
    return json.loads(resp.choices[0].message.content)

# 为每个任务类型生成 200-300 条
candidates = []
for tt, seeds in seeds_by_type.items():
    for _ in range(250):
        try:
            candidates.append({"task_type": tt, **generate_one(tt, seeds)})
        except Exception as e:
            print(f"skip: {e}")
        time.sleep(0.3)  # 避免速率限制

with open("candidates.jsonl", "w", encoding="utf-8") as f:
    for c in candidates:
        f.write(json.dumps(c, ensure_ascii=False) + "\n")

print(f"生成候选: {len(candidates)}")

控制 temperature:0.7-0.9 是甜蜜点。温度 < 0.5 会导致高度重复,> 1.0 容易失控漂移。

步骤 3:Judge 过滤(25 分钟)

使用更强的模型(如 Claude-3.5 或 GPT-4o)做多维度评分与过滤。

JUDGE_PROMPT = """你是一位严格的 SFT 数据质检员。请从以下维度对(指令, 回复)对评分(1-5,5 最好):
- relevance: 回复是否正确响应了润色请求?
- accuracy: 润色建议是否正确(不破坏原意、符合学术规范)?
- structure: 是否按"原句/润色/修改理由"结构输出?
- fluency: 语言是否自然、准确?
- originality: 是否与示例相似度过高(1=极相似,5=很独特)?

只返回 JSON:{{"relevance": x, "accuracy": x, "structure": x, "fluency": x, "originality": x, "overall": x, "keep": true|false}}

指令:{user}
回复:{resp}
"""

def judge(sample):
    prompt = JUDGE_PROMPT.format(user=sample["user_msg"], resp=sample["assistant_msg"])
    r = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.0,
        response_format={"type": "json_object"},
    )
    return json.loads(r.choices[0].message.content)

filtered = []
for s in candidates:
    try:
        scores = judge(s)
        s["_scores"] = scores
        if scores.get("overall", 0) >= 4 and scores.get("keep", False):
            filtered.append(s)
    except Exception as e:
        continue

print(f"Judge 通过: {len(filtered)} / {len(candidates)}")

附加自动过滤(参考 5.3 节):

# 语义去重 + 长度过滤
from sentence_transformers import SentenceTransformer
import numpy as np

emb_model = SentenceTransformer("BAAI/bge-m3")
embs = emb_model.encode([s["user_msg"] for s in filtered], normalize_embeddings=True)
keep = np.ones(len(filtered), dtype=bool)
for i in range(len(filtered)):
    if not keep[i]: continue
    sim = embs[i] @ embs[i+1:].T
    keep[i+1:][sim > 0.92] = False

final_candidates = [
    s for s, k in zip(filtered, keep)
    if k
    and 30 <= len(s["user_msg"]) <= 1000
    and 100 <= len(s["assistant_msg"]) <= 2500
]
print(f"最终候选: {len(final_candidates)}")

步骤 4:人工抽检 20 条(15 分钟)

自动过滤不能替代人工把关。随机抽样 20 条,按以下清单打标签:

样本ID相关性准确性结构流畅性致命问题备注
001
002
...

若抽检 20 条中 > 3 条(15%)存在致命问题,必须调整 Judge 阈值或 Prompt 后重跑

步骤 5:格式转换为 ShareGPT(5 分钟)

def to_sharegpt(s):
    return {
        "id": f"academic_polish_{hash(s['user_msg']) % 10**8:08d}",
        "task_type": s["task_type"],
        "conversations": [
            {"from": "human", "value": s["user_msg"]},
            {"from": "gpt",   "value": s["assistant_msg"]},
        ],
        "quality_score": s["_scores"]["overall"],
    }

sharegpt_data = [to_sharegpt(s) for s in final_candidates]

# 保存为 JSONL
with open("academic_polish_sft.jsonl", "w", encoding="utf-8") as f:
    for item in sharegpt_data:
        f.write(json.dumps(item, ensure_ascii=False) + "\n")

print(f"最终数据集: {len(sharegpt_data)} 条")

(可选)同步生成 OpenAI messages 格式

def to_openai_messages(s):
    return {
        "messages": [
            {"role": "system", "content": "你是一位中文学术写作润色助手。"},
            {"role": "user",   "content": s["user_msg"]},
            {"role": "assistant", "content": s["assistant_msg"]},
        ]
    }

步骤 6:评估多样性与质量(10 分钟)

任务类型分布

from collections import Counter
dist = Counter(s["task_type"] for s in final_candidates)
print("任务类型分布:")
for k, v in dist.most_common():
    print(f"  {k}: {v} ({v/len(final_candidates)*100:.1f}%)")

Distinct-n 多样性

def distinct_n(texts, n=3):
    all_ngrams = set()
    total = 0
    for t in texts:
        toks = list(t)  # 中文按字
        grams = [tuple(toks[i:i+n]) for i in range(len(toks)-n+1)]
        all_ngrams.update(grams)
        total += len(grams)
    return len(all_ngrams) / max(total, 1)

assts = [s["assistant_msg"] for s in final_candidates]
print(f"Distinct-3: {distinct_n(assts, 3):.4f}")  # 期望 > 0.4

Embedding 聚类覆盖

from sklearn.cluster import KMeans
embs = emb_model.encode(assts, normalize_embeddings=True)
kmeans = KMeans(n_clusters=20, random_state=42, n_init=10).fit(embs)
cluster_sizes = Counter(kmeans.labels_)
max_ratio = max(cluster_sizes.values()) / len(assts)
print(f"最大簇占比: {max_ratio:.2%}(< 15% 为佳)")

质量评分分布

import numpy as np
overalls = [s["_scores"]["overall"] for s in final_candidates]
print(f"overall 均值={np.mean(overalls):.2f} p25={np.percentile(overalls, 25):.1f} p50={np.percentile(overalls, 50):.1f}")

交付物清单

完成实验后,请提交以下内容:

  • 数据集文件academic_polish_sft.jsonl(500-1000 条,ShareGPT 格式)
  • 种子文件seeds.jsonl(15-25 条人工种子)
  • 构建脚本pipeline.py(含生成、Judge、过滤、格式化)
  • 人工抽检表manual_review.csv(20 条人工打分)
  • 报告(≤ 2 页),内容包括:
    • 任务类型分布与目标覆盖
    • 候选 → Judge → 去重 → 人工抽检各阶段的数据量漏斗
    • Distinct-3、最大簇占比、overall 评分均值
    • 3 条典型"好样本"与 3 条"失败样本"的对比分析
    • 若要再扩充到 1 万条,你会改进哪些环节

加分项(可选)

  • 用该数据集在 Qwen3-1.7B 上真实跑一轮 LoRA SFT(参考第 1 讲实验),对比微调前后在 10 条新提问上的表现;
  • 对数据集做 13-gram 去污染扫描(相对 CMMLU、C-Eval)并附扫描报告;
  • 加入对抗样本(10 条刻意给出不合规或有歧义的请求),让模型学会拒绝或澄清。

常见问题

Q1:生成温度多少合适? A:0.7-0.9。温度过低同质化,过高漂移。可用"温度阶梯"——200 条 0.7、200 条 0.9、100 条 1.0,再用 Judge 筛选。

Q2:Judge 判断不一致怎么办? A:使用配对比较而非单点打分(见 Nemotron-4 经验),或用多 Judge(2-3 个不同模型)多数表决。

Q3:能否直接用一个已有的开源数据集? A:不能作为本次实验交付物——本次实验的核心是"走完整条管线"。但可以将开源数据作为种子的一部分(明确标注来源)。

Q4:500 条够训练出可用模型吗? A:LIMA 的 1000 条已经能把 Llama 训出可用助手。500 条垂直领域数据在 LoRA 微调下,通常能看到明显的任务特化效果。