Enhance model training lite workflow

This commit is contained in:
wuyang6
2026-07-07 19:58:00 +08:00
parent 8eadc25b19
commit 2a6a7c2780
6 changed files with 484 additions and 14 deletions
+1
View File
@@ -26,6 +26,7 @@ router_session_parquet/
# Environment files # Environment files
.env .env
.env.* .env.*
skills/*/.local/
# Local benchmark outputs # Local benchmark outputs
benchmark_artifacts/ benchmark_artifacts/
+112 -10
View File
@@ -1,8 +1,8 @@
--- ---
name: model-training-lite name: model-training-lite
name_zh: 轻量模型训练 name_zh: 轻量模型训练
description: 从 Codex、Claude Code 或其他 Agent 通过用户提供的 Jupyter 环境和数据版本发起一次模型训练,聚焦“准备远端工作区、同步数据、提交 CML SFT、返回 JobID”,不包含完整自主迭代闭环。 description: 从 Codex、Claude Code 或其他 Agent 通过用户提供的 Jupyter 环境和数据版本发起一次模型训练,并可选发起评测和生成轻量结果摘要;不包含自动数据增强闭环。
when_to_use: 当用户已经准备好训练数据,想让 Agent 使用 Jupyter/CloudML 发起一次模型训练、复用当前仓库的训练脚本和模板、或询问如何把模型训练流程沉淀成轻量 skill 时使用。 when_to_use: 当用户已经准备好训练数据,想让 Agent 使用 Jupyter/CloudML 基于某个数据分支或 commit 发起一次模型训练、训练后评测、汇总结果,或询问如何把模型训练流程沉淀成轻量 skill 时使用。
aliases: jupyter-model-training, sft-submit, 轻量训练, 模型训练提交 aliases: jupyter-model-training, sft-submit, 轻量训练, 模型训练提交
examples: examples:
- 用这个 Jupyter 和 ai-planning 当前分支重新发起训练 - 用这个 Jupyter 和 ai-planning 当前分支重新发起训练
@@ -14,7 +14,17 @@ allowed_tools: read_file, write_file, grep_search, glob_search, ask_user_questio
# 轻量模型训练 # 轻量模型训练
本 skill 只负责一次训练提交: 本 skill 是人机协同的训练/评测执行器:人负责目标和边界裁决,AI 负责候选、统计、证据整理和确定性执行。
```text
人提出目标问题
-> AI 整理候选、统计分布、生成 review 表
-> 人确认边界、保留样本、数据是否入库
-> AI 提交训练、发起评测、汇总结果
-> 人判断是否继续下一轮
```
训练评测执行链路:
```text ```text
Jupyter 授权环境 Jupyter 授权环境
@@ -23,26 +33,53 @@ Jupyter 授权环境
-> 同步训练脚本与数据版本 -> 同步训练脚本与数据版本
-> 检查 CML / 数据 / zk_trainer -> 检查 CML / 数据 / zk_trainer
-> 提交 SFT -> 提交 SFT
-> 返回 JobID、产物路径、下一步评测入口 -> 可选:SFT 成功后提交 CML 评测
-> 可选:读取 workflow metric_diff 生成轻量结果摘要
-> 返回 JobID、workflow ID、产物路径、摘要路径
``` ```
不要把它扩展成 `model-iteration` 那种完整“baseline -> 分析 -> 增强 -> 训练 -> 评测 -> 再分析”的自主循环。 不要把它扩展成 `model-iteration` 那种完整“baseline -> 分析 -> 增强 -> 训练 -> 评测 -> 再分析”的自主循环。本 skill 可以辅助人完成每一步决策前的信息准备,但不自动裁决边界、不自动修改数据、不自动生成增强样本、不自动进入下一轮训练。
## 必要输入 ## 必要输入
开始前必须拿到: 开始前必须拿到:
- **Jupyter 地址和授权方式**URL;密码、token、cookie/session,或说明当前环境已有可用登录态 - **Jupyter 地址和授权方式**URL;密码、token、cookie/session,或使用本地默认配置
- **数据版本**git repo + branch + commit,或远端工作区中已存在的数据路径。 - **数据版本**git repo + branch + commit,或 git repo + branch,或远端工作区中已存在的数据路径。
- **训练配方**:如果是当前 ZK 中控 SFT,默认复用 `skills/model-iteration/scripts/`;如果不是,必须让用户提供训练脚本、模板或命令。 - **训练配方**:如果是当前 ZK 中控 SFT,默认复用 `skills/model-iteration/scripts/`;如果不是,必须让用户提供训练脚本、模板或命令。
通常用户只需要显式给 `Jupyter 地址``数据版本`。当前项目的默认训练配方可从本仓库继承,不必每次追问。 通常用户只需要显式给 `Jupyter 地址``数据版本`。当前项目的默认训练配方可从本仓库继承,不必每次追问。
本地默认 Jupyter 配置路径:
```text
skills/model-training-lite/.local/jupyter_defaults.json
```
`.local/` 已加入 gitignore。真实密码只能放在本地配置或环境变量里,不要写入 `SKILL.md`、README、提交记录或最终回复。
配置格式可参考:
```text
skills/model-training-lite/jupyter_defaults.example.json
```
脚本也支持环境变量:
```text
MODEL_TRAINING_LITE_JUPYTER_URL
MODEL_TRAINING_LITE_JUPYTER_PASSWORD
MODEL_TRAINING_LITE_JUPYTER_TOKEN
MODEL_TRAINING_LITE_JUPYTER_COOKIE
MODEL_TRAINING_LITE_JUPYTER_AUTH_TYPE
```
可选但推荐确认: 可选但推荐确认:
- `owner`:远端输出目录使用的用户前缀,例如 `wuyang6` - `owner`:远端输出目录使用的用户前缀,例如 `wuyang6`
- `run_name`:本次训练工作区名,例如 `manual_YYYYMMDD_zk_intent_xxx` - `run_name`:本次训练工作区名,例如 `manual_YYYYMMDD_zk_intent_xxx`
- 是否训练后立刻发起评测。默认只提交训练,不自动串接评测 - 是否训练后立刻发起评测。默认只提交训练;用户要求“训练后评测/看效果/分析结果”时启用
- `model_old`:评测工作流中的对照模型路径;未提供时使用默认线上基线。
## 默认 ZK SFT 配方 ## 默认 ZK SFT 配方
@@ -62,6 +99,22 @@ skills/model-iteration/assets/config.yaml
## 推荐流程 ## 推荐流程
### 0. 人机协同边界
AI 应主动辅助人完成信息准备,但不能替人拍板:
| 阶段 | AI 负责 | 人负责 |
|---|---|---|
| 目标定义 | 把问题拆成可评测口径,列出候选集合和风险 | 确认目标问题和主指标 |
| 数据准备 | 调用相关 skill 生成候选、统计、review 表 | 判断边界样本是否保留 |
| 数据构造 | 参考 `product-data` 生成训练/评测格式 | 确认数据是否入库 |
| 标签判断 | 参考 `label-master` 校验 tag / function / complex | 确认争议规则 |
| 线上挖掘 | 参考 `online-mining-v2` 获取 rid/session/prompt/output | 确认挖掘口径 |
| 批量打标 | 参考 `model-labeling` 请求线上模型,生成基线 | 确认宽口径和人工 override |
| 训练评测 | 本 skill 提交 SFT、评测、轻量摘要 | 决定是否继续下一轮 |
需要全自动假设驱动迭代时,使用 `model-iteration`,不要把本 skill 临时扩展成自动闭环。
### 1. 先生成提交计划 ### 1. 先生成提交计划
优先执行 portable script 优先执行 portable script
@@ -80,12 +133,16 @@ python skills/model-training-lite/scripts/render_sft_submission_plan.py --input
"data_commit": "b92be709", "data_commit": "b92be709",
"owner": "wuyang6", "owner": "wuyang6",
"run_name": "manual_20260528_zk_intent_clean_train", "run_name": "manual_20260528_zk_intent_clean_train",
"recipe": "zk_sft" "recipe": "zk_sft",
"run_eval": true,
"model_old": "/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface"
} }
``` ```
脚本只生成计划和命令草案,不连接 Jupyter,不提交训练。 脚本只生成计划和命令草案,不连接 Jupyter,不提交训练。
如果只提供 `data_branch` 不提供 `data_commit`,脚本会生成 `git reset --hard origin/<branch>`,并在结果中写 warning。正式训练报告里必须记录实际 `git rev-parse HEAD`,避免后续无法复现。
### 2. 连接 Jupyter ### 2. 连接 Jupyter
如果已经有 session/cookie,可直接调用 Jupyter REST API 如果已经有 session/cookie,可直接调用 Jupyter REST API
@@ -117,6 +174,12 @@ zk_trainer/
把默认 ZK SFT 配方里的 `scripts/``config.yaml` 同步到远端工作区。再 clone 或更新数据仓库,并 checkout 到用户指定 commit。 把默认 ZK SFT 配方里的 `scripts/``config.yaml` 同步到远端工作区。再 clone 或更新数据仓库,并 checkout 到用户指定 commit。
`model-iteration` 默认脚本外,还要同步本 skill 的轻量分析脚本:
```text
skills/model-training-lite/scripts/summarize_eval_result.py
```
### 4. 提交前检查 ### 4. 提交前检查
提交训练前必须输出: 提交训练前必须输出:
@@ -158,10 +221,49 @@ eval "$(./scripts/resolve_run_ids.sh)"
再查一次 `cml custom_train describe <JOB_ID>`,确认状态不是提交后立即失败。 再查一次 `cml custom_train describe <JOB_ID>`,确认状态不是提交后立即失败。
### 7. 可选:训练后提交评测
只有用户要求“训练后评测/看效果/分析结果”时执行。
训练成功标记存在后:
```bash
source ~/.cloudml-cli/.profile
export AUTORESEARCH_CHAT_ROOT=<remote_workspace>
export AUTORESEARCH_ROOT="$AUTORESEARCH_CHAT_ROOT"
cd "$AUTORESEARCH_CHAT_ROOT"
eval "$(./scripts/resolve_run_ids.sh)"
./scripts/submit_cml_eval.sh "$EVAL_RUNDIC" "$AUTORESEARCH_CHAT_ROOT/sft_output" "<model_old>"
```
提交后必须回报:
- `EVAL_RUNDIC`
- CloudML workflow 提交输出
- 评测产物目录:`/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow<EVAL_RUNDIC>/`
- 评测完成标记:`metric_diff/lark_template.json`
### 8. 可选:轻量结果摘要
评测产物落盘后执行:
```bash
python "$AUTORESEARCH_CHAT_ROOT/scripts/summarize_eval_result.py" \
--workflow-root /mnt/xiaoai-zk-model-train-tj5/workflow5 \
--run-dic "$EVAL_RUNDIC" \
--output "$AUTORESEARCH_CHAT_ROOT/results/eval_summary_${EVAL_RUNDIC}.md"
```
摘要脚本只做轻量汇总:
-`metric_diff/lark_template.json` 尽力抽取指标。
-`metric_diff/specific_comparison.csv` 汇总错误分布和错误样例。
- 不做数据增强建议,不做训练集修改,不替代 `model-iteration` 的深度归因。
## 边界 ## 边界
- 不自动修改训练数据。 - 不自动修改训练数据。
- 不自动做错误归因。 - 不自动做深度错误归因;只允许生成轻量指标/错误分布摘要
- 不自动生成增强样本。 - 不自动生成增强样本。
- 不默认串接评测;训练成功后是否评测由用户或后续明确指令决定。 - 不默认串接评测;训练成功后是否评测由用户或后续明确指令决定。
- 不把 Jupyter 密码、CloudML key、cookie 写进产物或最终回复。 - 不把 Jupyter 密码、CloudML key、cookie 写进产物或最终回复。
@@ -0,0 +1,5 @@
{
"jupyter_url": "https://example-jupyter/lab?",
"auth_type": "password",
"password": "DO_NOT_COMMIT_REAL_PASSWORD"
}
@@ -11,6 +11,7 @@ skills/model-iteration/scripts/submit_sft.sh
skills/model-iteration/scripts/submit_cml_eval.sh skills/model-iteration/scripts/submit_cml_eval.sh
skills/model-iteration/scripts/sft_train_job.yaml.tpl skills/model-iteration/scripts/sft_train_job.yaml.tpl
skills/model-iteration/assets/config.yaml skills/model-iteration/assets/config.yaml
skills/model-training-lite/scripts/summarize_eval_result.py
``` ```
## 默认基模 ## 默认基模
@@ -28,6 +29,14 @@ git@git.n.xiaomi.com:ai-service/ai-planning.git
branch: autoresearch-v1 branch: autoresearch-v1
``` ```
如果用户只提供数据分支、不提供 commit,训练前必须在远端工作区记录:
```bash
git -C ai-planning rev-parse HEAD
```
正式报告使用该 commit 作为本次训练的数据版本。
训练脚本会扫描: 训练脚本会扫描:
```text ```text
@@ -49,6 +58,22 @@ ai-planning/data/train_set/*/*.jsonl
<workspace>/sft_output/_SUCCESS <workspace>/sft_output/_SUCCESS
``` ```
## 默认 Jupyter 配置
本地默认配置文件:
```text
skills/model-training-lite/.local/jupyter_defaults.json
```
`.local/` 不提交。配置优先级:
```text
脚本输入 > 环境变量 > .local/jupyter_defaults.json
```
脚本输出只显示认证是否已配置,不回显密码、token 或 cookie。
## CML 环境 ## CML 环境
CloudML CLI 通常位于: CloudML CLI 通常位于:
@@ -71,6 +96,37 @@ cml config show
cml custom_train describe <JOB_ID> cml custom_train describe <JOB_ID>
``` ```
## 默认评测
训练后评测复用:
```text
skills/model-iteration/scripts/submit_cml_eval.sh
skills/model-iteration/assets/config.yaml
```
默认 workflow 配置从 `config.yaml` 读取:
```text
cml_eval.workflow_id
cml_eval.version
```
评测产物目录:
```text
/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow<EVAL_RUNDIC>/metric_diff/
```
轻量摘要脚本:
```bash
python scripts/summarize_eval_result.py \
--workflow-root /mnt/xiaoai-zk-model-train-tj5/workflow5 \
--run-dic "$EVAL_RUNDIC" \
--output results/eval_summary_${EVAL_RUNDIC}.md
```
## 常见坑 ## 常见坑
- Jupyter POST 请求缺少 `_xsrf`:从 cookie 取 `_xsrf`,请求头带 `X-XSRFToken` - Jupyter POST 请求缺少 `_xsrf`:从 cookie 取 `_xsrf`,请求头带 `X-XSRFToken`
@@ -78,3 +134,5 @@ cml custom_train describe <JOB_ID>
- `which cml` 为空:先 `source ~/.cloudml-cli/.profile` - `which cml` 为空:先 `source ~/.cloudml-cli/.profile`
- `resolve_run_ids.sh` 没有 `RUNDIC`:只使用 `SFT_RUNDIC` / `EVAL_RUNDIC` - `resolve_run_ids.sh` 没有 `RUNDIC`:只使用 `SFT_RUNDIC` / `EVAL_RUNDIC`
- git clone 看似卡住:先检查目标目录是否已经完整、是否存在残留进程,不要重复提交训练。 - git clone 看似卡住:先检查目标目录是否已经完整、是否存在残留进程,不要重复提交训练。
- 只给 branch 不给 commit:可训练,但必须回填实际 commit,否则结果不可复现。
- 训练和评测不要放在同一个长 bash 里串到底;SFT `_SUCCESS` 落盘后再单独提交评测。
+72 -4
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re import re
import sys import sys
from pathlib import Path
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any from typing import Any
@@ -26,14 +28,18 @@ def main() -> int:
def render_plan(payload: dict[str, Any]) -> dict[str, Any]: def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
defaults = load_local_defaults()
missing: list[str] = [] missing: list[str] = []
jupyter_url = clean(payload.get("jupyter_url")) jupyter_url = clean(payload.get("jupyter_url")) or clean(defaults.get("jupyter_url"))
jupyter_auth_type = clean(payload.get("jupyter_auth_type") or payload.get("auth_type")) or clean(defaults.get("auth_type"))
jupyter_password = clean(payload.get("jupyter_password") or payload.get("password")) or clean(defaults.get("password"))
jupyter_token = clean(payload.get("jupyter_token") or payload.get("token")) or clean(defaults.get("token"))
data_commit = clean(payload.get("data_commit") or payload.get("commit")) data_commit = clean(payload.get("data_commit") or payload.get("commit"))
data_path = clean(payload.get("data_path")) data_path = clean(payload.get("data_path"))
if not jupyter_url: if not jupyter_url:
missing.append("jupyter_url") missing.append("jupyter_url")
if not data_commit and not data_path: if jupyter_url and not (jupyter_password or jupyter_token or clean(payload.get("jupyter_cookie")) or clean(defaults.get("cookie"))):
missing.append("data_commit or data_path") missing.append("jupyter auth")
owner = clean(payload.get("owner")) or "wuyang6" owner = clean(payload.get("owner")) or "wuyang6"
run_name = clean(payload.get("run_name")) or default_run_name(data_commit or data_path or "manual") run_name = clean(payload.get("run_name")) or default_run_name(data_commit or data_path or "manual")
@@ -43,6 +49,12 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
data_repo = clean(payload.get("data_repo")) or DEFAULT_DATA_REPO data_repo = clean(payload.get("data_repo")) or DEFAULT_DATA_REPO
data_branch = clean(payload.get("data_branch")) or DEFAULT_BRANCH data_branch = clean(payload.get("data_branch")) or DEFAULT_BRANCH
recipe = clean(payload.get("recipe")) or DEFAULT_RECIPE recipe = clean(payload.get("recipe")) or DEFAULT_RECIPE
run_eval = bool(payload.get("run_eval") or payload.get("eval_after_train"))
model_old = clean(payload.get("model_old")) or "/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface"
wf_root = clean(payload.get("workflow_root")) or "/mnt/xiaoai-zk-model-train-tj5/workflow5"
warnings: list[str] = []
if not data_commit and not data_path:
warnings.append("data_commit 未提供:计划将使用 data_branch 当前 HEAD,复现性弱于固定 commit。")
commands = { commands = {
"prepare_workspace": [ "prepare_workspace": [
@@ -53,7 +65,11 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
f"[ -d ai-planning/.git ] || git clone -b {data_branch} {data_repo} ai-planning", f"[ -d ai-planning/.git ] || git clone -b {data_branch} {data_repo} ai-planning",
f"git -C ai-planning fetch origin {data_branch}", f"git -C ai-planning fetch origin {data_branch}",
f"git -C ai-planning checkout {data_branch}", f"git -C ai-planning checkout {data_branch}",
f"git -C ai-planning reset --hard {data_commit}" if data_commit else f"# use existing data_path: {data_path}", f"git -C ai-planning reset --hard {data_commit}" if data_commit else (
f"# use existing data_path: {data_path}" if data_path else
f"git -C ai-planning reset --hard origin/{data_branch}"
),
"git -C ai-planning rev-parse HEAD",
], ],
"submit_sft": [ "submit_sft": [
"source ~/.cloudml-cli/.profile", "source ~/.cloudml-cli/.profile",
@@ -67,21 +83,42 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
"cml custom_train describe <JOB_ID>", "cml custom_train describe <JOB_ID>",
f"test -f {workspace}/sft_output/_SUCCESS", f"test -f {workspace}/sft_output/_SUCCESS",
], ],
"submit_eval": [
"source ~/.cloudml-cli/.profile",
f"export AUTORESEARCH_CHAT_ROOT={workspace}",
'export AUTORESEARCH_ROOT="$AUTORESEARCH_CHAT_ROOT"',
f"export WF_ROOT={wf_root}",
f"cd {workspace}",
'eval "$(./scripts/resolve_run_ids.sh)"',
f'./scripts/submit_cml_eval.sh "$EVAL_RUNDIC" "{workspace}/sft_output" "{model_old}"',
],
"analyze_eval": [
f"python {workspace}/scripts/summarize_eval_result.py --workflow-root {wf_root} --run-dic <EVAL_RUNDIC> --output {workspace}/results/eval_summary_<EVAL_RUNDIC>.md",
],
} }
return { return {
"ok": not missing, "ok": not missing,
"missing": missing, "missing": missing,
"plan": { "plan": {
"jupyter_url": jupyter_url, "jupyter_url": jupyter_url,
"jupyter_auth": {
"type": jupyter_auth_type or ("token" if jupyter_token else "password" if jupyter_password else ""),
"configured": bool(jupyter_password or jupyter_token or clean(payload.get("jupyter_cookie")) or clean(defaults.get("cookie"))),
"source": auth_source(payload, defaults),
},
"recipe": recipe, "recipe": recipe,
"workspace": workspace, "workspace": workspace,
"data_repo": data_repo, "data_repo": data_repo,
"data_branch": data_branch, "data_branch": data_branch,
"data_commit": data_commit, "data_commit": data_commit,
"data_path": data_path, "data_path": data_path,
"run_eval": run_eval,
"model_old": model_old,
"workflow_root": wf_root,
"success_marker": f"{workspace}/sft_output/_SUCCESS", "success_marker": f"{workspace}/sft_output/_SUCCESS",
}, },
"commands": commands, "commands": commands,
"warnings": warnings,
"required_outputs": [ "required_outputs": [
"CloudML JobID", "CloudML JobID",
"CloudML task URL", "CloudML task URL",
@@ -89,6 +126,8 @@ def render_plan(payload: dict[str, Any]) -> dict[str, Any]:
"EVAL_RUNDIC", "EVAL_RUNDIC",
"workspace", "workspace",
"success_marker", "success_marker",
"eval workflow id when run_eval=true",
"eval summary path when analysis is requested",
], ],
} }
@@ -97,6 +136,35 @@ def clean(value: Any) -> str:
return str(value or "").strip() return str(value or "").strip()
def load_local_defaults() -> dict[str, Any]:
defaults: dict[str, Any] = {}
env_defaults = {
"jupyter_url": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_URL"),
"password": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_PASSWORD"),
"token": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_TOKEN"),
"cookie": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_COOKIE"),
"auth_type": os.environ.get("MODEL_TRAINING_LITE_JUPYTER_AUTH_TYPE"),
}
defaults.update({key: value for key, value in env_defaults.items() if value})
config_path = Path(__file__).resolve().parents[1] / ".local" / "jupyter_defaults.json"
if config_path.exists():
try:
file_defaults = json.loads(config_path.read_text(encoding="utf-8"))
if isinstance(file_defaults, dict):
defaults.update({key: value for key, value in file_defaults.items() if value})
except json.JSONDecodeError as exc:
raise SystemExit(f"invalid local defaults JSON: {config_path}: {exc}") from exc
return defaults
def auth_source(payload: dict[str, Any], defaults: dict[str, Any]) -> str:
if any(clean(payload.get(key)) for key in ("jupyter_password", "password", "jupyter_token", "token", "jupyter_cookie")):
return "input"
if any(clean(defaults.get(key)) for key in ("password", "token", "cookie")):
return "local_default"
return ""
def default_run_name(seed: str) -> str: def default_run_name(seed: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", seed).strip("_")[:32] or "manual" slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", seed).strip("_")[:32] or "manual"
return f"manual_sft_{slug}" return f"manual_sft_{slug}"
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from collections import Counter
from pathlib import Path
from typing import Any
def main() -> int:
parser = argparse.ArgumentParser(description="Summarize a CML eval workflow metric_diff directory.")
parser.add_argument("--workflow-root", default="/mnt/xiaoai-zk-model-train-tj5/workflow5")
parser.add_argument("--run-dic", required=True, help="workflow runDic, for example 18065")
parser.add_argument("--output", help="Markdown output path. Prints to stdout when omitted.")
parser.add_argument("--max-error-examples", type=int, default=30)
args = parser.parse_args()
workflow_dir = Path(args.workflow_root) / f"workflow{args.run_dic}"
metric_dir = workflow_dir / "metric_diff"
result = summarize(metric_dir, args.max_error_examples)
text = render_markdown(args.run_dic, workflow_dir, result)
if args.output:
out = Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text, encoding="utf-8")
else:
print(text)
return 0 if result["ok"] else 1
def summarize(metric_dir: Path, max_error_examples: int) -> dict[str, Any]:
lark_path = metric_dir / "lark_template.json"
comparison_path = metric_dir / "specific_comparison.csv"
result: dict[str, Any] = {
"ok": True,
"metric_dir": str(metric_dir),
"missing": [],
"metrics": [],
"specific_rows": 0,
"error_summary": [],
"error_examples": [],
}
if not lark_path.exists():
result["missing"].append(str(lark_path))
else:
result["metrics"] = extract_lark_metrics(load_json(lark_path))
if comparison_path.exists():
rows = read_csv(comparison_path)
result["specific_rows"] = len(rows)
result["error_summary"] = summarize_comparison(rows)
result["error_examples"] = select_error_examples(rows, max_error_examples)
else:
result["missing"].append(str(comparison_path))
result["ok"] = not result["missing"]
return result
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def extract_lark_metrics(obj: Any) -> list[dict[str, Any]]:
metrics: list[dict[str, Any]] = []
def walk(value: Any, path: list[str]) -> None:
if isinstance(value, dict):
maybe_name = value.get("name") or value.get("title") or value.get("sub_cate") or value.get("cate")
maybe_acc = first_present(value, ["acc", "accuracy", "new_acc", "dev_acc", "rate", "pass_rate"])
maybe_total = first_present(value, ["total", "count", "all"])
maybe_right = first_present(value, ["right", "correct", "hit"])
if maybe_acc is not None or (maybe_total is not None and maybe_right is not None):
metrics.append(
{
"path": " / ".join(path + ([str(maybe_name)] if maybe_name else [])),
"acc": maybe_acc,
"right": maybe_right,
"total": maybe_total,
}
)
for key, child in value.items():
if isinstance(child, (dict, list)):
walk(child, path + [str(key)])
elif isinstance(value, list):
for idx, child in enumerate(value):
if isinstance(child, (dict, list)):
walk(child, path + [str(idx)])
walk(obj, [])
# De-duplicate while preserving order.
seen: set[tuple[str, str, str, str]] = set()
uniq: list[dict[str, Any]] = []
for item in metrics:
key = tuple(str(item.get(k, "")) for k in ("path", "acc", "right", "total"))
if key not in seen:
seen.add(key)
uniq.append(item)
return uniq[:80]
def first_present(obj: dict[str, Any], keys: list[str]) -> Any:
for key in keys:
if key in obj and obj[key] not in ("", None):
return obj[key]
return None
def read_csv(path: Path) -> list[dict[str, str]]:
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
with path.open(encoding=encoding, newline="") as handle:
return list(csv.DictReader(handle))
except UnicodeDecodeError:
continue
raise UnicodeDecodeError("csv", b"", 0, 1, f"cannot decode {path}")
def summarize_comparison(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
if not rows:
return []
fieldnames = set(rows[0])
cate_field = pick_field(fieldnames, ["sub_cate", "子集", "cate", "类别"])
gold_field = pick_field(fieldnames, ["code_label", "gold", "label", "类别真实标签", "code标签"])
pred_field = pick_field(fieldnames, ["origin_predict_dev", "predict_dev", "prediction", "pred", "模型输出"])
if not cate_field:
cate_field = "__all__"
counter: Counter[tuple[str, str, str]] = Counter()
for row in rows:
cate = row.get(cate_field, "ALL") if cate_field != "__all__" else "ALL"
gold = row.get(gold_field, "") if gold_field else ""
pred = row.get(pred_field, "") if pred_field else ""
is_error = detect_error(row)
if is_error:
counter[(cate, gold[:80], pred[:80])] += 1
return [
{"sub_cate": cate, "gold": gold, "pred": pred, "count": count}
for (cate, gold, pred), count in counter.most_common(30)
]
def detect_error(row: dict[str, str]) -> bool:
for key in ("is_correct", "correct", "是否正确", "same", "is_same"):
value = row.get(key)
if value is None:
continue
text = str(value).strip().lower()
if text in {"false", "0", "", "错误", "no", "n"}:
return True
if text in {"true", "1", "", "正确", "yes", "y"}:
return False
base = row.get("origin_predict_base") or row.get("predict_base") or ""
dev = row.get("origin_predict_dev") or row.get("predict_dev") or row.get("prediction") or ""
gold = row.get("code_label") or row.get("gold") or row.get("code标签") or ""
return bool(gold and dev and normalize_label(gold) != normalize_label(dev)) or bool(base and dev and base != dev)
def normalize_label(text: str) -> str:
return "".join(str(text).split()).replace("'", '"')
def select_error_examples(rows: list[dict[str, str]], limit: int) -> list[dict[str, str]]:
examples: list[dict[str, str]] = []
for row in rows:
if not detect_error(row):
continue
examples.append(
{
"query": row.get("query") or row.get("当前query") or row.get("current_query") or "",
"sub_cate": row.get("sub_cate") or row.get("子集") or "",
"gold": row.get("code_label") or row.get("gold") or row.get("code标签") or "",
"pred": row.get("origin_predict_dev") or row.get("predict_dev") or row.get("prediction") or "",
}
)
if len(examples) >= limit:
break
return examples
def pick_field(fieldnames: set[str], candidates: list[str]) -> str:
for candidate in candidates:
if candidate in fieldnames:
return candidate
return ""
def render_markdown(run_dic: str, workflow_dir: Path, result: dict[str, Any]) -> str:
lines: list[str] = [
f"# workflow{run_dic} 评测摘要",
"",
f"- workflow_dir: `{workflow_dir}`",
f"- metric_dir: `{result['metric_dir']}`",
]
if result["missing"]:
lines.append(f"- missing: `{', '.join(result['missing'])}`")
lines += ["", "## 指标摘录", ""]
if result["metrics"]:
lines.append("| path | acc | right | total |")
lines.append("|---|---:|---:|---:|")
for item in result["metrics"]:
lines.append(
f"| {safe_cell(item.get('path'))} | {safe_cell(item.get('acc'))} | {safe_cell(item.get('right'))} | {safe_cell(item.get('total'))} |"
)
else:
lines.append("未解析到 lark_template 指标。")
lines += ["", "## 错误分布 Top", ""]
if result["error_summary"]:
lines.append("| sub_cate | gold | pred | count |")
lines.append("|---|---|---|---:|")
for item in result["error_summary"]:
lines.append(
f"| {safe_cell(item['sub_cate'])} | {safe_cell(item['gold'])} | {safe_cell(item['pred'])} | {item['count']} |"
)
else:
lines.append("未解析到错误分布。")
lines += ["", "## 错误样例", ""]
if result["error_examples"]:
lines.append("| sub_cate | query | gold | pred |")
lines.append("|---|---|---|---|")
for item in result["error_examples"]:
lines.append(
f"| {safe_cell(item['sub_cate'])} | {safe_cell(item['query'])} | {safe_cell(item['gold'])} | {safe_cell(item['pred'])} |"
)
else:
lines.append("未抽到错误样例。")
lines.append("")
return "\n".join(lines)
def safe_cell(value: Any) -> str:
text = str(value or "").replace("\n", "<br>").replace("|", "\\|")
return text[:500]
if __name__ == "__main__":
raise SystemExit(main())