Files
Video-Tree-TRM5/app/harness/pools.py
T
2026-07-12 22:41:48 -04:00

534 lines
18 KiB
Python
Raw 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.
"""三池:held-out test + 验证 + 诊断,分层采样 + 冻结持久化。
三池切分对应训练循环中的 DataLoader 阶段——从题目全集中按
test -> validation -> diagnosis 的顺序 progressive exclusion
保证 question_id 互斥。test 池用自然分布(correct_ratio=None),
验证池/诊断池按对错比例分层采样。
"""
from __future__ import annotations
import json
import math
import random
from collections import defaultdict
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from loguru import logger
from app.question_gen import stratified_sample
from core.types import GeneratedQuestion, PoolConfig
if TYPE_CHECKING:
from pathlib import Path
from app.harness.config import RunConfig
@dataclass
class Pools:
"""冻结的三池及其基线指标。
字段:
diagnosis: 诊断池(用于错误归因,对应 loss.backward)。
validation: 验证池(按类局部验证,每题型有保底样本)。
test: held-out 测试池(自然分布,用于最终无偏评估)。
baseline_run_id: 基线 run 标识。
baseline_val_accuracy: 基线在验证池上的准确率。
correctness: 三池所有题的 question_id -> 基线是否答对。
"""
diagnosis: list[GeneratedQuestion]
validation: list[GeneratedQuestion]
test: list[GeneratedQuestion]
baseline_run_id: str
baseline_val_accuracy: float
correctness: dict[str, bool] = field(default_factory=dict)
def build_pools(
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
diag_cfg: dict,
val_cfg: dict,
test_cfg: dict,
baseline_run_id: str,
) -> Pools:
"""先抽 held-out test,再抽验证集,最后抽诊断池,三池互斥。
参数:
questions: 题目全集。
correctness: question_id -> 基线是否答对。
diag_cfg: 诊断池采样配置(size/correct_ratio/task_types[/seed])。
val_cfg: 验证池采样配置,可含 min_per_class 做按类保底。
test_cfg: 测试池配置(size[/seed]);走自然分布,不强制对错比与题型。
baseline_run_id: 基线 run 标识。
返回:
冻结的三池 Pools。
关键实现细节:
切分顺序 test -> validation -> diagnosis;后两步从剩余题中采样以保证
question_id 互斥。test 池用 correct_ratio=None 的自然分布采样。
"""
test = _sample_excluding(
questions,
set(),
correctness,
size=test_cfg["size"],
correct_ratio=None,
task_types=None,
seed=test_cfg.get("seed", 0),
min_per_class=None,
)
selected_ids = {q.question_id for q in test}
validation = _sample_excluding(questions, selected_ids, correctness, **val_cfg)
selected_ids |= {q.question_id for q in validation}
diagnosis = _sample_excluding(questions, selected_ids, correctness, **diag_cfg)
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(
diagnosis=diagnosis,
validation=validation,
test=test,
baseline_run_id=baseline_run_id,
baseline_val_accuracy=baseline_val_accuracy,
correctness={
q.question_id: correctness.get(q.question_id, False)
for q in test + validation + diagnosis
},
)
class GlobalPoolStrategy:
"""全局三分策略:test -> val -> diag progressive exclusion。
封装现有 build_pools 逻辑为 PoolStrategy 接口。
"""
def build(
self,
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
) -> Pools:
"""委托给现有 build_pools 函数。
参数:
questions: 题目全集。
correctness: question_id -> 基线是否答对。
config: 池构建统一配置。
返回:
冻结的三池 Pools。
"""
return build_pools(
questions,
correctness,
diag_cfg={
"size": config.diag_size,
"correct_ratio": config.diag_correct_ratio,
"task_types": list(config.task_types) if config.task_types else None,
"seed": config.seed,
"min_per_class": None,
},
val_cfg={
"size": config.val_size,
"correct_ratio": config.val_correct_ratio,
"task_types": list(config.task_types) if config.task_types else None,
"seed": config.seed,
"min_per_class": config.eval_min_per_class,
},
test_cfg={"size": config.test_size, "seed": config.seed},
baseline_run_id=config.baseline_run_id,
)
def build_incremental(
self,
new_task_types: list[str],
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
) -> dict[str, dict[str, list[str]]]:
"""全局策略不支持增量。
异常:
NotImplementedError: 始终抛出。
"""
raise NotImplementedError(
"GlobalPoolStrategy 不支持增量构建,请使用 PerCategoryPoolStrategy。"
)
def _sample_excluding(
questions: list[GeneratedQuestion],
exclude_ids: set[str],
correctness: dict[str, bool],
**cfg: object,
) -> list[GeneratedQuestion]:
"""排除已选 question_id 后,按 cfg 对剩余题做分层采样。
参数:
questions: 题目全集。
exclude_ids: 已被其他池选走的 question_id,从候选中剔除以保证三池互斥。
correctness: question_id -> 基线是否答对。
cfg: 透传给 stratified_sample 的采样配置
size/correct_ratio/task_types[/seed/min_per_class])。
返回:
采样后的题目列表。
"""
pool = [q for q in questions if q.question_id not in exclude_ids]
return stratified_sample(pool, correctness, **cfg)
def _q_to_dict(q: GeneratedQuestion) -> dict:
"""将 GeneratedQuestion 转为可序列化字典。
参数:
q: 题目对象。
返回:
包含全部字段的字典(options/source_nodes 从 tuple 转为 list)。
"""
return {
"question_id": q.question_id,
"video_id": q.video_id,
"task_type": q.task_type,
"question": q.question,
"options": list(q.options),
"answer": q.answer,
"source_nodes": list(q.source_nodes),
"difficulty": q.difficulty,
"skill_target": q.skill_target,
"difficulty_steps": q.difficulty_steps,
}
def _dict_to_q(d: dict) -> GeneratedQuestion:
"""从字典恢复 GeneratedQuestion。
参数:
d: 由 _q_to_dict 产出的字典。
返回:
恢复的 GeneratedQuestion 实例(options/source_nodes 恢复为 tuple)。
"""
return GeneratedQuestion(
question_id=d["question_id"],
video_id=d["video_id"],
task_type=d["task_type"],
question=d["question"],
options=tuple(d["options"]),
answer=d["answer"],
source_nodes=tuple(d.get("source_nodes", ())),
difficulty=d.get("difficulty", "medium"),
skill_target=d.get("skill_target"),
difficulty_steps=d.get("difficulty_steps"),
)
def save_pools(pools: Pools, path: Path) -> None:
"""将三池及基线指标冻结为 JSON。
参数:
pools: 待冻结的三池。
path: 目标 JSON 文件路径。
"""
path.write_text(
json.dumps(
{
"baseline_run_id": pools.baseline_run_id,
"baseline_val_accuracy": pools.baseline_val_accuracy,
"correctness": pools.correctness,
"diagnosis": [_q_to_dict(q) for q in pools.diagnosis],
"validation": [_q_to_dict(q) for q in pools.validation],
"test": [_q_to_dict(q) for q in pools.test],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def load_pools(path: Path) -> Pools:
"""从 JSON 恢复冻结的三池。
参数:
path: 冻结的 pools.json 路径。
返回:
恢复的三池 Pools。
异常:
ValueError: 旧格式 pools.json(无 test 池)。
关键实现细节:
旧格式 pools.json(无 test 池)会以清晰的 ValueError 中止——本项目不做
向后兼容,也不为缺失字段填默认值。删除旧文件后 build_pools 会重新采样切分,
无需重新推理。
"""
d = json.loads(path.read_text(encoding="utf-8"))
if "test" not in d:
raise ValueError(
f"{path} 为旧格式 pools.json(缺 test 池),"
"请删除后重新切分(build_pools 会重新采样,无需重新推理)。"
)
return Pools(
diagnosis=[_dict_to_q(x) for x in d["diagnosis"]],
validation=[_dict_to_q(x) for x in d["validation"]],
test=[_dict_to_q(x) for x in d["test"]],
baseline_run_id=d["baseline_run_id"],
baseline_val_accuracy=d["baseline_val_accuracy"],
correctness=d["correctness"],
)
def build_or_load_pools(
config: RunConfig,
run_id: str,
task_types: list[str] | None = None,
) -> Pools:
"""train 模式的三池获取入口:pools.json 已存在则加载,否则从基线 db 切分并冻结。
把 main.py train 分支「pools.json 存在则 load_pools 否则 build_pools 再 save_pools」
那段抽成纯函数,使 main 与集成测试共用同一切分逻辑、避免重复。pools.json 是
一次 fresh 训练的冻结切分,resume/重跑同一 workspace 时直接复用以保证三池一致。
参数:
config: 运行配置,提供 workspace_dir 与三池采样旋钮(diag/val/test 各项)。
run_id: 基线全量记录的 run_idfresh 时来自 seed.json,决定从哪个 run 读对错)。
task_types: 可选题型过滤,限定诊断/验证池只采样这些题型;None 表示不过滤。
返回:
冻结的三池 Pools。
关键实现:
切分前从基线 db 的 predictions 表读该 run_id 的逐题对错,作为分层采样依据。
pools.json 落在 config.workspace_dir 下,存在即视为已冻结,原样加载不重切。
"""
from app.harness.log import HarnessLog
from app.harness.workspace import resolve_paths
from app.question_gen import load_benchmark
pools_path = config.workspace_dir / "pools.json"
if pools_path.exists():
return load_pools(pools_path)
paths = resolve_paths(config.workspace_dir)
questions = load_benchmark(paths.questions_dir)
with HarnessLog(str(paths.db_path), run_id) as log:
rows = log.query(
"SELECT question_id, prediction, answer FROM predictions WHERE run_id=?",
(run_id,),
)
correctness = {r["question_id"]: r["prediction"] == r["answer"] for r in rows}
pools = build_pools(
questions,
correctness,
diag_cfg={
"size": config.diag_size,
"correct_ratio": config.diag_correct_ratio,
"task_types": task_types,
"seed": 0,
"min_per_class": None,
},
val_cfg={
"size": config.val_size,
"correct_ratio": config.val_correct_ratio,
"task_types": task_types,
"seed": 0,
"min_per_class": config.eval_min_per_class,
},
test_cfg={"size": config.test_size},
baseline_run_id=run_id,
)
save_pools(pools, pools_path)
return pools
class PerCategoryPoolStrategy:
"""Per-category 分层池构建策略。
按题型分组,每个题型内部按 correctness 分层,以 train_ratio 比例
划分 train(映射到 diagnosis 池)和 val(映射到 validation 池)。
与 GlobalPoolStrategy 的全局 progressive exclusion 不同,本策略
保证每个类别内部的 train/val 比例精确对齐。
"""
def build(
self,
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
) -> Pools:
"""按题型分组后,每组做 correctness 分层的 train/val 划分。
参数:
questions: 题目全集。
correctness: question_id -> 基线是否答对。
config: 池构建配置(使用 train_ratio, task_types, seed,
baseline_run_id, test_questions_dir)。
返回:
冻结的 Poolsdiagnosis=train, validation=val,
test 从 test_questions_dir 加载或为空列表)。
"""
# Phase 1: 按 task_types 过滤
if config.task_types is not None:
allowed = set(config.task_types)
filtered = [q for q in questions if q.task_type in allowed]
else:
filtered = list(questions)
# Phase 2: 按 task_type 分组
groups: dict[str, list[GeneratedQuestion]] = defaultdict(list)
for q in filtered:
groups[q.task_type].append(q)
# Phase 3: 每组分层划分
all_train: list[GeneratedQuestion] = []
all_val: list[GeneratedQuestion] = []
rng = random.Random(config.seed)
for task_type in sorted(groups.keys()):
train, val = self._split_one_category(
groups[task_type], correctness, config.train_ratio, rng,
)
all_train.extend(train)
all_val.extend(val)
# Phase 4: test 池(从外部目录加载,无则空)
test: list[GeneratedQuestion] = []
if config.test_questions_dir is not None:
from app.question_gen import load_benchmark
test = load_benchmark(config.test_questions_dir)
# Phase 5: 计算 baseline_val_accuracy
val_correct = sum(
1 for q in all_val if correctness.get(q.question_id, False)
)
baseline_val_accuracy = val_correct / len(all_val) if all_val else 0.0
return Pools(
diagnosis=all_train,
validation=all_val,
test=test,
baseline_run_id=config.baseline_run_id,
baseline_val_accuracy=baseline_val_accuracy,
correctness={
q.question_id: correctness.get(q.question_id, False)
for q in all_train + all_val + test
},
)
def _split_one_category(
self,
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
train_ratio: float,
rng: random.Random,
) -> tuple[list[GeneratedQuestion], list[GeneratedQuestion]]:
"""单类别 correctness 分层划分。
参数:
questions: 单类别全部题目。
correctness: question_id -> 基线是否答对。
train_ratio: train 占总量的比例。
rng: 随机数生成器(保证跨类别可复现)。
返回:
(train, val) 题目列表元组,两池互斥且总量 == len(questions)。
异常:
ValueError: correctness 中缺少某些 question_id。
"""
n_total = len(questions)
n_train = round(n_total * train_ratio)
n_val = n_total - n_train
# 校验 correctness 完整性
missing = [q.question_id for q in questions if q.question_id not in correctness]
if missing:
raise ValueError(
f"correctness 缺失 {len(missing)} 题: {missing[:5]}"
)
correct_qs = [q for q in questions if correctness[q.question_id]]
wrong_qs = [q for q in questions if not correctness[q.question_id]]
n_correct = len(correct_qs)
# 全 correct 或全 wrong -> 退化为非分层随机划分
if n_correct == 0 or n_correct == n_total:
label = "全部正确" if n_correct == n_total else "全部错误"
logger.warning(
"类别 {} {} ({} 题),退化为非分层随机划分",
questions[0].task_type, label, n_total,
)
shuffled = list(questions)
rng.shuffle(shuffled)
return shuffled[:n_train], shuffled[n_train:]
# 分层: 按 correctness 比例分配到 train
train_correct = math.floor(n_correct * n_train / n_total)
train_wrong = n_train - train_correct
rng.shuffle(correct_qs)
rng.shuffle(wrong_qs)
train = correct_qs[:train_correct] + wrong_qs[:train_wrong]
val = correct_qs[train_correct:] + wrong_qs[train_wrong:]
assert len(train) == n_train, (
f"train 数量不匹配: {len(train)} != {n_train}"
)
assert len(val) == n_val, (
f"val 数量不匹配: {len(val)} != {n_val}"
)
return train, val
def build_incremental(
self,
new_task_types: list[str],
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
) -> dict[str, dict[str, list[str]]]:
"""增量划分:仅处理 new_task_types 中的类别。
参数:
new_task_types: 需要增量划分的类别列表。
questions: 题目全集(从中筛选指定类别)。
correctness: question_id -> 基线是否答对。
config: 池构建配置(使用 train_ratio, seed)。
返回:
{task_type: {"train": [qid, ...], "val": [qid, ...]}}。
"""
target_types = set(new_task_types)
groups: dict[str, list[GeneratedQuestion]] = defaultdict(list)
for q in questions:
if q.task_type in target_types:
groups[q.task_type].append(q)
result: dict[str, dict[str, list[str]]] = {}
rng = random.Random(config.seed)
for task_type in sorted(groups.keys()):
train, val = self._split_one_category(
groups[task_type], correctness, config.train_ratio, rng,
)
result[task_type] = {
"train": [q.question_id for q in train],
"val": [q.question_id for q in val],
}
return result