Files
Video-Tree-TRM5/research-wiki/designs/2026-07-12-per-category-pool-strategy-design.md
T

268 lines
9.9 KiB
Markdown
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.
# Per-Category Pool Strategy 设计
> **状态**: 已批准
> **日期**: 2026-07-12
> **关联**: [app-harness-design](2026-07-07-app-harness-design.md)、[question-gen-v2-design](2026-07-11-question-gen-v2-design.md)
## 1. 动机
v2 题目生成管线为 12 个 task_type 各生成 30 题(共 360 题)。需要支持:
| 需求 | 说明 |
|------|------|
| 按类增量 baseline | 类别生成完成后立即可跑 baseline inference,无需等全量完成 |
| Per-category 分层划分 | 每类 30 题按 correctness 2:1 分为 20 train / 10 val |
| 按类训练 | `--task-types` 控制训练作用域,快速验证单类别效果 |
| 外部 test 池 | test 用 Video-MME 900 道真题中同类题目,不从生成题中抽取 |
| 全局训练兼容 | 全类别联合训练时 system/tool 慢进化看全量 val 信号 |
## 2. 方案选型
| 方案 | 描述 | 取舍 |
|------|------|------|
| A. 最小改动 | 在 `build_pools` 内加 if 分支 | 改动小但两条路径耦合 |
| B. 替换为纯 per-category | 删除全局模式 | 简洁但丢失 Video-MME 900 题训练能力 |
| **C. Pool 工厂模式** ✓ | `PoolStrategy` Protocol + 具体策略 | 符合 Clean Architecture DIP/OCP;策略可独立测试替换 |
**选择 C**:池分割是真正易变的接缝(已有两种策略,未来可扩展),满足 CLAUDE.md 抽象引入条件。
## 3. 核心抽象
### 3.1 PoolStrategy Protocol
```python
# app/ports.py 新增(应用层端口,非 core 层——因为返回值 Pools 定义在 app/harness/
class PoolStrategy(Protocol):
"""池构建策略端口。"""
def build(
self,
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
) -> Pools: ...
def build_incremental(
self,
new_task_types: list[str],
questions: list[GeneratedQuestion],
correctness: dict[str, bool],
config: PoolConfig,
) -> dict[str, dict[str, list[str]]]:
"""增量构建新类别的 train/val 划分。
返回 {task_type: {"train": [qid, ...], "val": [qid, ...]}}。
仅 PerCategoryPoolStrategy 有实质实现;GlobalPoolStrategy
不支持增量,调用时抛出 NotImplementedError。
"""
...
```
> **层级决策**`PoolStrategy` 放在 `app/ports.py` 而非 `core/protocols.py`,因为返回类型 `Pools` 定义在 `app/harness/pools.py`。若放入 core 会导致 core → app 的逆向依赖,违反 Clean Architecture 四层分层。
>
> **注**`PoolConfig` 的两组字段(Global 系列 / PerCategory 系列)由两个策略各取所需,未使用的字段被忽略。这避免了为每个策略定义专属 config 子类的过度抽象。
### 3.2 PoolConfig
```python
# core/types.py 新增
@dataclass(frozen=True)
class PoolConfig:
task_types: tuple[str, ...] | None # None = 全部类别
seed: int # 随机种子
baseline_run_id: str # 基线 run 标识(用于读 correctness、写入 pools 指纹)
# GlobalStrategy 使用
diag_size: int
diag_correct_ratio: float
val_size: int
val_correct_ratio: float
test_size: int
eval_min_per_class: int
# PerCategoryStrategy 使用
train_ratio: float # train/(train+val),默认 2/3
test_questions_dir: Path | None # 外部 test 题源路径(与 RunConfig.test_questions 对应)
```
### 3.3 两个具体策略
**GlobalPoolStrategy**:封装现有 `build_pools` 逻辑,test → val → diag 全局三分。
**PerCategoryPoolStrategy**
```
对每个 task_type (共 N 题,目标 train=20, val=10):
correct 题 (Nc 个) → shuffle(seed)
train_correct = floor(Nc * 20/N) # 按总量 20/10 比例分配
val_correct = Nc - train_correct
wrong 题 (Nw 个) → shuffle(seed)
train_wrong = 20 - train_correct # 确保 train 总量恰好 20
val_wrong = Nw - train_wrong
assert train_correct + train_wrong == 20
assert val_correct + val_wrong == 10
diagnosis = 所有选中类别的 train 合并
validation = 所有选中类别的 val 合并
test = load_benchmark(test_questions_dir) 按 task_types 过滤
```
**边界场景处理**
| 场景 | 行为 |
|------|------|
| 某类别全部 correct0 wrong | 退化为非分层 random 20/10,记录 WARNING |
| 某类别全部 wrong0 correct | 同上,退化为非分层 random 20/10 |
| 某类别 correctness 不完整(部分 qid 无 baseline | fail-fast,列出缺失 qid |
| Video-MME 中某 task_type 无题 | test 池该类别为空,记录 WARNING |
## 4. RunConfig 变更
| 新增字段 | 类型 | 默认值 | 归属 |
|---------|------|--------|------|
| `task_types` | `tuple[str, ...] \| None` | `None` | CLI 临时覆盖 |
| `pool_split_mode` | `Literal["global", "per_category"]` | `"global"` | 科研 YAML |
| `train_ratio` | `float` | `0.667` | 科研 YAML |
| `test_questions` | `str` | `"benchmarks/Video-MME"` | 科研 YAML |
`default.yaml` 新增:
```yaml
harness:
pool_split_mode: global
train_ratio: 0.667
test_questions: "benchmarks/Video-MME"
```
## 5. 增量 Baseline Inference
### 5.1 流程
```bash
# 类别 A、B 完成 → 跑 baseline
python main.py harness --mode infer \
--run-id baseline_v2 --questions generated-v2-360 \
--task-types "Object Recognition" "Scene Understanding"
# 类别 C 完成 → 追加到同一 run_id
python main.py harness --mode infer \
--run-id baseline_v2 --questions generated-v2-360 \
--task-types "Action Reasoning"
# promote 为 seed
python main.py harness --mode promote \
--run-id baseline_v2 --seed baseline_v2_seed
# 按类训练
python main.py harness --mode train --fresh \
--seed baseline_v2_seed --run-id train_obj_recog_01 \
--task-types "Object Recognition" --pool-split-mode per_category
```
### 5.2 增量写入规则
| 表 | 唯一键 | 同 run_id 多次 infer 行为 |
|---|--------|-------------------------|
| `_runs` | `run_id` | upsert:更新 `updated_at`,保留原始 `created_at` |
| `predictions` | `(run_id, question_id)` | INSERT;主键冲突报错(防重复推理) |
| `traces` | `trace_id` | 正常 INSERTUUID 天然唯一) |
**重跑同类别**:若需重新推理某类别,先 DELETE 该类别的 predictions`WHERE run_id=? AND question_id IN (?)`),再重新 infer。
**promote 读取规则**`promote_to_seed` 读取指定 `run_id` 下全部 predictions,不区分追加批次。promote 前应确保所有目标类别均已完成 baseline。
## 6. 池冻结格式
### 6.1 per_category 格式
```json
{
"split_mode": "per_category",
"train_ratio": 0.667,
"baseline_run_id": "baseline_v2",
"categories": {
"Object Recognition": {
"train": ["qid_1", "qid_2", "..."],
"val": ["qid_21", "..."]
}
},
"test_source": "benchmarks/Video-MME",
"questions": [...]
}
```
### 6.2 增量更新
```python
def build_or_load_pools(
config: RunConfig,
strategy: PoolStrategy,
db_path: Path,
) -> Pools:
"""构建或加载三池。
参数:
config: 运行配置(含 task_types, pool_split_mode, baseline_run_id 等)。
strategy: 池构建策略实例。
db_path: harness.db 路径(用于读取 baseline correctness)。
流程:
1. 若 pools.json 存在 → 加载
a. 若 config.task_types 中有类别不在已冻结的 categories → 增量构建并追加
b. 若 pools.json 的 seed/train_ratio/baseline_run_id 与当前 config 不一致 → 报错
2. 若不存在 → 从 DB 加载 correctness → strategy.build() → 冻结
3. 按 config.task_types 从 categories 中组装 Pools 返回
"""
```
**冻结一致性校验**:加载已有 pools.json 时,若 `split_mode``seed``train_ratio``baseline_run_id` 与当前 config 不一致,直接报错并提示删除 pools.json 重建。已冻结类别的划分不可变,新类别可追加。
### 6.3 向后兼容
旧格式 `pools.json`(无 `split_mode` 字段)自动识别为 global 模式。
## 7. task_types 作用域贯穿
策略在池构建阶段完成所有过滤,下游无需感知 task_types
| 阶段 | 行为 |
|------|------|
| 池构建 | strategy 只处理指定类别 |
| mini-batch | 消费 scoped 后的 diagnosis 池,无改动 |
| per-skill gate | 池中只有指定类别的题,自然 scoped |
| 慢进化 | 用 scoped 后的 val 池评估,照常进化 |
| held-out eval | test 池已是 Video-MME 同类题 |
## 8. main.py train 接线
```python
# composition root 伪代码
elif config.mode == "train":
strategy = (PerCategoryPoolStrategy()
if config.pool_split_mode == "per_category"
else GlobalPoolStrategy())
pools = build_or_load_pools(config, strategy)
await runner.train(pools)
```
## 9. 文件改动地图
| 文件 | 改动类型 | 内容 |
|------|---------|------|
| `app/ports.py` | 新增 | `PoolStrategy` Protocol(应用层端口,避免 core → app 逆向依赖) |
| `core/types.py` | 新增 | `PoolConfig` dataclass |
| `app/harness/pools.py` | 重构 | `GlobalPoolStrategy` + `PerCategoryPoolStrategy` + 增量逻辑 |
| `app/harness/config.py` | 修改 | RunConfig 新增 4 字段 + 校验;`pool_split_mode` 用 Literal 类型 |
| `config/default.yaml` | 修改 | 3 个新配置项 |
| `main.py` | 修改 | `task_types` 纳入 `cli_overrides` + RunConfigtrain 接线;strategy 组装 |
| `app/harness/log.py` | 微调 | `_runs``INSERT OR IGNORE` 改为 `ON CONFLICT DO UPDATE`(增量 infer 更新时间戳) |
| `tests/unit/test_pools.py` | 新增 | 两种策略单元测试 |
| `tests/integration/test_pool_strategy.py` | 新增 | 端到端集成测试 |
### 不改动
- `app/harness/batching.py` — 消费 scoped 池,无需感知策略
- `core/evolution/` — 进化引擎与池来源解耦
- `app/harness/inference.py` — 推理逻辑不变
- `app/question_gen/` — 题目生成与池构建解耦