feat: add video-split config knobs and reproducible script
This commit is contained in:
@@ -115,6 +115,14 @@ def build_pools(
|
||||
_VIDEO_ASSIGNMENT_LABELS = ("trainval", "test")
|
||||
|
||||
|
||||
class InsufficientValSignal(Exception): # noqa: N818 领域名「验证信号不足」,非通用错误后缀更贴切
|
||||
"""validation 池错题数不足以支撑可靠验证信号(如 McNemar 检验功效)时抛出。
|
||||
|
||||
fail loud(P5):不静默兜底、不放宽阈值,直接暴露 val 池错题数与所需下限,
|
||||
由调用方决定放大 val_ratio / 换 trainval 归属或调低 val_wrong_min。
|
||||
"""
|
||||
|
||||
|
||||
def split_by_video_assignment(
|
||||
questions: list[GeneratedQuestion],
|
||||
assignment: dict[str, str],
|
||||
@@ -122,6 +130,7 @@ def split_by_video_assignment(
|
||||
val_ratio: float,
|
||||
seed: int,
|
||||
baseline_run_id: str = "",
|
||||
val_wrong_min: int = 0,
|
||||
) -> Pools:
|
||||
"""按视频归属做原子切分:同一视频所有题绝不跨 trainval/test 池。
|
||||
|
||||
@@ -136,6 +145,8 @@ def split_by_video_assignment(
|
||||
val_ratio: validation 占 trainval 视频组总数的比例,[0.0, 1.0]。
|
||||
seed: 随机种子,保证视频组 shuffle 可复现。
|
||||
baseline_run_id: 基线 run 标识;离线切分阶段可留空,由调用方回填。
|
||||
val_wrong_min: validation 池最少错题数(默认 0 = 不检查,保持既有调用契约)。
|
||||
> 0 时切出 val 后统计其错题数,不足即 fail loud(见 InsufficientValSignal)。
|
||||
|
||||
返回:
|
||||
冻结的三池 Pools:diagnosis/validation 仍是逐题 GeneratedQuestion 列表
|
||||
@@ -146,6 +157,8 @@ def split_by_video_assignment(
|
||||
ValueError: assignment 缺失某题 video_id(fail-fast 不静默丢题)、
|
||||
assignment 取值非法、correctness 缺失任一参与 Pools 的题(trainval 或
|
||||
test)、或 val_ratio 越界。
|
||||
InsufficientValSignal: val_wrong_min > 0 且 validation 池错题数 < val_wrong_min
|
||||
(P5,验证信号不足以支撑可靠比较,直接报错而非静默放行)。
|
||||
|
||||
关键实现细节:
|
||||
视频组 correctness 取组内全部题的 AND(组内均答对才记为 correct 组),
|
||||
@@ -163,6 +176,15 @@ def split_by_video_assignment(
|
||||
trainval_qs, correctness, val_ratio, random.Random(seed)
|
||||
)
|
||||
|
||||
if val_wrong_min > 0:
|
||||
val_wrong = sum(1 for q in validation if not correctness[q.question_id])
|
||||
if val_wrong < val_wrong_min:
|
||||
raise InsufficientValSignal(
|
||||
f"validation 池错题数 {val_wrong} < val_wrong_min={val_wrong_min},"
|
||||
"验证信号不足以支撑可靠比较(如 McNemar 检验功效),"
|
||||
"请放大 val_ratio / 调整 trainval 归属或调低阈值。"
|
||||
)
|
||||
|
||||
val_correct = sum(1 for q in validation if correctness.get(q.question_id))
|
||||
baseline_val_accuracy = val_correct / len(validation) if validation else 0.0
|
||||
return Pools(
|
||||
|
||||
@@ -7,11 +7,36 @@ build_video_records / select_split。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def diag_fingerprint(prompt_version: str, model: str, code_version: str) -> str:
|
||||
"""由 (诊断 prompt 版本, 模型名, 代码 git 短 SHA) 合成诊断口径指纹。
|
||||
|
||||
诊断信号以 (question_id, baseline_run_id, diag_fingerprint) 为主键持久化,
|
||||
指纹隔离不同诊断配置的信号——换 prompt 版本 / 换模型 / 换代码实现都会得到
|
||||
新指纹,从而 `--force` 用新指纹重跑诊断时**不覆盖旧记录**(旧指纹行仍在),
|
||||
保证不同口径的诊断结果可并存、可回溯、可比对。
|
||||
|
||||
参数:
|
||||
prompt_version: 诊断 prompt 的版本标识(如 prompts/diagnose_*.md 的版本)。
|
||||
model: 执行诊断的模型名(如 "deepseek-v4")。
|
||||
code_version: 诊断代码的版本(约定为 git 短 SHA)。
|
||||
|
||||
返回:
|
||||
16 位十六进制指纹(sha256 截断),对三分量任一变化敏感、对相同三元组确定。
|
||||
|
||||
实现细节:
|
||||
三分量用 "|" 分隔后 sha256,取前 16 位;分隔符防止 ("ab","c") 与 ("a","bc")
|
||||
碰撞成同一指纹。纯函数,相同输入永远同输出,可安全用于主键。
|
||||
"""
|
||||
return hashlib.sha256("|".join([prompt_version, model, code_version]).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
_EVOLUTION_TARGET = {
|
||||
"extraction_failure": "tool",
|
||||
"search_failure": "skill",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# config/video_split.yaml
|
||||
# 结果驱动视频级切分 —— 科研旋钮快照(会在实验中被反复扫动 / 对比的参数)。
|
||||
# 工程配置(harness.db 路径、LLM 凭证、超时)由 .env / pydantic-settings 管理,不在此文件。
|
||||
# 优先级: CLI args > 此文件。CLI 仅用于单次临时覆盖。
|
||||
#
|
||||
# ⚠️ 下列阈值为「占位值」,必须在离线诊断跑完后用真实 T2 分布标定。
|
||||
# 标定程序见 scripts/build_video_split.sh 顶部注释(诊断产物 → 逐旋钮定值)。
|
||||
|
||||
video_split:
|
||||
baseline_run_id: infer_adhoc # 基线 run 标识(错题诊断与切分依据),对应 workspaces/default/harness.db
|
||||
n_trainval: 100 # trainval 目标视频数(多样性阶段填充上限;标定取 ~100)
|
||||
epsilon: 0.1 # test 相对全局最大允许分布偏差(题型占比 / 难度画像两维,逐桶)
|
||||
report_floor: 27 # per-type 报告门限:题数 ≥ 此值的 task_type 才入 ε 代表性约束
|
||||
val_wrong_min: 20 # validation 池最少错题数(McNemar 检验功效阈 ≈ 20,低于则信号不足)
|
||||
val_ratio: 0.3 # validation 占 trainval 视频组总数的比例
|
||||
seed: 7 # 贪心选择器预洗牌 + 视频组题级切分种子(打破等增益 / 等槽平局)
|
||||
floor_k: # 各高信号 task_type 的 T2 defect 下限(硬约束)—— 占位,标定后替换
|
||||
Counting Problem: 3 # 取克制值 min(诊断可用 defect 数, 3),避免把信号全抽进 trainval
|
||||
Object Reasoning: 3
|
||||
Action Reasoning: 3
|
||||
|
||||
diag: # 诊断口径指纹三分量(隔离不同诊断配置的信号,参与主键)
|
||||
prompt_version: diagnose_v1 # 诊断 prompt 版本标识(换 prompt 即换指纹,旧记录不被覆盖)
|
||||
model: deepseek-v4-pro # 执行诊断的模型名(与 .env JUDGE_LLM_MODEL 对齐)
|
||||
# code_version 由 build_video_split.sh 注入 git 短 SHA,不写死在此(随代码变动)
|
||||
@@ -160,6 +160,16 @@ class PoolConfig:
|
||||
eval_min_per_class: 验证池中每类保底样本数(GlobalStrategy 用)。
|
||||
train_ratio: train/(train+val) 比例(PerCategoryStrategy 用)。
|
||||
test_questions_dir: 外部 test 题源路径(PerCategoryStrategy 用)。
|
||||
n_trainval: trainval 目标视频数(结果驱动视频级切分用;0 表示不启用)。
|
||||
floor_k: 各高信号 task_type 的 T2 defect 下限(视频级切分硬约束;空表示无约束)。
|
||||
epsilon: test 相对全局的最大允许分布偏差(视频级切分 test 代表性守护)。
|
||||
report_floor: per-type 报告门限,题数 ≥ 此值的 task_type 才入 ε 约束(视频级切分用)。
|
||||
val_wrong_min: validation 池最少错题数(McNemar 功效护栏;0 表示不检查)。
|
||||
|
||||
实现细节:
|
||||
视频级切分五个旋钮均带惰性默认(0 / 空 dict),使现有 GlobalPoolStrategy /
|
||||
PerCategoryStrategy 的构造点无需改动即可保持行为不变。floor_k 为不可哈希 dict,
|
||||
标 hash=False 排除出 frozen dataclass 的自动 __hash__,避免入 set/dict 键时报错。
|
||||
"""
|
||||
|
||||
task_types: tuple[str, ...] | None
|
||||
@@ -174,3 +184,8 @@ class PoolConfig:
|
||||
train_ratio: float
|
||||
test_questions_dir: _Path | None
|
||||
batch_correct_ratio: float | None = None
|
||||
n_trainval: int = 0
|
||||
floor_k: dict[str, int] = field(default_factory=dict, hash=False)
|
||||
epsilon: float = 0.0
|
||||
report_floor: int = 0
|
||||
val_wrong_min: int = 0
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# 结果驱动视频级切分 —— capstone 可复现实验(离线管线最后一步)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 把整条离线管线串成零参可复现实验:诊断信号 → 贪心联合约束选择 → 冻结 pools.json。
|
||||
# 全部旋钮写死在 config/video_split.yaml,零参即可复现(GPU 卡号除外)。
|
||||
#
|
||||
# 用法:
|
||||
# CUDA_VISIBLE_DEVICES=0 bash scripts/build_video_split.sh
|
||||
#
|
||||
# ============================================================================
|
||||
# 两阶段流程
|
||||
# ----------------------------------------------------------------------------
|
||||
# ① 离线诊断(Task 4,run_baseline_diagnosis)——【LLM 重活,需单独跑一次】
|
||||
# 对 baseline run(infer_adhoc,workspaces/default/harness.db 内 236 道错题)
|
||||
# 逐题跑诊断瀑布,把 defect/lapse/infra/degraded 投影为 tier 信号,逐行 upsert
|
||||
# 到 harness.db 的 baseline_diagnosis 表(主键含 diag_fingerprint,断点续跑幂等)。
|
||||
# 诊断依赖完整 harness 装配(GovernedLLMClient / 树数据 / 诊断 prompt),
|
||||
# 不在本脚本内联,由诊断编排单独执行;本脚本前置校验诊断信号已落库,缺失即 fail loud。
|
||||
#
|
||||
# ② 冻结切分(Task 11,build_split)——【本脚本执行】
|
||||
# 读 canonical 基线预测 + 诊断信号 → 构建全视频画像 → 贪心选择 trainval/test →
|
||||
# 视频组原子切三池 → 原子冻结 pools.json + 溯源 manifest → 六条防御断言。
|
||||
#
|
||||
# ============================================================================
|
||||
# 标定程序(诊断跑完 → 用真实 T2 分布把 config/video_split.yaml 的占位值定死)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 1. floor_k : 读 baseline_diagnosis 各 task_type 的 T2(defect) 计数,
|
||||
# 每个高信号题型取克制值 floor = min(可用 defect 数, 3),
|
||||
# 避免把该类信号全抽进 trainval(会让 test 失去代表性)。
|
||||
# 2. n_trainval : trainval 目标视频数,取 ~100(总 300 视频的约 1/3),
|
||||
# 给多样性阶段留足填充预算,同时保证 test ≥ 2/3 保代表性。
|
||||
# 3. epsilon : test 相对全局的最大分布偏差,取 0.1(题型/难度逐桶容忍 10%)。
|
||||
# 4. val_wrong_min : validation 池最少错题数,取 McNemar 检验功效阈 ≈ 20;
|
||||
# 切分后 val 错题不足此值 → 验证信号不可靠 → fail loud。
|
||||
# 5. report_floor : per-type 报告门限,题数 ≥ 27 的 task_type 才入 ε 约束(滤长尾)。
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}"
|
||||
export CUDA_VISIBLE_DEVICES
|
||||
|
||||
# 日志即时输出,不缓存,便于实时排错。
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
# 工程配置(DB 路径、LLM 凭证)走 .env,不写死在脚本。
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
PYTHON="$(conda run -n Video-Tree-TRM which python)"
|
||||
|
||||
CONFIG="config/video_split.yaml"
|
||||
HARNESS_DB="workspaces/default/harness.db"
|
||||
QUESTIONS_DIR="store/questions/benchmarks/Video-MME"
|
||||
OUT_DIR="workspaces/video-split"
|
||||
|
||||
echo "=== 结果驱动视频级切分:冻结 pools.json(读 ${CONFIG})==="
|
||||
|
||||
# 诊断口径指纹的代码分量 = 当前 git 短 SHA(诊断代码变则指纹变,旧信号不被覆盖)。
|
||||
CODE_VERSION="$(git rev-parse --short HEAD)"
|
||||
export CODE_VERSION HARNESS_DB QUESTIONS_DIR OUT_DIR CONFIG
|
||||
|
||||
"${PYTHON}" -c "
|
||||
import datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
|
||||
from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore
|
||||
from app.harness.build_split import SplitBuildConfig, build_split
|
||||
from app.harness.split_selection import diag_fingerprint
|
||||
|
||||
config_path = Path(os.environ['CONFIG'])
|
||||
cfg = yaml.safe_load(config_path.read_text(encoding='utf-8'))
|
||||
vs = cfg['video_split']
|
||||
dg = cfg['diag']
|
||||
|
||||
# 诊断口径指纹:诊断 prompt 版本 + 模型名 + 代码短 SHA(三分量任一变则换指纹)。
|
||||
fingerprint = diag_fingerprint(dg['prompt_version'], dg['model'], os.environ['CODE_VERSION'])
|
||||
logger.info('诊断口径指纹 diag_fingerprint={} (prompt={} model={} code={})',
|
||||
fingerprint, dg['prompt_version'], dg['model'], os.environ['CODE_VERSION'])
|
||||
|
||||
harness_db = Path(os.environ['HARNESS_DB'])
|
||||
questions_dir = Path(os.environ['QUESTIONS_DIR'])
|
||||
out_dir = Path(os.environ['OUT_DIR'])
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
store = SqliteDiagnosisSignalStore(str(harness_db))
|
||||
try:
|
||||
# 前置校验(P5 fail loud):本指纹下必须已有诊断信号(Phase 1 诊断先跑完)。
|
||||
signals = store.load(vs['baseline_run_id'], fingerprint)
|
||||
if not signals:
|
||||
raise SystemExit(
|
||||
f'诊断信号为空:baseline={vs[\"baseline_run_id\"]} fingerprint={fingerprint} '
|
||||
f'在 {harness_db} 的 baseline_diagnosis 表无任何行。'
|
||||
'请先跑离线诊断(Phase 1 run_baseline_diagnosis)再执行本脚本。'
|
||||
)
|
||||
logger.info('诊断信号就绪:{} 行,开始冻结切分。', len(signals))
|
||||
|
||||
result = build_split(
|
||||
db_path=harness_db,
|
||||
baseline_run_id=vs['baseline_run_id'],
|
||||
signal_store=store,
|
||||
diag_fingerprint=fingerprint,
|
||||
questions_dir=questions_dir,
|
||||
config=SplitBuildConfig(
|
||||
n_trainval=vs['n_trainval'],
|
||||
floor_k=vs['floor_k'],
|
||||
epsilon=vs['epsilon'],
|
||||
report_floor=vs['report_floor'],
|
||||
select_seed=vs['seed'],
|
||||
val_ratio=vs['val_ratio'],
|
||||
split_seed=vs['seed'],
|
||||
),
|
||||
out_path=out_dir / 'pools.json',
|
||||
manifest_path=out_dir / 'split_manifest.json',
|
||||
generated_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
# McNemar 功效护栏(P5 fail loud):build_split 按 Task 11 契约不带 val_wrong_min,
|
||||
# 冻结后在 capstone 层校验 validation 池错题数是否达 McNemar 功效阈。
|
||||
pools = result['pools']
|
||||
val_wrong = sum(1 for q in pools.validation if not pools.correctness[q.question_id])
|
||||
val_wrong_min = vs['val_wrong_min']
|
||||
if val_wrong < val_wrong_min:
|
||||
raise SystemExit(
|
||||
f'validation 池错题数 {val_wrong} < val_wrong_min={val_wrong_min},'
|
||||
'验证信号不足以支撑可靠比较(McNemar 检验功效不够)。'
|
||||
'请放大 val_ratio / 调整旋钮后重跑,勿静默放行。'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'切分冻结完成:pools={} manifest={} val错题={}/{}(阈)',
|
||||
out_dir / 'pools.json',
|
||||
out_dir / 'split_manifest.json',
|
||||
val_wrong,
|
||||
val_wrong_min,
|
||||
)
|
||||
"
|
||||
|
||||
echo "=== 完成:冻结产物在 ${OUT_DIR}/ (pools.json + split_manifest.json) ==="
|
||||
@@ -0,0 +1,115 @@
|
||||
"""视频级切分科研旋钮单元测试:诊断指纹 + val_wrong_min 功效护栏 + PoolConfig 新字段。
|
||||
|
||||
覆盖:
|
||||
- diag_fingerprint 对 (prompt 版本 / 模型 / 代码版本) 三元组确定且敏感;
|
||||
- split_by_video_assignment 的 val_wrong_min 门控 fail loud(验证信号不足即报错);
|
||||
- val_wrong_min 默认 0 时行为与 Task 11 现有调用完全一致(不回归);
|
||||
- PoolConfig 能接收视频级切分的五个新旋钮字段(纯 dataclass 装配)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.harness.pools import InsufficientValSignal, split_by_video_assignment
|
||||
from app.harness.split_selection import diag_fingerprint
|
||||
from core.types import GeneratedQuestion, PoolConfig
|
||||
|
||||
|
||||
def _q(qid: str, vid: str, tt: str = "Counting Problem") -> GeneratedQuestion:
|
||||
"""构造最小可用题目(补齐 GeneratedQuestion 的必填 source_nodes/difficulty)。"""
|
||||
return GeneratedQuestion(
|
||||
question_id=qid,
|
||||
video_id=vid,
|
||||
task_type=tt,
|
||||
question="",
|
||||
options=("A", "B", "C", "D"),
|
||||
answer="A",
|
||||
source_nodes=(),
|
||||
difficulty="easy",
|
||||
)
|
||||
|
||||
|
||||
def test_diag_fingerprint_deterministic_and_sensitive():
|
||||
"""诊断指纹对相同三元组稳定,对任一分量变化敏感。"""
|
||||
a = diag_fingerprint("p1", "deepseek-v4", "abc123")
|
||||
assert a == diag_fingerprint("p1", "deepseek-v4", "abc123") # 确定性
|
||||
assert a != diag_fingerprint("p2", "deepseek-v4", "abc123") # prompt 变则变
|
||||
assert a != diag_fingerprint("p1", "kimi", "abc123") # model 变则变
|
||||
assert a != diag_fingerprint("p1", "deepseek-v4", "def456") # 代码版本变则变
|
||||
assert len(a) == 16 # sha256 截断 16 位十六进制
|
||||
|
||||
|
||||
def test_val_wrong_min_enforced():
|
||||
"""val 错题数 < val_wrong_min 时 fail loud(InsufficientValSignal),不静默兜底。"""
|
||||
qs = [_q("v1-1", "v1"), _q("v1-2", "v1"), _q("v1-3", "v1")]
|
||||
correctness = {q.question_id: True for q in qs} # 全对 → val 无错题
|
||||
with pytest.raises(InsufficientValSignal): # val 错题 < val_wrong_min
|
||||
split_by_video_assignment(
|
||||
qs,
|
||||
{"v1": "trainval"},
|
||||
correctness=correctness,
|
||||
val_ratio=0.5,
|
||||
seed=0,
|
||||
val_wrong_min=5,
|
||||
)
|
||||
|
||||
|
||||
def test_val_wrong_min_default_zero_no_regression():
|
||||
"""val_wrong_min 默认 0 时不检查错题数,保持 Task 11 现有调用契约不破。"""
|
||||
qs = [_q("v1-1", "v1"), _q("v2-1", "v2")]
|
||||
assignment = {"v1": "trainval", "v2": "trainval"}
|
||||
correctness = {"v1-1": True, "v2-1": True} # 全对但默认不触发护栏
|
||||
pools = split_by_video_assignment(
|
||||
qs, assignment, correctness=correctness, val_ratio=1.0, seed=0
|
||||
)
|
||||
assert len(pools.validation) == 2 # 未抛异常,正常返回
|
||||
|
||||
|
||||
def test_pool_config_accepts_video_split_knobs():
|
||||
"""PoolConfig 能接收视频级切分五个新旋钮字段(默认惰性,不破坏现有构造点)。"""
|
||||
cfg = PoolConfig(
|
||||
task_types=None,
|
||||
seed=0,
|
||||
baseline_run_id="infer_adhoc",
|
||||
diag_size=200,
|
||||
diag_correct_ratio=0.5,
|
||||
val_size=30,
|
||||
val_correct_ratio=0.5,
|
||||
test_size=60,
|
||||
eval_min_per_class=2,
|
||||
train_ratio=0.667,
|
||||
test_questions_dir=None,
|
||||
n_trainval=100,
|
||||
floor_k={"Counting Problem": 3},
|
||||
epsilon=0.1,
|
||||
report_floor=27,
|
||||
val_wrong_min=20,
|
||||
)
|
||||
assert cfg.n_trainval == 100
|
||||
assert cfg.floor_k == {"Counting Problem": 3}
|
||||
assert cfg.epsilon == 0.1
|
||||
assert cfg.report_floor == 27
|
||||
assert cfg.val_wrong_min == 20
|
||||
|
||||
|
||||
def test_pool_config_video_split_knobs_default_inert():
|
||||
"""未传视频级切分字段时默认惰性(0 / 空 dict),不破坏 GlobalPoolStrategy 现有构造。"""
|
||||
cfg = PoolConfig(
|
||||
task_types=None,
|
||||
seed=0,
|
||||
baseline_run_id="run_1",
|
||||
diag_size=200,
|
||||
diag_correct_ratio=0.5,
|
||||
val_size=30,
|
||||
val_correct_ratio=0.5,
|
||||
test_size=60,
|
||||
eval_min_per_class=2,
|
||||
train_ratio=0.667,
|
||||
test_questions_dir=None,
|
||||
)
|
||||
assert cfg.n_trainval == 0
|
||||
assert cfg.floor_k == {}
|
||||
assert cfg.epsilon == 0.0
|
||||
assert cfg.report_floor == 0
|
||||
assert cfg.val_wrong_min == 0
|
||||
Reference in New Issue
Block a user