Files
Video-Tree-TRM5/app/harness/split_selection.py

552 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频级切分选择:signal 分层、视频聚合、贪心联合约束选择(纯函数)。
结果驱动切分管线的核心:把诊断信号投影为多样性格子,供贪心选择器最大化覆盖。
本模块起步定义 evolution_target 派生与多样性格子;后续追加 score_signal /
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",
"reasoning_failure": "skill",
"mixed": "system",
}
def evolution_target_of(error_type: str) -> str:
"""由 error_type 确定性派生进化目标(tool/skill/system)。
这是报告用的派生标注,非独立多样性轴(多样性主格子=task_type×error_type)。
参数:
error_type: 诊断瀑布归因的错误类别(extraction/search/reasoning/mixed_failure)。
返回:
进化目标字符串 tool / skill / system。
异常:
ValueError: error_type 不在已知集合内(不静默兜底)。
"""
if error_type not in _EVOLUTION_TARGET:
raise ValueError(f"未知 error_type: {error_type}")
return _EVOLUTION_TARGET[error_type]
def cell_of(task_type: str, error_type: str) -> tuple[str, str]:
"""构造多样性主格子 = (task_type, error_type)。
参数:
task_type: 题型(12 类之一)。
error_type: 错误类别(4 类之一)。
返回:
(task_type, error_type) 二元组,作为覆盖计数的格子键。
"""
return (task_type, error_type)
@dataclass(frozen=True)
class SignalLabel:
"""诊断信号分层标签(DiagnosisResult 的确定性投影)。
字段:
tier: 信号层级,取值 T0 / T1 / T2 / uncertain(判据见 score_signal)。
"""
tier: str
def score_signal(*, cause_category: str | None, infra: bool, degraded: bool) -> SignalLabel:
"""把诊断产物投影为信号分层 tier(不发明新分类,是确定性投影)。
分层优先级顺序固定(用早返回表达,不用魔法权重):
先判 INFRA,再判 degraded,然后 defect / lapse,最后兜底 uncertain。
各层判据来源:
T0 — infra=True,即诊断 INFRA 排除(stop_reason ∈ {error, parse_error}),
基础设施失败先于一切判定,排除出可训练主体。
uncertain — degraded=Truejudge 解析失败)或 cause_category 落不到
defect/lapse 上(如为 None),信号不可信,排除出 T2。
T2 — cause_category == "defect",可训练核心,进多样性覆盖与训练主体。
T1 — cause_category == "lapse",低信号(含无解题),接受但不作训练主体。
参数:
cause_category: 诊断的缺陷归因("defect" / "lapse" / None)。
infra: 是否被 INFRA 护栏排除(基础设施失败)。
degraded: judge 是否解析失败导致诊断降级。
返回:
SignalLabel,其 tier 字段为上述四层之一。
实现细节:
关键字参数强制传入,防止 infra / degraded 两个 bool 位置混淆。
"""
if infra:
return SignalLabel(tier="T0")
if degraded:
return SignalLabel(tier="uncertain")
if cause_category == "defect":
return SignalLabel(tier="T2")
if cause_category == "lapse":
return SignalLabel(tier="T1")
return SignalLabel(tier="uncertain")
@dataclass(frozen=True)
class VideoRecord:
"""全视频画像单元(贪心选择器 Task 8 的输入单元)。
覆盖全部视频(含全对、零诊断信号的视频),既承载 test 代表性所需的难度/题型画像,
也叠加 T2 可训练缺陷的多样性格子,供选择器算覆盖与补集。
字段:
video_id: 视频唯一标识。
type_set: 该视频所有题的 task_type 集合(去重,画像用)。
n_correct: 该视频答对题数。
difficulty: 难度画像桶 = 错题数 = 题数 - n_correct。
cells: 仅 tier=="T2" 信号行投影的 (task_type, error_type) 主格子并集(去重)。
wrong_by_type: 各 task_type 的 T2 计数,供选择器 floor 约束(普通 dict)。
实现细节:
frozen 生成的 __hash__ 会遍历各字段;wrong_by_type 为不可哈希 dict
故显式标注 hash=False 将其排除出哈希,避免 VideoRecord 入 set/dict 键时报错,
仍保留其参与相等性比较。
"""
video_id: str
type_set: frozenset[str]
n_correct: int
difficulty: int
cells: frozenset[tuple[str, str]]
wrong_by_type: dict[str, int] = field(hash=False)
def build_video_records(preds: list[dict], signal_rows: list[dict]) -> list[VideoRecord]:
"""由全量 predictions 与诊断信号行构建全视频 VideoRecord 列表。
先按 video_id 聚合全部 predictions(覆盖全对、零信号视频),再叠加仅 tier=="T2"
的诊断信号为多样性格子与 wrong_by_type 计数。非 T2 信号行(T0/T1/uncertain
不计入格子与计数。
参数:
preds: 全量预测行,每行含 video_id / question_id / task_type / correct。
每视频含其全部题(不限于错题),correct 为布尔答对标记。
signal_rows: 诊断信号行,每行含 question_id / task_type / error_type / tier。
诊断只覆盖错题子集,正确题无对应信号行属正常,不视为错误。
返回:
全部视频的 VideoRecord 列表,按视频在 preds 中首次出现顺序排列。
无任何 T2 信号的视频其 cells 为空 frozenset、wrong_by_type 为空 dict。
实现细节:
signal_rows 的 question_id 若不在 preds 中则忽略(诊断可能滞后于当前预测集,
非数据损坏),不 fail-fast;缺失必需键则按 KeyError 直接暴露(不静默兜底)。
异常:
KeyError: preds 或 signal_rows 行缺少必需键(校验前置,防脏数据静默通过)。
"""
# Phase 1: 按 video_id 聚合 preds(保持首次出现顺序)。
signal_by_qid = {row["question_id"]: row for row in signal_rows}
aggregates: dict[str, dict] = {}
for pred in preds:
video_id = pred["video_id"]
bucket = aggregates.setdefault(
video_id, {"types": set(), "question_ids": [], "n_correct": 0}
)
bucket["types"].add(pred["task_type"])
bucket["question_ids"].append(pred["question_id"])
if pred["correct"]:
bucket["n_correct"] += 1
# Phase 2: 逐视频叠加 T2 信号为格子与 wrong_by_type。
records: list[VideoRecord] = []
for video_id, bucket in aggregates.items():
cells: set[tuple[str, str]] = set()
wrong_by_type: dict[str, int] = {}
for question_id in bucket["question_ids"]:
row = signal_by_qid.get(question_id)
if row is None or row["tier"] != "T2":
continue
task_type = row["task_type"]
cells.add(cell_of(task_type, row["error_type"]))
wrong_by_type[task_type] = wrong_by_type.get(task_type, 0) + 1
n_questions = len(bucket["question_ids"])
records.append(
VideoRecord(
video_id=video_id,
type_set=frozenset(bucket["types"]),
n_correct=bucket["n_correct"],
difficulty=n_questions - bucket["n_correct"],
cells=frozenset(cells),
wrong_by_type=wrong_by_type,
)
)
return records
@dataclass(frozen=True)
class SelectConfig:
"""贪心联合约束选择器的实验配置(科研配置,随实验扫动)。
字段:
n_trainval: trainval 目标视频数(多样性阶段的填充上限)。
floor_k: 各高信号 task_type 的 T2 defect 数下限(硬约束,floor 阶段满足)。
epsilon: test 相对全局的最大允许分布偏差(题型占比 / 难度画像两维,逐桶)。
reportable_types: 参与 ε 题型代表性校验的 task_type 集(长尾类型不入约束)。
seed: 预洗牌随机种子,仅用于打破等增益平局,保证同 config 同 videos 同解。
实现细节:
floor_k / reportable_types 为不可哈希容器,标 hash=False 排除出自动 __hash__
避免 frozen dataclass 被哈希时报错(本类不作为字典键,仅承载配置)。
"""
n_trainval: int
floor_k: dict[str, int] = field(hash=False)
epsilon: float
reportable_types: frozenset[str] | set[str] = field(hash=False)
seed: int
@dataclass(frozen=True)
class SplitAssignment:
"""视频级切分归属结果(交给 split_by_video_assignment 做题级切分)。
字段:
trainval: 进入 trainval 的 video_id 元组(按选择顺序,确定性)。
test: 补集视频的 video_id 元组(按 videos 原始顺序)。
"""
trainval: tuple[str, ...]
test: tuple[str, ...]
class InfeasibleSplitError(Exception):
"""floor 硬约束与 ε 守护死锁、无法在不破坏 test 代表性下满足 floor 时抛出。
fail loud(P5):不静默兜底、不随机塞题,直接暴露不可行并报告未达标类型。
"""
def derive_reportable_types(total_by_type: dict[str, int], report_floor: int) -> set[str]:
"""派生可 per-type 报告的 task_type 集(长尾处理:题数 ≥ report_floor 才报告)。
参数:
total_by_type: 各 task_type 的总题数(或代理承载数)。
report_floor: 报告门限,低于此的类型并入长尾、不单独报告也不入 ε 约束。
返回:
总题数 ≥ report_floor 的 task_type 集合。
"""
return {task_type for task_type, total in total_by_type.items() if total >= report_floor}
def _type_membership_fraction(records: list[VideoRecord], keys: set[str]) -> dict[str, float]:
"""计算各 task_type 在给定视频集中的承载占比(含该题型的视频数 / 总视频数)。
参数:
records: 视频记录子集(非空,调用方保证)。
keys: 需计算占比的 task_type 键集。
返回:
{task_type: 占比},占比 ∈ [0, 1]。
"""
total = len(records)
return {key: sum(1 for r in records if key in r.type_set) / total for key in keys}
def _difficulty_fraction(records: list[VideoRecord], buckets: set[int]) -> dict[int, float]:
"""计算各难度桶在给定视频集中的占比(难度 = 错题数)。
参数:
records: 视频记录子集(非空,调用方保证)。
buckets: 需计算占比的难度桶键集。
返回:
{难度桶: 占比},占比 ∈ [0, 1]。
"""
total = len(records)
return {bucket: sum(1 for r in records if r.difficulty == bucket) / total for bucket in buckets}
def _max_deviation(global_dist: dict, subset_dist: dict, keys: set) -> float:
"""逐键取全局与子集分布的最大绝对偏差(键集为空时约定为 0.0)。
参数:
global_dist: 全局分布(键 → 占比)。
subset_dist: 子集分布(键 → 占比)。
keys: 参与比较的键集。
返回:
逐键 |global - subset| 的最大值;keys 为空返回 0.0。
"""
if not keys:
return 0.0
return max(abs(global_dist.get(k, 0.0) - subset_dist.get(k, 0.0)) for k in keys)
def _epsilon_ok(
test_video_records: list[VideoRecord],
videos_all: list[VideoRecord],
config: SelectConfig,
) -> bool:
"""校验 test 子集相对全局在题型占比与难度画像两维的偏差是否均 ≤ epsilon。
test 越简单则 headline 越虚高,故 test 必须保持代表性:逐 reportable 题型、逐难度桶
比较 test 与全局占比,任一维超 epsilon 即判不合格。
参数:
test_video_records: 候选 test 子集(trainval 补集)。
videos_all: 全部视频(全局分布基准)。
config: 选择配置,提供 epsilon 与 reportable_types。
返回:
两维最大偏差均 ≤ epsilon 为 Truetest 为空视为不合格返回 False。
"""
if not test_video_records:
return False
type_keys = set(config.reportable_types)
global_type = _type_membership_fraction(videos_all, type_keys)
subset_type = _type_membership_fraction(test_video_records, type_keys)
if _max_deviation(global_type, subset_type, type_keys) > config.epsilon:
return False
diff_keys = {r.difficulty for r in videos_all}
global_diff = _difficulty_fraction(videos_all, diff_keys)
subset_diff = _difficulty_fraction(test_video_records, diff_keys)
return _max_deviation(global_diff, subset_diff, diff_keys) <= config.epsilon
def _current_wrong_counts(selected: list[VideoRecord]) -> dict[str, int]:
"""聚合已选 trainval 视频的 T2 defect 计数(供 floor 达标判定)。
参数:
selected: 当前已进入 trainval 的视频记录。
返回:
{task_type: T2 defect 累计数}。
"""
counts: dict[str, int] = {}
for video in selected:
for task_type, wrong in video.wrong_by_type.items():
counts[task_type] = counts.get(task_type, 0) + wrong
return counts
def _unmet_floors(selected: list[VideoRecord], floor_k: dict[str, int]) -> dict[str, int]:
"""计算尚未达标的 floor 类型及其缺口(已达标类型不返回)。
参数:
selected: 当前已进入 trainval 的视频记录。
floor_k: 各高信号 task_type 的 defect 下限。
返回:
{task_type: 缺口数},仅含 current < floor 的类型;全达标返回空 dict。
"""
counts = _current_wrong_counts(selected)
return {
task_type: floor - counts.get(task_type, 0)
for task_type, floor in floor_k.items()
if counts.get(task_type, 0) < floor
}
def _floor_fill_count(video: VideoRecord, deficits: dict[str, int]) -> int:
"""计算某视频能填补的 floor 缺口槽数(逐类型取 min(defect, 缺口) 求和)。
参数:
video: 候选视频记录。
deficits: 各未达标类型的缺口。
返回:
该视频实际可填的槽数总和(0 表示对当前缺口无贡献)。
"""
return sum(
min(video.wrong_by_type.get(task_type, 0), deficit)
for task_type, deficit in deficits.items()
)
def _marginal_gain(video: VideoRecord, current_cells: set[tuple[str, str]]) -> int:
"""计算把某视频移入 trainval 的边际覆盖增益(新开的 T2 格子数)。
参数:
video: 候选视频记录。
current_cells: 当前 trainval 的 T2 格子并集。
返回:
video.cells 相对 current_cells 的新增格子数(去重)。
"""
return len(video.cells - current_cells)
def _prospective_test(pool: list[VideoRecord], candidate: VideoRecord) -> list[VideoRecord]:
"""构造"把候选移入 trainval 后"的 test 子集 = 当前剩余池去掉候选。
参数:
pool: 当前尚未进入 trainval 的视频(即当前 test 补集)。
candidate: 拟移入 trainval 的候选视频。
返回:
pool 去掉 candidate 后的视频列表。
"""
return [r for r in pool if r.video_id != candidate.video_id]
def _satisfy_floors(
selected: list[VideoRecord],
pool: list[VideoRecord],
videos_all: list[VideoRecord],
config: SelectConfig,
) -> None:
"""Floor 阶段:硬约束优先,逐步移入能填 floor 槽且不破 ε 的视频(就地改 selected/pool)。
每轮取未达标类型的缺口,候选 = 能填 ≥1 槽 且 移入后 test 仍满足 ε 的视频;候选为空即
死锁抛 InfeasibleSplitError;否则选填槽最多者(等槽数按预洗牌顺序取首个,确定性)。
n_trainval 是硬预算:floor 需求超出预算(尚有缺口却已达 n_trainval)也判不可行 fail loud
保证返回的 trainval 永不超过 n_trainval(不因硬约束悄悄超额、挤占 test)。
参数:
selected: 当前 trainval(就地追加)。
pool: 当前剩余池 = test 补集(就地移除)。
videos_all: 全部视频(ε 全局基准)。
config: 选择配置。
异常:
InfeasibleSplitError: 存在未达标类型但无候选可在不破 ε 下填补,
或 floor 需求超过 n_trainval 预算。
"""
while True:
deficits = _unmet_floors(selected, config.floor_k)
if not deficits:
return
if len(selected) >= config.n_trainval:
raise InfeasibleSplitError(
f"floor 需求超过 n_trainval={config.n_trainval} 预算,剩余缺口: {dict(deficits)}"
)
candidates = [
video
for video in pool
if _floor_fill_count(video, deficits) > 0
and _epsilon_ok(_prospective_test(pool, video), videos_all, config)
]
if not candidates:
raise InfeasibleSplitError(
f"floor 无法在 ε≤{config.epsilon} 下满足,未达标类型缺口: {dict(deficits)}"
)
pick = max(candidates, key=lambda video: _floor_fill_count(video, deficits))
selected.append(pick)
pool.remove(pick)
def _maximize_diversity(
selected: list[VideoRecord],
pool: list[VideoRecord],
videos_all: list[VideoRecord],
config: SelectConfig,
) -> None:
"""多样性阶段:submodular 贪心,按边际覆盖增益降序填至 n_trainval(就地改 selected/pool)。
每轮对剩余视频算新开格子数,按 -增益稳定排序(等增益按预洗牌顺序),取第一个移入后 test
仍满足 ε 的视频;若无任一视频可加而不破 ε,则停并记 warning(欠额,不静默不报错)。
参数:
selected: 当前 trainval(就地追加)。
pool: 当前剩余池 = test 补集(就地移除)。
videos_all: 全部视频(ε 全局基准)。
config: 选择配置。
"""
while len(selected) < config.n_trainval:
if not pool:
logger.warning(
"多样性阶段剩余池耗尽,trainval 欠额: {}/{}", len(selected), config.n_trainval
)
return
current_cells = set().union(*(v.cells for v in selected)) if selected else set()
ranked = sorted(pool, key=lambda video: -_marginal_gain(video, current_cells))
pick = next(
(
video
for video in ranked
if _epsilon_ok(_prospective_test(pool, video), videos_all, config)
),
None,
)
if pick is None:
logger.warning(
"多样性阶段 ε 守护阻断全部候选,trainval 欠额: {}/{}",
len(selected),
config.n_trainval,
)
return
selected.append(pick)
pool.remove(pick)
def select_split(videos: list[VideoRecord], *, config: SelectConfig) -> SplitAssignment:
"""贪心联合约束视频级切分:floor 硬约束先满足、多样性覆盖后最大化、ε 守护 test 代表性。
核心洞察:全数据集错题总数固定,越把信号塞 trainval、test 越简单、headline 越虚高,
故 test 必须保持代表性(ε 约束),trainval 只靠 floor + 多样性覆盖富集,不从 test 偷难题。
两阶段贪心(均带 ε 守护):先 Floor 阶段满足各高信号类型 defect 下限(不可行 fail loud),
再多样性阶段按边际覆盖增益填至 n_trainval(欠额记 warning)。test = trainval 补集。
确定性:入场用 random.Random(seed) 对视频列表做一次预洗牌,此后 max / 稳定排序仅取首个,
seed 只打破等增益 / 等槽数平局;同 config 同 videos → 同结果。
参数:
videos: 全部视频记录(Task 7 build_video_records 产物)。
config: 选择配置(关键字传入,含 n_trainval / floor_k / epsilon / reportable_types / seed)。
返回:
SplitAssignmenttrainval 按选择顺序、test 按 videos 原始顺序。
异常:
InfeasibleSplitError: videos 为空、floor 与 ε 死锁无法满足、
或 floor 需求超过 n_trainval 预算。
"""
if not videos:
raise InfeasibleSplitError("videos 为空,无法执行切分")
rng = random.Random(config.seed)
pool = list(videos)
rng.shuffle(pool)
selected: list[VideoRecord] = []
_satisfy_floors(selected, pool, videos, config)
_maximize_diversity(selected, pool, videos, config)
trainval_ids = {video.video_id for video in selected}
trainval = tuple(video.video_id for video in selected)
test = tuple(video.video_id for video in videos if video.video_id not in trainval_ids)
return SplitAssignment(trainval=trainval, test=test)