第 47 章

第 47 章:全量 v2 微调实验

第 47 章:全量 v2 微调实验

本文整理自 Datawhale 开源项目 datawhalechina/jev-cookbook(CC BY-NC-SA 4.0),源文件:main/10_本地模型/notebooks/full_v2_finetuning.ipynb。

本 Notebook 把三套训练脚本组织成同一数据、同一拆分、同一评估口径的对照实验。它是启动与比较入口;真正的训练逻辑仍由 finetune_reviewed_jsonl.py 和 finetune_variant_jsonl.py 执行。

方法 更新哪些参数 训练目标 这组对照想回答什么
Head-only 只更新决策 head,encoder 冻结 soft cross-entropy 只适配输出头是否已足够?
LoRA-SFT 冻结 encoder 主权重,在注意力投影加 LoRA,并更新 head soft cross-entropy 少量可训练参数能否提升效果?
RLCD-style 更新 encoder 与 head,以探索采样、proper-score 奖励、组基线和辅助 CE 优化 本项目的 RLCD-style 实现 直接调整表征并加入概率决策信号是否有收益?

三者使用同一 train/dev/calibration/test 来源组切分与随机种子;学习率按方法分别设置。checkpoint 只按 dev 选择,定版后才评估 calibration/test。这里的 RLCD-style 是本项目的实验实现,不是上游官方 RLCD 配方的完整复现。

重要:首次打开时不会训练、下载模型或语料,也不会调用标注 API。 训练开关、伪标签授权和大文件下载均默认关闭。全量实验训练耗时较长,本 Notebook 只提供可审阅的启动配置;请在确认数据、GPU 和预算后自行开启训练。

当前 v2 数据是 DeepSeek 投票产生、尚待人工审核的伪标签。实验指标仅反映模型对这些代理标签的拟合,不是人工金标准确率或概率校准结论。重新生成标签只需 DeepSeek API key;训练、读取已有数据和运行 notebook 不需要 Jev API key。数据与训练流程说明见服务器项目中的 微调指南;历史全量实验指标见 留出集评估 JSON]。

AutoDL 一键启动

Notebook 放在 /root/autodl-tmp/full_v2_finetuning.ipynb,训练脚本在 /root/autodl-tmp/laya-training-project/laya/,候选数据、模型权重和实验输出均使用 AutoDL 持久盘路径。服务器已准备 /root/autodl-tmp/laya-venv、模型和数据后,在 SSH 终端执行一条命令即可启动 JupyterLab 并打开本 Notebook:

bash /root/autodl-tmp/start_autodl_full_v2_finetuning.sh

默认只启动 JupyterLab,不会自动开始训练、下载或调用标注 API。Notebook 中 RUN_TRAINING、DOWNLOAD_MODEL_IF_NEEDED 和 DeepSeek 标注开关仍默认关闭;首次训练伪标签还需显式开启授权。Jupyter 只监听 127.0.0.1:8888。在本地电脑另开终端建立 SSH 隧道:

ssh -p 29470 -L 8888:127.0.0.1:8888 root@connect.nmb2.seetacloud.com

然后打开启动日志给出的本地 JupyterLab 地址和 token。启动脚本会检查项目脚本、候选数据、模型文件及 JupyterLab 是否齐全,并将训练结果写入 /root/autodl-tmp/experiments/laya/。

from pathlib import Path
from datetime import datetime
import os
import uuid
import sys

cwd = Path.cwd().expanduser().resolve()
root_candidates = []
if os.environ.get("LAYA_PROJECT_ROOT"):
    root_candidates.append(Path(os.environ["LAYA_PROJECT_ROOT"]).expanduser())
root_candidates.extend((cwd, *cwd.parents))
root_candidates.extend(parent / "laya-training-project" for parent in (cwd, *cwd.parents))
ROOT = next(
    (candidate.resolve() for candidate in root_candidates
     if (candidate / "laya" / "finetune_reviewed_jsonl.py").is_file()),
    None,
)
if ROOT is None:
    raise FileNotFoundError(
        "找不到 Laya 训练脚本。请设置 LAYA_PROJECT_ROOT,或从项目目录及其子目录打开本 Notebook。"
    )
sys.path.insert(0, str(ROOT))

