bd1f7a22a2
_q_to_dict 写出 pair_id/question_role/flip_axis/unit_id,_dict_to_q 用 .get 兼容旧 workspace 的 pools.json 读回并回填(unit_id 缺省交 __post_init__)。 pools.json 是训练主回路读回题目处,此前漏写会让孪生对解冻后退化成孤儿 single、配对指标失真。categories 块沿用 per-qid 记录,Task 3 的 unit 原子 切分已保证两 pair 成员同池同 key,序列化不破坏该原子性。
173 lines
5.8 KiB
Python
173 lines
5.8 KiB
Python
"""pools.json 冻结/解冻序列化 pair 字段回归测试。
|
|
|
|
pools.json 是训练主回路真正读回题目的地方——若 _q_to_dict/_dict_to_q 不序列化
|
|
pair 四字段(pair_id/question_role/flip_axis/unit_id),孪生对一旦冻结再 load_pools
|
|
就退化成孤儿 single,配对指标(collapse 等)全部失真。本测试锁死:
|
|
|
|
- pair 冻结进 pools.json 再 load_pools,四字段不丢;
|
|
- per_category 的 categories 块以 unit 原子记 train/val(两 pair 成员同 key);
|
|
- 旧 JSON(无这些字段)用 .get 兼容不崩、默认退化为 single。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
from app.harness.pools import (
|
|
PerCategoryPoolStrategy,
|
|
Pools,
|
|
load_pools,
|
|
save_pools,
|
|
)
|
|
from core.types import GeneratedQuestion, PoolConfig
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _pair(pid: str) -> tuple[GeneratedQuestion, GeneratedQuestion]:
|
|
"""构造一个合法孪生对(original + mirror),共享 pair_id / flip_axis。
|
|
|
|
参数:
|
|
pid: 该孪生对的共享标识。
|
|
|
|
返回:
|
|
(original, mirror) 两条题目,unit_id 由 __post_init__ 回填为 pid。
|
|
"""
|
|
base = {
|
|
"video_id": "v",
|
|
"task_type": "Action Reasoning",
|
|
"question": "?",
|
|
"options": ("A. a", "B. b", "C. c", "D. d"),
|
|
"answer": "A",
|
|
"source_nodes": ("n",),
|
|
"difficulty": "hard",
|
|
"pair_id": pid,
|
|
"flip_axis": "before_after",
|
|
}
|
|
original = GeneratedQuestion(question_id=f"{pid}_o", question_role="pair_original", **base)
|
|
mirror = GeneratedQuestion(question_id=f"{pid}_m", question_role="pair_mirror", **base)
|
|
return original, mirror
|
|
|
|
|
|
def test_pair_fields_survive_freeze_thaw(tmp_path: Path) -> None:
|
|
"""pair 冻结进 pools.json 再 load_pools,四字段逐一不丢。"""
|
|
o, m = _pair("p00")
|
|
single = GeneratedQuestion(
|
|
question_id="s0",
|
|
video_id="v",
|
|
task_type="Action Reasoning",
|
|
question="?",
|
|
options=("A. a", "B. b", "C. c", "D. d"),
|
|
answer="B",
|
|
source_nodes=("n",),
|
|
difficulty="medium",
|
|
)
|
|
pools = Pools(
|
|
diagnosis=[o, m],
|
|
validation=[single],
|
|
test=[],
|
|
baseline_run_id="run_001",
|
|
baseline_val_accuracy=0.0,
|
|
correctness={},
|
|
)
|
|
pools_path = tmp_path / "pools.json"
|
|
save_pools(pools, pools_path)
|
|
restored = load_pools(pools_path)
|
|
|
|
ro, rm = restored.diagnosis
|
|
assert ro.pair_id == "p00"
|
|
assert rm.pair_id == "p00"
|
|
assert ro.question_role == "pair_original"
|
|
assert rm.question_role == "pair_mirror"
|
|
assert ro.flip_axis == "before_after"
|
|
assert rm.flip_axis == "before_after"
|
|
assert ro.unit_id == "p00"
|
|
assert rm.unit_id == "p00"
|
|
|
|
# single 侧默认值也需正确往返
|
|
rs = restored.validation[0]
|
|
assert rs.pair_id is None
|
|
assert rs.question_role == "single"
|
|
assert rs.flip_axis is None
|
|
assert rs.unit_id == "s0"
|
|
|
|
|
|
def test_per_category_categories_record_pair_atomically(tmp_path: Path) -> None:
|
|
"""per_category 的 categories 块以 unit 原子记:两 pair 成员必落同一 train/val key。"""
|
|
questions: list[GeneratedQuestion] = []
|
|
correctness: dict[str, bool] = {}
|
|
for i in range(15):
|
|
o, m = _pair(f"p{i:02d}")
|
|
questions.extend((o, m))
|
|
# 部分对错混合以触发分层划分而非退化随机
|
|
val = i < 9
|
|
correctness[o.question_id] = val
|
|
correctness[m.question_id] = val
|
|
|
|
config = PoolConfig(
|
|
task_types=("Action Reasoning",),
|
|
seed=42,
|
|
baseline_run_id="baseline_v2",
|
|
diag_size=0,
|
|
diag_correct_ratio=0.0,
|
|
val_size=0,
|
|
val_correct_ratio=0.0,
|
|
test_size=0,
|
|
eval_min_per_class=0,
|
|
train_ratio=2 / 3,
|
|
test_questions_dir=None,
|
|
)
|
|
pools = PerCategoryPoolStrategy().build(questions, correctness, config)
|
|
pools_path = tmp_path / "pools.json"
|
|
save_pools(pools, pools_path, split_mode="per_category", config=config)
|
|
|
|
data = json.loads(pools_path.read_text(encoding="utf-8"))
|
|
cat = data["categories"]["Action Reasoning"]
|
|
train_ids = set(cat["train"])
|
|
val_ids = set(cat["val"])
|
|
|
|
# 每个 pair 的两个成员必须同在 train 或同在 val,绝不被劈开
|
|
for i in range(15):
|
|
o_id, m_id = f"p{i:02d}_o", f"p{i:02d}_m"
|
|
in_train = {o_id, m_id} <= train_ids
|
|
in_val = {o_id, m_id} <= val_ids
|
|
assert in_train ^ in_val, f"pair p{i:02d} 被 categories 劈开: train={o_id in train_ids},{m_id in train_ids} val={o_id in val_ids},{m_id in val_ids}"
|
|
|
|
|
|
def test_old_json_without_pair_fields_defaults_single(tmp_path: Path) -> None:
|
|
"""旧 JSON(无 pair 四字段)用 .get 兼容加载不崩,退化为 single。"""
|
|
old_format = {
|
|
"baseline_run_id": "run_old",
|
|
"baseline_val_accuracy": 0.5,
|
|
"correctness": {},
|
|
"diagnosis": [
|
|
{
|
|
"question_id": "q_old",
|
|
"video_id": "v",
|
|
"task_type": "Action Reasoning",
|
|
"question": "?",
|
|
"options": ["A. a", "B. b", "C. c", "D. d"],
|
|
"answer": "A",
|
|
"source_nodes": ["n"],
|
|
"difficulty": "medium",
|
|
"family": None,
|
|
"skill_target": None,
|
|
"difficulty_steps": None,
|
|
}
|
|
],
|
|
"validation": [],
|
|
"test": [],
|
|
}
|
|
pools_path = tmp_path / "pools.json"
|
|
pools_path.write_text(json.dumps(old_format), encoding="utf-8")
|
|
|
|
restored = load_pools(pools_path)
|
|
q = restored.diagnosis[0]
|
|
assert q.pair_id is None
|
|
assert q.question_role == "single"
|
|
assert q.flip_axis is None
|
|
# unit_id 缺省 → __post_init__ 回填为 question_id
|
|
assert q.unit_id == "q_old"
|