第 45 章:中文数据集构建实验
第 45 章:中文数据集构建实验
本文整理自 Datawhale 开源项目 datawhalechina/jev-cookbook(CC BY-NC-SA 4.0),源文件:main/10_本地模型/notebooks/zh_dataset_construction.ipynb。
本 Notebook 保留原有的任务规范、候选生成和人工审核流程;新增一条基于 ModelScope ShareGPT 中文对话的真实语料构造流程。112 条 zh-pilot-112 试验数据与其训练 Notebook 不会被覆盖。
新增数据是对话决策辅助数据:输入为当前用户轮次及此前对话,标签描述下一步回答策略、主题、是否澄清、是否外部核验和推理深度。它不训练模型复述原始 assistant 回答。所有模型标注只作为待审核候选;投票频率是伪标签代理,不是人工概率校准结果。
from pathlib import Path
import sys
ROOT = next(
(parent for parent in (Path.cwd(), *Path.cwd().parents)
if (parent / "laya" / "generate_synthetic_data.py").is_file()),
None,
)
if ROOT is None:
raise FileNotFoundError("请从 jev-cookbook 仓库根目录或 10_本地模型/notebooks 打开本 Notebook")
sys.path.insert(0, str(ROOT / "laya"))
EXAMPLE_SPEC = ROOT / "laya" / "data_generation" / "spec.example.json"
GENERATED_DIR = ROOT / "laya" / "data_generation" / "generated"
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
SPEC_PATH = GENERATED_DIR / "my_task_spec.json"
print("仓库根目录:", ROOT)
print("任务规范副本:", SPEC_PATH)
print("生成目录(Git 忽略):", GENERATED_DIR)1. 复制任务规范并定义标签规则
任务规范决定模型可以生成什么,以及每道题按什么证据贴标签。先阅读 spec.example.json;它定义了客服部门、是否加急和严重程度三个任务。改用自己的领域时,编辑 Notebook 下一格中的路径副本或直接打开 my_task_spec.json,重点完善 state 可见字段、候选定义、labeling_policy、边界案例和版本号。
示例只作为规则格式参考,不要把真实验证集、个人资料或客户秘密放进规范和示例。
import json
from generate_synthetic_data import load_spec
if not SPEC_PATH.exists():
template = json.loads(EXAMPLE_SPEC.read_text(encoding="utf-8"))
SPEC_PATH.write_text(json.dumps(template, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("已复制示例规范。请按业务规则修改该文件,再重新运行本单元格。")
spec = load_spec(SPEC_PATH)
print(json.dumps(spec, ensure_ascii=False, indent=2))标签约定
| 题型 | 在任务规范里定义候选 | API 返回标签 |
|---|---|---|
choice |
按固定顺序排列的对象,key 是标签 ID | 精确返回其中一个 key |
noul |
同时定义 false 和 true |
JSON 布尔值 false / true |
score |
从低到高排列的候选数组 | 从 0 开始的整数等级 |
生成器会把标签转换为候选位置上的整数 y。choice 候选顺序和 score 等级顺序属于标签定义的一部分,不要在规范中随意重排。soft 概率分布不会由生成模型伪造。
# 重新读取并检查规范;字段或标签规则有误时会在这里报出具体问题。
spec = load_spec(SPEC_PATH)
print("任务族:", spec["task_family"])
print("规范版本:", spec["policy_version"])
print("语言:", spec["lang"])
print("题型:", {q["id"]: q["t"] for q in spec["questions"]})
print("人工示例数:", len(spec.get("examples", [])))2. 配置 API 环境变量和并发参数
先在启动 Jupyter 的操作系统会话中设置 DEEPSEEK_API_KEY,再启动或重启 Notebook kernel。具体设置方式见数据构造指南第 2 节。
默认模型是 deepseek-flash(DeepSeek-V4.1-Flash);API 使用 Chat Completions JSON Output。可在运行参数中调整模型、输出条数、每请求 batch size 和并发 worker。workers=4 时最多同时等待 4 个 API 请求;生成器按完成的请求打印进度,而不是逐 token 流式输出。
import os
import uuid
from datetime import datetime, timezone
MODEL = "deepseek-flash"
COUNT = 20
BATCH_SIZE = 4
WORKERS = 4
TEMPERATURE = 0.7
MAX_TOKENS = 4096
TIMEOUT_SECONDS = 120
RUN_GENERATION = False # 确认规则和 API 用量后,手动改为 True
run_stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:6]
task_slug = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in spec["task_family"]).strip("-_") or "laya-data"
OUTPUT_PATH = GENERATED_DIR / (task_slug + "-" + run_stamp + "-candidate.jsonl")
key_is_configured = bool(os.environ.get("DEEPSEEK_API_KEY"))
print("API key 已配置:", key_is_configured, "(只显示状态,不显示内容)")
print("模型:", MODEL, "样本数:", COUNT, "batch:", BATCH_SIZE, "并发:", WORKERS)
print("候选输出:", OUTPUT_PATH)3. 并发生成候选 JSONL
第一次建议生成 10–20 条并逐条复核。Notebook 用 python -u 无缓冲运行脚本,因此每批请求完成后能看到进度。失败时,已成功完成的批次已写入候选文件;本单元格重复运行时会自动追加并跳过重复 state。
import subprocess
if not RUN_GENERATION:
print("已跳过 API 请求。确认 DEEPSEEK_API_KEY、任务规范和用量后,把 RUN_GENERATION 设为 True 并重新运行本单元格。")
elif not key_is_configured:
raise RuntimeError("DEEPSEEK_API_KEY 未设置;请在启动 Jupyter 的终端配置环境变量并重启 kernel。")
else:
command = [
sys.executable, "-u", str(ROOT / "laya" / "generate_synthetic_data.py"),
"--spec", str(SPEC_PATH),
"--count", str(COUNT),
"--batch-size", str(BATCH_SIZE),
"--workers", str(WORKERS),
"--model", MODEL,
"--temperature", str(TEMPERATURE),
"--max-tokens", str(MAX_TOKENS),
"--timeout", str(TIMEOUT_SECONDS),
"--out", str(OUTPUT_PATH),
]
if OUTPUT_PATH.exists():
command.append("--append")
print("启动并发生成;命令中不包含 API key。")
child_env = os.environ.copy()
child_env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1, env=child_env,
)
for line in process.stdout:
print(line, end="", flush=True)
status = process.wait()
if status:
raise subprocess.CalledProcessError(status, command)4. 检查 JSONL、重复项和标签分布
如果上一格被跳过,检查会提示尚无本次输出;生成成功后重新运行本格。候选仍必须逐题审核,统计通过不表示标签正确。
from collections import Counter
if not OUTPUT_PATH.is_file():
audit_records = []
print("没有找到本次候选文件。先启用生成单元格,再运行本格。")
else:
audit_records = [
json.loads(line)
for line in OUTPUT_PATH.read_text(encoding="utf-8").splitlines()
if line.strip()
]
ids = [record["id"] for record in audit_records]
state_keys = [json.dumps(record["state"], ensure_ascii=False, sort_keys=True) for record in audit_records]
assert len(ids) == len(set(ids)), "发现重复 id"
assert len(state_keys) == len(set(state_keys)), "发现重复 state"
assert all(record["split"] == "train_candidate" for record in audit_records)
assert all(record["metadata"]["review_status"] == "needs_human_review" for record in audit_records)
label_counts = {q["id"]: Counter() for q in spec["questions"]}
qspec_by_id = {q["id"]: q for q in spec["questions"]}
for record in audit_records:
assert [q["id"] for q in record["qs"]] == list(qspec_by_id)
for generated_q in record["qs"]:
qspec = qspec_by_id[generated_q["id"]]
y = generated_q["y"]
assert generated_q["t"] == qspec["t"]
if qspec["t"] == "choice":
assert isinstance(y, int) and not isinstance(y, bool) and 0 <= y < len(qspec["crit"])
label = list(qspec["crit"])[y]
elif qspec["t"] == "noul":
assert y in (0, 1) and not isinstance(y, bool)
label = "true" if y == 1 else "false"
else:
assert isinstance(y, int) and not isinstance(y, bool) and 0 <= y < len(qspec["crit"])
label = str(y)
label_counts[generated_q["id"]][label] += 1
audit_summary = {
"file": str(OUTPUT_PATH),
"records": len(audit_records),
"unique_ids": len(set(ids)),
"unique_states": len(set(state_keys)),
"review_status": "needs_human_review",
"label_counts": {qid: dict(counts) for qid, counts in label_counts.items()},
}
print(json.dumps(audit_summary, ensure_ascii=False, indent=2))5. 导出逐题人工审核表
CSV 一行对应一条记录中的一道题,包含 state、规范候选、模型建议标签和证据线索。请审核者填写 decision、corrected_label、reviewer、review_note,并结合原文和规范判断;证据线索不能当作自动通过理由。
import csv
if not audit_records:
REVIEW_CSV = None
print("没有候选记录,暂不生成审核表。")
else:
REVIEW_CSV = OUTPUT_PATH.with_name(OUTPUT_PATH.stem + "-review.csv")
with REVIEW_CSV.open("w", encoding="utf-8-sig", newline="") as csv_file:
columns = [
"record_id", "source_group_id", "task_family", "policy_version", "lang", "state_json",
"question_id", "question_type", "instructions", "labeling_policy", "options_json", "suggested_label",
"review_evidence", "decision", "corrected_label", "reviewer", "review_note",
]
writer = csv.DictWriter(csv_file, fieldnames=columns)
writer.writeheader()
for record in audit_records:
evidence = record["metadata"].get("review_evidence", {})
for question in record["qs"]:
qspec = qspec_by_id[question["id"]]
y = question["y"]
if qspec["t"] == "choice":
label = list(qspec["crit"])[y]
options = qspec["crit"]
elif qspec["t"] == "noul":
label = "true" if y else "false"
options = qspec["crit"]
else:
label = str(y)
options = qspec["crit"]
writer.writerow({
"record_id": record["id"],
"source_group_id": record["source_group_id"],
"task_family": record["task_family"],
"policy_version": record["metadata"]["policy_version"],
"lang": record["lang"],
"state_json": json.dumps(record["state"], ensure_ascii=False),
"question_id": question["id"],
"question_type": question["t"],
"instructions": question["ins"],
"labeling_policy": qspec["labeling_policy"],
"options_json": json.dumps(options, ensure_ascii=False),
"suggested_label": label,
"review_evidence": evidence.get(question["id"], ""),
"decision": "",
"corrected_label": "",
"reviewer": "",
"review_note": "",
})
print("人工审核表:", REVIEW_CSV)
print("审核完成后保留原 JSONL 和审计信息;不要在本 Notebook 中自动晋升 split 或审核状态。")6. 审核后再进入训练
- 逐题核对标签与
labeling_policy,纠正错误或拒绝样本,并记录审核者、规则版本和理由。 - 保留原始 JSONL。把接受的记录复制到新的数据文件中,记录人工审核 metadata,再把
split标为train。 - 合成样本只用于训练。请用独立、人工审核的真实数据构造
dev、校准、锁定 test 和 OOD 集;不要从同一套合成模板随机切出“验证集”。 - 再运行 Laya 微调 Notebook 或阅读微调实操指南。训练器会拒绝未经审核的候选数据。
生成器会拒绝覆盖已有文件。重试同一次生成请保留本 Notebook 内的 OUTPUT_PATH,重新运行生成单元格即可续写;新开 kernel 后请把 OUTPUT_PATH 指向相同候选文件,并在 CLI 指令后加 --append。生成目录已被 Git 忽略,审核前不要将业务数据提交到仓库。
7. 从公开中文对话构造决策辅助数据
这里复用 sharegpt_policy_v2.json 的五类决策问题。每条样本从一段公开对话中选一个 user 轮次,state 只保留当前 user 及此前最多 7 条上下文消息;源对话中紧随其后的 assistant 回复不会进入 state,也不会发送给标注 API。v2 policy 加入了“开放问题可以直接回答”和“澄清策略必须与澄清标记一致”等边界示例。每个原始会话最多产生一条样本,避免同一会话跨 split 泄漏。
默认目标规模为 train 1,200、dev 200、calibration 100、test 400(共 1,900 cases / 9,500 decisions)。敏感标识和 URL 在本地过滤;输出先标记为候选。ModelScope 卡片显示 CC-BY-4.0,同时注明沿用 ShareGPT 许可;社区公开前应核对上游 ShareGPT 条款并完成逐题人工审核。
import subprocess
BUILDER = ROOT / "laya" / "data_generation" / "build_sharegpt_laya.py"
POLICY_PATH = ROOT / "laya" / "data_generation" / "sharegpt_policy_v2.json"
SOURCE_DIR = GENERATED_DIR / "sharegpt_zh_38k"
VERSION_DIR = SOURCE_DIR / "v2"
RAW_PATH = SOURCE_DIR / "raw" / "sharegpt_zh_38K_format.jsonl"
CASES_PATH = VERSION_DIR / "cases.jsonl"
VOTES_PATH = VERSION_DIR / "deepseek_votes.jsonl"
MANIFEST_PATH = VERSION_DIR / "manifest.json"
LAYA_CANDIDATES_PATH = VERSION_DIR / "laya_candidates.jsonl"
REVIEW_CSV_PATH = VERSION_DIR / "human_review.csv"
ENV_FILE = ROOT.parent / "laya" / ".env" # 只把路径传给脚本;密钥不会显示或写入输出
print("原始语料存在:", RAW_PATH.is_file())
print("112 条 pilot 保留:", (ROOT / "laya" / "experiments" / "zh-pilot-112" / "train-dev.jsonl").is_file())
VERSION_DIR.mkdir(parents=True, exist_ok=True)
print("原始语料目录:", SOURCE_DIR)
print("构造版本目录:", VERSION_DIR)7.0 获取 ModelScope 原始语料(缺文件时)
本工作区已下载原始文件。其他环境可将 DOWNLOAD_SOURCE_IF_MISSING 设为 True 下载;文件约 147 MB,保存到 Git 忽略目录。
from urllib.request import urlopen
SOURCE_REVISION = "75412fc0a6a262899c6b99bfa35349d323d0c5c3"
SOURCE_URL = f"https://www.modelscope.cn/datasets/AI-ModelScope/sharegpt_gpt4/resolve/{SOURCE_REVISION}/sharegpt_zh_38K_format.jsonl"
DOWNLOAD_SOURCE_IF_MISSING = False
if DOWNLOAD_SOURCE_IF_MISSING and not RAW_PATH.is_file():
RAW_PATH.parent.mkdir(parents=True, exist_ok=True)
with urlopen(SOURCE_URL, timeout=60) as response, RAW_PATH.open("wb") as output:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
output.write(chunk)
print("原始数据就绪:", RAW_PATH.is_file(), "字节数:", RAW_PATH.stat().st_size if RAW_PATH.is_file() else 0)7.1 本地筛选与分层切分
筛选脚本会校验 JSONL,按固定 seed 选出唯一对话状态,输出 cases.jsonl 和带来源、许可提示、过滤统计的 manifest.json。不打印对话正文。原始语料和生成文件都位于 .gitignore 忽略目录。
RUN_REAL_EXTRACTION = True # 纯本地处理,不产生 API 用量
if RUN_REAL_EXTRACTION:
command = [sys.executable, str(BUILDER), "extract", "--raw", str(RAW_PATH),
"--policy", str(POLICY_PATH), "--out", str(CASES_PATH),
"--manifest", str(MANIFEST_PATH), "--seed", "42",
"--counts", "train=1200,dev=200,calibration=100,test=400"]
completed = subprocess.run(command, cwd=ROOT, check=True, text=True, capture_output=True)
print(completed.stdout)7.2 并发生成多轮伪标签
每个 state 用 3 轮独立 API 请求标注五道问题,保留每票标签;人工审核时回看原始 state。合并时将投票频率写入 soft,供 soft-target SFT / RLCD 数据管线实验使用;三票分辨率有限且由同一模型产生,必须标为 deepseek_vote_frequency_proxy,不能据此报告真实校准。API 请求可能产生费用;脚本支持断点续跑,日志不输出原文或 key。
RUN_REAL_ANNOTATION = False # 需明确检查语料范围后再改 True;此步骤会调用 DeepSeek API
ANNOTATION_BATCH_SIZE = 10
ANNOTATION_WORKERS = 4
ANNOTATION_VOTES = 3
if RUN_REAL_ANNOTATION:
command = [sys.executable, "-u", str(BUILDER), "annotate",
"--cases", str(CASES_PATH), "--policy", str(POLICY_PATH),
"--votes-out", str(VOTES_PATH), "--env-file", str(ENV_FILE),
"--batch-size", str(ANNOTATION_BATCH_SIZE), "--workers", str(ANNOTATION_WORKERS),
"--votes", str(ANNOTATION_VOTES)]
child_env = os.environ.copy()
child_env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", bufsize=1, env=child_env)
for line in process.stdout:
print(line, end="", flush=True)
if process.wait():
raise RuntimeError("标注未完整结束;修复 API 问题后可复用 votes 文件续跑")
else:
print("已跳过 API 标注。设置 RUN_REAL_ANNOTATION=True 后可断点续跑。")7.3 合并为 Laya JSONL、审计并导出审核表
合并要求每条 case 收齐 3 票;y 取多数标签,平票按 policy 中的固定选项顺序处理。split 暂时统一为 train_candidate,原计划用途保存在 metadata.planned_split。人工审核并记录修正、审核者和理由后,再形成真正的 train/dev/calibration/test;未经审核不能直接训练。
if LAYA_CANDIDATES_PATH.is_file():
print("候选数据已存在,跳过 assemble,避免覆盖现有审核表。")
elif REVIEW_CSV_PATH.is_file():
raise FileExistsError(
"审核表已存在,但候选文件缺失。为保护人工复核结果,请先备份审核表,"
"再将重建产物输出到新的版本目录。"
)
elif CASES_PATH.is_file() and VOTES_PATH.is_file():
command = [sys.executable, str(BUILDER), "assemble", "--cases", str(CASES_PATH),
"--policy", str(POLICY_PATH), "--votes", str(VOTES_PATH),
"--out", str(LAYA_CANDIDATES_PATH), "--review-csv", str(REVIEW_CSV_PATH),
"--manifest", str(MANIFEST_PATH), "--required-votes", str(ANNOTATION_VOTES)]
completed = subprocess.run(command, cwd=ROOT, check=True, text=True, capture_output=True)
print(completed.stdout)
audit_cmd = [sys.executable, str(BUILDER), "audit", "--cases", str(CASES_PATH),
"--policy", str(POLICY_PATH), "--votes", str(VOTES_PATH), "--laya", str(LAYA_CANDIDATES_PATH)]
print(subprocess.run(audit_cmd, cwd=ROOT, check=True, text=True, capture_output=True).stdout)
print("逐题人工审核表:", REVIEW_CSV_PATH)
else:
print("尚无完整 votes 文件;先执行本地筛选与标注。")7.4 进入三种微调对比前的门槛
- 保留
zh-pilot-112原样作为小规模流程回归基线;真实公开语料单独存放。 - 从审核 CSV 逐题核对输入上下文、标签和证据,必要时纠正
y/soft;记录 reviewer 和 policy 版本。 - 按
source_group_id建立实际 split;dev、calibration、test 要人工审核。伪标签 test 只能检查管线,不能支撑模型效果结论。 - 三种方法可以共享审核后的 Laya JSONL schema;当前仓库只有 Head-only trainer,LoRA-SFT 和 RLCD 的 trainer 仍待实现。RLCD 可读取
soft,但本数据的 vote frequency 只是教师模型代理。报告中应单独标注这一限制。 - 后续迁移 AutoDL 时,将数据与 manifest 放在实例的持久化数据盘目录,并一并分享来源、许可和审核说明。
7.5 完成人工审核后导出正式 split
审核者需要为每个 case 的五道题填写 decision 和 reviewer;corrected_label 使用候选 key、true/false 或 score 索引。下面的导出器只会晋升五题都已审核、每题都填写审核人的记录;任何空项继续留在候选集中。输出 train-dev.jsonl 可供当前 Head-only trainer 使用,calibration/test 独立写出。
RUN_PROMOTE_REVIEWED = False # 填完并复核 CSV 后显式改为 True
REVIEWED_DIR = VERSION_DIR / "reviewed"
if RUN_PROMOTE_REVIEWED:
command = [sys.executable, str(BUILDER), "promote",
"--candidates", str(LAYA_CANDIDATES_PATH),
"--review-csv", str(REVIEW_CSV_PATH),
"--out-dir", str(REVIEWED_DIR)]
completed = subprocess.run(command, cwd=ROOT, check=True, text=True, capture_output=True)
print(completed.stdout)
else:
print("待人工逐题审核;审核前不会晋升为正式训练/评估 split。")