LAYA_DIR = ROOT / "laya"
GENERATED_DIR = LAYA_DIR / "data_generation" / "generated" / "sharegpt_zh_38k"
DATA_DIR = GENERATED_DIR / "v2"
RAW_DIR = GENERATED_DIR / "raw"
POLICY_PATH = LAYA_DIR / "data_generation" / "sharegpt_policy_v2.json"
RAW_PATH = RAW_DIR / "sharegpt_zh_38K_format.jsonl"
CASES_PATH = DATA_DIR / "cases.jsonl"
VOTES_PATH = DATA_DIR / "deepseek_votes.jsonl"
AUTODL_CANDIDATES = Path(
    "/root/autodl-tmp/datasets/laya-datasets/sharegpt_zh_38k/v2/laya_candidates.jsonl"
)
DEFAULT_CANDIDATES = (
    AUTODL_CANDIDATES if AUTODL_CANDIDATES.is_file()
    else DATA_DIR / "laya_candidates.jsonl"
)
CANDIDATES_PATH = Path(
    os.environ.get("LAYA_CANDIDATES_PATH", str(DEFAULT_CANDIDATES))
).expanduser()
REVIEW_CSV_PATH = DATA_DIR / "human_review.csv"
AUTODL_MODEL_DIR = Path("/root/autodl-tmp/models/laya/multilingual")
DEFAULT_MODEL_DIR = (
    AUTODL_MODEL_DIR if AUTODL_MODEL_DIR.is_dir()
    else LAYA_DIR / "models" / "multilingual"
)
MODEL_DIR = Path(os.environ.get("LAYA_MODEL_DIR", str(DEFAULT_MODEL_DIR))).expanduser()

# AutoDL 预留数据盘优先;其他机器默认写入用户目录。可用 LAYA_OUTPUT_DIR 覆盖。
AUTODL_OUTPUT = Path("/root/autodl-tmp/experiments/laya")
DEFAULT_OUTPUT = AUTODL_OUTPUT if AUTODL_OUTPUT.exists() else Path.home() / "laya-runs"
OUTPUT_ROOT = Path(os.environ.get("LAYA_OUTPUT_DIR", str(DEFAULT_OUTPUT))).expanduser()
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)

RUN_ID = os.environ.get("LAYA_RUN_ID") or (datetime.now().strftime("full-v2-%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6])
RUN_DIR = OUTPUT_ROOT / RUN_ID
SPLIT_DIR = OUTPUT_ROOT / (RUN_ID + "-splits")

print("仓库:", ROOT)
print("候选数据:", CANDIDATES_PATH)
print("模型目录:", MODEL_DIR)
print("实验输出:", RUN_DIR)
print("切分输出:", SPLIT_DIR)

1. 检查训练环境

本 Notebook 不会自动替换 PyTorch,因为需要保留与本机 NVIDIA 驱动匹配的 CUDA wheel。若缺少 Transformers、Safetensors、NumPy 或 PEFT,可以用下面的开关安装;安装后重启 kernel 并从第一格重新运行。

import importlib.util
import subprocess

INSTALL_MISSING_PACKAGES = False  # 远程环境默认不自动改依赖;确认后可显式开启
optional_packages = {
    "transformers": "transformers>=4.45",
    "safetensors": "safetensors>=0.4",
    "numpy": "numpy>=1.26",
    "peft": "peft>=0.17",
}
missing_packages = [
    package_spec for module_name, package_spec in optional_packages.items()
    if importlib.util.find_spec(module_name) is None
]
if importlib.util.find_spec("torch") is None:
    raise RuntimeError(
        "当前 Python 没有 PyTorch。请先按本机 NVIDIA 驱动安装 CUDA 版 PyTorch,再重启 Jupyter kernel。"
    )
if missing_packages:
    if not INSTALL_MISSING_PACKAGES:
        raise RuntimeError("缺少依赖: " + ", ".join(missing_packages) + "。请在当前 kernel 对应环境安装 laya/requirements.txt 与 peft>=0.17,再重启 kernel。")
    subprocess.run(
        [sys.executable, "-m", "pip", "install", *missing_packages],
        check=True,
    )
    raise RuntimeError("依赖已安装。请重启 Jupyter kernel,然后从第一格重新运行。")

import torch
import transformers
import safetensors
import numpy as np
import peft

print("Python:", sys.version.split()[0])
print("PyTorch:", torch.__version__, "| CUDA runtime:", torch.version.cuda)
print("Transformers:", transformers.__version__)
print("Safetensors:", safetensors.__version__, "| NumPy:", np.__version__, "| PEFT:", peft.__version__)
print("CUDA available:", torch.cuda.is_available())
if not torch.cuda.is_available():
    mps_available = bool(getattr(torch.backends, "mps", None) and torch.backends.mps.is_available())
    if mps_available:
        raise RuntimeError(
            "检测到 Apple MPS,但当前 Head-only、LoRA 和 RLCD-style 训练器只支持 CUDA。"
            "请在 NVIDIA CUDA 机器上运行微调;MPS 可用于推理。"
        )
    raise RuntimeError(
        "当前 kernel 没有可用 CUDA。请使用 Windows/Linux NVIDIA GPU 环境,并安装匹配的 CUDA 版 PyTorch。"
    )
GPU_NAME = torch.cuda.get_device_name(0)
GPU_VRAM_GIB = torch.cuda.get_device_properties(0).total_memory / 1024**3
print("GPU:", GPU_NAME, "| 显存 GiB:", round(GPU_VRAM_GIB, 1))

2. 获取 Laya 多语言 checkpoint

模型文件保存在仓库忽略目录 laya/models/multilingual,不会写进 Git。若本机没有完整 checkpoint,Notebook 会安装 ModelScope Hub CLI 并下载 multilingual 子目录。

import shutil
import sysconfig

DOWNLOAD_MODEL_IF_NEEDED = False  # 模型约 644 MB;首次下载前请确认网络与磁盘空间
required_model_files = [
    MODEL_DIR / "model.safetensors",
    MODEL_DIR / "rl_agent_config.json",
    MODEL_DIR / "encoder",
    MODEL_DIR / "tokenizer",
]
missing_model_files = [str(path) for path in required_model_files if not path.exists()]
if missing_model_files:
    if not DOWNLOAD_MODEL_IF_NEEDED:
        raise FileNotFoundError(
            "模型文件尚未就绪。请预先下载至 LAYA_MODEL_DIR,或确认空间后将 "
            "DOWNLOAD_MODEL_IF_NEEDED 改为 True。缺少: " + ", ".join(missing_model_files)
        )
    subprocess.run([sys.executable, "-m", "pip", "install", "modelscope-hub"], check=True)
    cli_name = "ms-hub.exe" if os.name == "nt" else "ms-hub"
    cli_path = Path(sysconfig.get_path("scripts")) / cli_name
    if not cli_path.is_file():
        found_cli = shutil.which("ms-hub")
        if found_cli:
            cli_path = Path(found_cli)
        else:
            raise FileNotFoundError("找不到 ms-hub;安装 ModelScope Hub 后请重启 kernel 并重跑本格。")
    subprocess.run(
        [
            str(cli_path), "download", "convaiinnovations/laya",
            "--local-dir", str(MODEL_DIR.parent),
            "--include", "multilingual/**",
        ],
        check=True,
    )

missing_model_files = [str(path) for path in required_model_files if not path.exists()]
if missing_model_files:
    raise FileNotFoundError("模型文件仍不完整: " + ", ".join(missing_model_files))
print("多语言模型已就绪:", MODEL_DIR)

3. 复用或构造全量对话决策数据

现有数据路径为 laya/data_generation/generated/sharegpt_zh_38k/v2/laya_candidates.jsonl。新环境没有候选集时,以下格子会按固定 revision 下载公开 ShareGPT 中文对话并筛出 1,900 个来源组。原始 assistant 回复不会进入 state 或标签提示。

DeepSeek 三轮投票需要 API key,Notebook 会读取环境变量 DEEPSEEK_API_KEY,或从 laya/.env 读取;不会回显密钥。打开标注开关前,请确认对数据的发送范围、授权和费用。

# 固定公开数据 revision,确保重建输入来源可追溯。
SOURCE_REVISION = "75412fc0a6a262899c6b99bfa35349d323d0c5c3"
SOURCE_URL = (
    "https://www.modelscope.cn/datasets/AI-ModelScope/sharegpt_gpt4/resolve/"
    + SOURCE_REVISION + "/sharegpt_zh_38K_format.jsonl"
)
DOWNLOAD_SOURCE_IF_NEEDED = (os.environ.get("LAYA_DOWNLOAD_SOURCE", "0") == "1"
                             and not CANDIDATES_PATH.is_file()
                             and not RAW_PATH.is_file())
RUN_EXTRACTION_IF_NEEDED = not CANDIDATES_PATH.is_file() and not CASES_PATH.is_file()

RAW_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)

if not CANDIDATES_PATH.is_file() and not RAW_PATH.is_file() and not DOWNLOAD_SOURCE_IF_NEEDED:
    raise FileNotFoundError(
        "找不到候选数据或原始语料。请设置 LAYA_CANDIDATES_PATH,或准备 RAW_PATH;"
        "确认需要联网下载后,在启动前设置 LAYA_DOWNLOAD_SOURCE=1。"
    )

if DOWNLOAD_SOURCE_IF_NEEDED:
    from urllib.request import Request, urlopen

    print("下载公开语料:", SOURCE_URL)
    request = Request(SOURCE_URL, headers={"User-Agent": "Laya-dataset-notebook/1.0"})
    temporary_raw = RAW_PATH.with_suffix(RAW_PATH.suffix + ".partial")
    total_bytes = 0
    with urlopen(request, timeout=60) as response, temporary_raw.open("wb") as out:
        while True:
            chunk = response.read(8 * 1024 * 1024)
            if not chunk:
                break
            out.write(chunk)
            total_bytes += len(chunk)
            if total_bytes % (64 * 1024 * 1024) < len(chunk):
                print("已下载 MiB:", round(total_bytes / 1024**2))
    temporary_raw.replace(RAW_PATH)
    print("语料文件 MiB:", round(RAW_PATH.stat().st_size / 1024**2, 1))
elif RAW_PATH.is_file():
    print("复用已下载语料:", RAW_PATH)

if RUN_EXTRACTION_IF_NEEDED:
    if not RAW_PATH.is_file():
        raise FileNotFoundError("需要原始 ShareGPT JSONL: " + str(RAW_PATH))
    command = [
        sys.executable, str(LAYA_DIR / "data_generation" / "build_sharegpt_laya.py"),
        "extract", "--raw", str(RAW_PATH), "--policy", str(POLICY_PATH),
        "--out", str(CASES_PATH), "--manifest", str(DATA_DIR / "manifest.json"),
        "--seed", "42", "--counts", "train=1200,dev=200,calibration=100,test=400",
    ]
    print("本地筛选对话并保留固定来源组切分。")
    subprocess.run(command, cwd=ROOT, check=True)
else:
    print("候选/中间数据已存在,跳过下载与筛选。")

3.1 可选:DeepSeek 生成三轮伪标签

候选数据缺少时,需先执行本节取得投票文件。把 RUN_DEEPSEEK_ANNOTATION 改为 True 后重新运行本格。API key 可预先放在当前进程的 DEEPSEEK_API_KEY 环境变量或 laya/.env;Notebook 只传递文件路径,不读取或打印密钥。

RUN_DEEPSEEK_ANNOTATION = False  # 明确开启后才会产生 API 用量

ENV_FILE = LAYA_DIR / ".env"
if RUN_DEEPSEEK_ANNOTATION:
    if not CASES_PATH.is_file():
        raise FileNotFoundError("缺少案例文件;先运行上面的公开语料下载与筛选。")
    if not os.environ.get("DEEPSEEK_API_KEY") and not ENV_FILE.is_file():
        raise RuntimeError("请设置 DEEPSEEK_API_KEY 或在 laya/.env 中配置密钥,再运行标注。")
    command = [
        sys.executable, str(LAYA_DIR / "data_generation" / "build_sharegpt_laya.py"),
        "annotate", "--cases", str(CASES_PATH), "--policy", str(POLICY_PATH),
        "--votes-out", str(VOTES_PATH), "--batch-size", "10", "--workers", "4", "--votes", "3",
    ]
    if ENV_FILE.is_file():
        command.extend(["--env-file", str(ENV_FILE)])
    subprocess.run(command, cwd=ROOT, check=True)
else:
    print("DeepSeek API 标注未开启。")
    if not VOTES_PATH.is_file() and not CANDIDATES_PATH.is_file():
        print("要从零构造数据,请确认授权与预算后把 RUN_DEEPSEEK_ANNOTATION 改为 True。")

3.2 合并投票并导出候选与人工审核表

已有候选集时会直接复用。若有 cases 和 votes、但还没有 candidates,则自动合并并生成逐题审核表。

if not CANDIDATES_PATH.is_file() and CASES_PATH.is_file() and VOTES_PATH.is_file():
    if REVIEW_CSV_PATH.is_file():
        raise FileExistsError(
            "审核表已存在,为避免覆盖人工复核结果,已停止重新 assemble。"
            "请先备份/迁移审核表,再设置新的 DATA_DIR。"
        )
    command = [
        sys.executable, str(LAYA_DIR / "data_generation" / "build_sharegpt_laya.py"),
        "assemble", "--cases", str(CASES_PATH), "--policy", str(POLICY_PATH),
        "--votes", str(VOTES_PATH), "--out", str(CANDIDATES_PATH),
        "--review-csv", str(REVIEW_CSV_PATH), "--manifest", str(DATA_DIR / "manifest.json"),
    ]
    subprocess.run(command, cwd=ROOT, check=True)
elif CANDIDATES_PATH.is_file():
    print("候选数据已存在,跳过 assemble。")

if not CANDIDATES_PATH.is_file():
    raise FileNotFoundError(
        "还没有全量候选数据: " + str(CANDIDATES_PATH) + "\n"
        "如果从零构造,请先执行原始语料/筛选格,再审核数据授权并开启 RUN_DEEPSEEK_ANNOTATION。"
    )
print("候选数据:", CANDIDATES_PATH, "| MiB:", round(CANDIDATES_PATH.stat().st_size / 1024**2, 1))
if REVIEW_CSV_PATH.is_file():
    print("逐题人工审核表:", REVIEW_CSV_PATH)

4. 固定拆分并核验数据

v2 按来源对话组切分:train 1,200、dev 200、calibration 100、test 400,共 1,900 组和 9,500 题。只用 train 更新权重、dev 选择 checkpoint;calibration/test 留到三种方法全部定版后再评估。

import hashlib
import json

def sha256_file(path):
    digest = hashlib.sha256()
    with Path(path).open("rb") as stream:
        for block in iter(lambda: stream.read(8 * 1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()

split_manifest_path = SPLIT_DIR / "manifest.json"
if split_manifest_path.is_file():
    existing_manifest = json.loads(split_manifest_path.read_text(encoding="utf-8"))
    if existing_manifest.get("source_sha256") != sha256_file(CANDIDATES_PATH):
        raise RuntimeError(
            "当前切分目录对应其他候选数据。请更改 LAYA_RUN_ID 或 SPLIT_DIR 后重新运行。"
        )
elif SPLIT_DIR.exists() and any(SPLIT_DIR.iterdir()):
    raise RuntimeError("切分输出目录非空但没有 manifest。请更改 LAYA_RUN_ID 后重跑。")
else:
    command = [
        sys.executable, str(LAYA_DIR / "data_generation" / "prepare_candidate_splits.py"),
        "--input", str(CANDIDATES_PATH), "--output-dir", str(SPLIT_DIR),
    ]
    subprocess.run(command, cwd=ROOT, check=True)
    existing_manifest = json.loads(split_manifest_path.read_text(encoding="utf-8"))

print("切分清单:", split_manifest_path)
print("候选 SHA256:", existing_manifest["source_sha256"])
print("切分计数:", existing_manifest["planned_split_counts"])
print("导出文件:", existing_manifest["files"])
from collections import Counter

split_rows = {}
for filename in ("train-dev.jsonl", "calibration.jsonl", "test.jsonl"):
    path = SPLIT_DIR / filename
    split_rows[filename] = [
        json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()
    ]

train_dev = split_rows["train-dev.jsonl"]
train_rows = [row for row in train_dev if row.get("split") == "train"]
dev_rows = [row for row in train_dev if row.get("split") == "dev"]
cal_rows = split_rows["calibration.jsonl"]
test_rows = split_rows["test.jsonl"]

assert (len(train_rows), len(dev_rows), len(cal_rows), len(test_rows)) == (1200, 200, 100, 400)
assert sum(len(row["qs"]) for row in train_rows) == 6000
assert sum(len(row["qs"]) for row in dev_rows) == 1000
assert sum(len(row["qs"]) for row in cal_rows) == 500
assert sum(len(row["qs"]) for row in test_rows) == 2000
all_rows = train_rows + dev_rows + cal_rows + test_rows
assert all((row.get("metadata") or {}).get("review_status") == "needs_human_review" for row in all_rows)
assert all(row.get("split") in {"train", "dev", "calibration", "test"} for row in all_rows)

print("records:", len(all_rows), "| questions:", sum(len(row["qs"]) for row in all_rows))
print("split:", dict(Counter(row["split"] for row in all_rows)))
print("review state:", dict(Counter(row["metadata"]["review_status"] for row in all_rows)))
print("question types:", dict(Counter(q["t"] for row in all_rows for q in row["qs"])))
print("全量 schema、计划拆分和审核状态检查通过。")

5. 训练设置:显式启用伪标签并行实验

三种训练器共用同一个 train/dev 文件和随机种子。Head-only 只更新决策头;LoRA-SFT 更新 encoder LoRA 与决策头;RLCD-style 更新 encoder 与决策头,使用探索采样、组基线和交叉熵辅助项。这里的 RLCD-style 是本项目实验实现,不是对上游 RLCD 全部细节的完整复现。

候选标签尚未经过人工审核。若这次实验有意研究 DeepSeek 伪标签,请把 ALLOW_UNREVIEWED_PSEUDOLABELS 设为 True。该开关仅传递显式实验授权,不会更改数据审核字段,也不能将结果作为人工金标质量结论。

三种方法的数据拆分和训练轮数一致,但参数规模、目标函数与学习率不同;这是一组方法对照,不是严格控制所有变量的单因素消融。先串行运行可以减少显存争用并让日志更容易排查。RTX 3090 24 GB 是本项目验证过的并行配置;低于 20 GB 必须串行或降低 max_tokens。

方法 可训练部分 本 Notebook 参数
Head-only 决策 head head LR 1e-4
LoRA-SFT Encoder 的 attention 投影 LoRA 与决策 head LR 5e-5,rank 8、alpha 16、dropout 0.05
RLCD-style Encoder 与决策 head LR 2e-5,4 个探索样本、标准差 0.5、CE 辅助权重 0.1

共同参数是 seed 42、最多 4 轮、dev patience 2、max_tokens 1024、max_seqs 4。

当前 RLCD-style 单元复现的是本项目此前的实验参数,不等于官方训练配方;该 run 的 accuracy 接近 LoRA,但 soft cross-entropy 明显更高。官方配方差异和原因排查见 微调指南。按官方核心参数在全量对话决策数据上复训的独立入口是 laya-training-project/laya/finetune_rlcd_official_jsonl.py,其参数、结果和 AutoDL 持久目录见 官方配方复跑报告]。这次使用单卡和项目自己的 321.9M 本地基座,属于适配复跑,不是官方 2×T4 / 421M typed-decisions Notebook 的逐项复现。

METHODS_TO_RUN = ["head-only", "lora-sft", "rlcd"]
ALLOW_UNREVIEWED_PSEUDOLABELS = False  # 伪标签实验须由使用者显式改为 True
RUN_TRAINING = False                  # 确认参数、GPU 和数据后再改为 True
PARALLEL_TRAINING = False             # 先串行;确认显存充足后再改为 True
EPOCHS = 4
PATIENCE = 2
MAX_TOKENS = 1024
MAX_SEQS = 4
SEED = 42

assert set(METHODS_TO_RUN) <= {"head-only", "lora-sft", "rlcd"}
assert len(METHODS_TO_RUN) == len(set(METHODS_TO_RUN))
if RUN_TRAINING:
    if any(row["metadata"]["review_status"] == "needs_human_review" for row in train_rows):
        if not ALLOW_UNREVIEWED_PSEUDOLABELS:
            raise RuntimeError(
                "数据尚未人工审核。确认仅做伪标签研究后,将 ALLOW_UNREVIEWED_PSEUDOLABELS 改为 True。"
            )
    if PARALLEL_TRAINING and GPU_VRAM_GIB < 20:
        raise RuntimeError("当前 GPU 显存低于 20 GiB;请将 PARALLEL_TRAINING 设为 False。")

print("方法:", METHODS_TO_RUN)
print("训练开启:", RUN_TRAINING, "| 并行:", PARALLEL_TRAINING)
print("训练轮数:", EPOCHS, "| max_tokens:", MAX_TOKENS, "| max_seqs:", MAX_SEQS)
if not RUN_TRAINING:
    print("当前只完成数据核验。确认后将 RUN_TRAINING=True;伪标签还需单独显式启用。")
import time
from collections import OrderedDict

def build_training_command(method, output_dir):
    common_args = [
        "--data", str(SPLIT_DIR / "train-dev.jsonl"),
        "--model-dir", str(MODEL_DIR),
        "--output-dir", str(output_dir),
        "--epochs", str(EPOCHS), "--patience", str(PATIENCE),
        "--max-tokens", str(MAX_TOKENS), "--max-seqs", str(MAX_SEQS),
        "--seed", str(SEED),
    ]
    if ALLOW_UNREVIEWED_PSEUDOLABELS:
        common_args.append("--allow-unreviewed-pseudolabels")
    if method == "head-only":
        return [
            sys.executable, str(LAYA_DIR / "finetune_reviewed_jsonl.py"),
            *common_args, "--head-lr", "1e-4",
        ]
    if method == "lora-sft":
        return [
            sys.executable, str(LAYA_DIR / "finetune_variant_jsonl.py"),
            "--method", "lora_sft", *common_args, "--lr", "5e-5", "--lora-rank", "8",
        ]
    return [
        sys.executable, str(LAYA_DIR / "finetune_variant_jsonl.py"),
        "--method", "rlcd", *common_args, "--lr", "2e-5",
        "--rl-samples", "4", "--exploration-std", "0.5", "--aux-weight", "0.1",
    ]

def tail_text(path, max_lines=4):
    try:
        return "\n".join(path.read_text(encoding="utf-8", errors="replace").splitlines()[-max_lines:])
    except OSError:
        return "(日志尚未生成)"

if RUN_TRAINING:
    if any(row["metadata"]["review_status"] == "needs_human_review" for row in train_rows) and not ALLOW_UNREVIEWED_PSEUDOLABELS:
        raise RuntimeError("当前数据是 needs_human_review 伪标签;训练前必须显式开启伪标签实验。")
    RUN_DIR.mkdir(parents=True, exist_ok=True)
    process_map = OrderedDict()
    log_handles = {}
    for method in METHODS_TO_RUN:
        method_dir = RUN_DIR / method
        if method_dir.exists() and any(method_dir.iterdir()):
            raise RuntimeError("输出目录非空;为避免覆盖请设置新的 LAYA_RUN_ID: " + str(method_dir))

    try:
        launch_order = METHODS_TO_RUN if PARALLEL_TRAINING else list(METHODS_TO_RUN)
        for method in launch_order:
            method_dir = RUN_DIR / method
            log_path = RUN_DIR / (method + ".log")
            log_handles[method] = log_path.open("w", encoding="utf-8")
            child_env = os.environ.copy()
            child_env["PYTHONUNBUFFERED"] = "1"
            process_map[method] = subprocess.Popen(
                build_training_command(method, method_dir),
                cwd=ROOT, env=child_env, stdout=log_handles[method],
                stderr=subprocess.STDOUT,
            )
            print("已启动:", method, "| pid:", process_map[method].pid, "| 日志:", log_path)
            if not PARALLEL_TRAINING:
                while process_map[method].poll() is None:
                    time.sleep(20)
                    print(method, "仍在运行\n" + tail_text(log_path))
                if process_map[method].returncode != 0:
                    raise subprocess.CalledProcessError(process_map[method].returncode, method)
        if PARALLEL_TRAINING:
            last_report = 0
            while any(process.poll() is None for process in process_map.values()):
                time.sleep(20)
                now = time.time()
                if now - last_report >= 30:
                    last_report = now
                    for method, process in process_map.items():
                        state = "running" if process.poll() is None else "exit=" + str(process.returncode)
                        print("\n[" + method + "] " + state + "\n" + tail_text(RUN_DIR / (method + ".log")))
            failed = {name: p.returncode for name, p in process_map.items() if p.returncode != 0}
            if failed:
                raise RuntimeError("训练进程失败,请查看对应日志: " + str(failed))
    except KeyboardInterrupt:
        print("收到中断请求,正在停止本 Notebook 启动的训练进程。")
        for process in process_map.values():
            if process.poll() is None:
                process.terminate()
        for process in process_map.values():
            try:
                process.wait(timeout=10)
            except subprocess.TimeoutExpired:
                process.kill()
        raise
    finally:
        for handle in log_handles.values():
            handle.close()
    print("训练结束。完整输出:", RUN_DIR)
else:
    print("尚未启动训练。需要实际训练时先在上一格确认开关,再重跑本格。")

6. 汇总训练报告

每个方法目录包含 experiment.json、checkpoint、训练日志和指标。下面只读取已经完成的结果;若刚才没有启动训练,可对已有 RUN_DIR 调用。

import json

training_summaries = {}
for method in METHODS_TO_RUN:
    experiment_path = RUN_DIR / method / "experiment.json"
    if not experiment_path.is_file():
        print("尚无完成报告:", method, "|", experiment_path)
        continue
    experiment = json.loads(experiment_path.read_text(encoding="utf-8"))
    training_summaries[method] = {
        "best_epoch": experiment.get("best_epoch"),
        "dev_before": experiment.get("dev_before"),
        "dev_after": experiment.get("dev_after"),
        "training_seconds": experiment.get("training_seconds"),
        "peak_vram_gib": experiment.get("peak_vram_gib"),
        "checkpoint": str(RUN_DIR / method / "model.safetensors"),
    }
training_summaries

7. 锁定 checkpoint 后运行留出评估

训练和 dev checkpoint 选择完成后,才使用 calibration 与 test。这里的 calibration 是预留 split 名称;当前脚本只报告伪标签 soft-CE 和 argmax accuracy,没有拟合温度缩放或声称真实概率校准。三种方法必须都有 checkpoint。

RUN_HOLDOUT_EVALUATION = False  # 三个模型训练完成并检查后,再显式改为 True

if RUN_HOLDOUT_EVALUATION:
    if any(row["metadata"]["review_status"] == "needs_human_review" for row in cal_rows + test_rows) and not ALLOW_UNREVIEWED_PSEUDOLABELS:
        raise RuntimeError("留出集标签尚未人工审核;只做伪标签研究时需显式开启 ALLOW_UNREVIEWED_PSEUDOLABELS。")
    required_checkpoints = [RUN_DIR / method / "model.safetensors" for method in ("head-only", "lora-sft", "rlcd")]
    missing = [str(path) for path in required_checkpoints if not path.is_file()]
    if missing:
        raise FileNotFoundError("留出评估前需要三种方法的 checkpoint: " + ", ".join(missing))
    command = [
        sys.executable, str(LAYA_DIR / "evaluate_candidate_holdouts.py"),
        "--split-dir", str(SPLIT_DIR), "--run-root", str(RUN_DIR),
        "--max-tokens", str(MAX_TOKENS), "--max-seqs", str(MAX_SEQS),
        "--allow-unreviewed-pseudolabels",
    ]
    subprocess.run(command, cwd=ROOT, check=True)
else:
    print("留出集尚未运行。确认 checkpoint 全部定版后,再将 RUN_HOLDOUT_EVALUATION 改为 True。")
holdout_path = RUN_DIR / "holdout_metrics.json"
if holdout_path.is_file():
    metrics = json.loads(holdout_path.read_text(encoding="utf-8"))
    for method, result in metrics["models"].items():
        cal = result["splits"]["calibration"]
        test = result["splits"]["test"]
        print(
            method,
            "| calibration CE/acc:",
            round(cal["soft_cross_entropy"], 4), "/", round(cal["argmax_accuracy"], 4),
            "| test CE/acc:",
            round(test["soft_cross_entropy"], 4), "/", round(test["argmax_accuracy"], 4),
            "| test questions:", test["questions"],
        )
    print("结果文件:", holdout_path)
else:
    print("评估文件还未生成。")

8. 产物、复现与解释边界

对照时先看三种方法的 checkpoint 选择轮数、训练时长和峰值显存,再并列比较 calibration/test 的 soft cross-entropy、argmax accuracy 与各题型结果。soft cross-entropy 衡量与投票 soft target 的差异;argmax accuracy 只看最大概率对应标签是否一致。两者都不是人工标注效果或概率校准指标。

实验和切分写在 OUTPUT_ROOT 下;AutoDL 启动脚本会将 LAYA_OUTPUT_DIR 指向持久盘。下载的模型和生成的数据在各自数据目录,不会进 Git。可将实验目录、manifest、CSV/JSONL 日志与报告整理后共享;请先按上游语料授权决定是否能分发原始语料和候选数据。

本 Notebook 的 full-v2 跑法对应已记录的 全量实验报告]。实验报告中的分数以未审核 DeepSeek 投票为参照,只能说明这组伪标签上的拟合/留出表现,不能解释为人工金标质量、生产效果或概率校准。用户应使用人工审核标签和独立业务评测集作正式结论。

📑 Jev Cookbook:System One 判断模型实战教程

1 第 1 章:认识 Jev:模型、上手与场景 2 第 2 章:核心概念总览 3 第 3 章:System One:判断的核心心智模型 4 第 4 章:状态:让判断连续可追溯 5 第 5 章:原语:Choice、Score 与 Noul 6 第 6 章:置信度:让概率可信 7 第 7 章:应用构建:从原语到完整系统 8 第 8 章:架构模式 9 第 9 章:实战指南总览 10 第 10 章:自一致性 · Noul 11 第 11 章:自一致性 · Choice 12 第 12 章:并行提问 13 第 13 章:重排序 14 第 14 章:逐行语义搜索 15 第 15 章:结构恢复 16 第 16 章:函数调用 17 第 17 章:技能推荐 18 第 18 章:实体对齐 19 第 19 章:RAG 段落分类 20 第 20 章:引用核查 21 第 21 章:LLM 防护栏 22 第 22 章:SDE 级联 23 第 23 章:日期抽取 24 第 24 章:预解析值抽取 25 第 25 章:层级分类 26 第 26 章:自动研究特征发现 27 第 27 章:基于置信度的分类 28 第 28 章:智能家居实验 29 第 29 章:模型评测总览 30 第 30 章:模型评测实验 31 第 31 章:Laya vs Jev 对比基准 32 第 32 章:JevBench:LLM 评测体系 33 第 33 章:智能家居应用实战 34 第 34 章:Jev-Mem 研究总览 35 第 35 章:Jev-Mem 缩放实验 36 第 36 章:Jev-Mem 完整走查 37 第 37 章:Agent 集成总览 38 第 38 章:Pi 集成实验 39 第 39 章:DSH 决策协作 40 第 40 章:本地模型总览 41 第 41 章:本地模型介绍与对比 42 第 42 章:中文数据集构建方案 43 第 43 章:微调指南 44 第 44 章:RLCD 原理与实验优化 45 第 45 章:中文数据集构建实验 46 第 46 章:中文 Head 微调实验 47 第 47 章:全量 v2 微调实验 48 第 48 章:知识库
← 返回本书大纲