Files
Video-Tree-TRM5/app/harness/batching.py
T

241 lines
10 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.
"""混合 mini-batch 切分:大类打散、小类整锁,供 runner 每 step 处理一个 batch。"""
from __future__ import annotations
import math
import random
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from core.types import GeneratedQuestion
def build_batches(
items: list[GeneratedQuestion],
correctness: dict[str, bool],
batch_size: int,
min_class_per_batch: int,
seed: int,
correct_ratio: float = 0.0,
) -> tuple[list[list[GeneratedQuestion]], int]:
"""把诊断池里的题目切成多个混合 mini-batch。
当 ``correct_ratio > 0`` 时,按题型为每组错题配比一定数量的正确题,使 batch
包含正误混合样本("动量"机制);``correct_ratio <= 0`` 时退化为纯错题模式。
参数:
items: 候选题目全集。
correctness: question_id -> 基线是否答对。
batch_size: 单个 batch 的样本数上限(> 0)。
min_class_per_batch: 小类判定阈值——题目数 ≤ 此值的题型整组锁进单一
batch> 0)。
seed: 随机种子,保证相同输入产出完全一致的切分。
correct_ratio: 正确题占比(0.0 ~ 1.0)。0.0 = 纯错题;0.5 = 错题:正确题 = 1:1。
返回:
(非空 mini-batch 列表, selected_count);无错题时返回 ([], 0)。
selected_count 是所有 batch 中题目总数。
异常:
ValueError: batch_size 或 min_class_per_batch < 1, 或
min_class_per_batch >= batch_size(破坏小类整组装箱不超容的前提)。
关键实现细节:
装箱顺序为「先小类后大类」。小类整组用 first-fit-decreasing 装箱:按组大小
降序处理(同大小再按 task_type 排序保证确定性),每组放进第一个剩余容量足够
的 batch;若现有 batch 都装不下就新开一个空 batch——因小类组大小
≤ min_class_per_batch < batch_size,新空 batch 必能容纳,故小类装箱永不抛
ValueError,且保证整组不拆。再把大类样本(seed 确定性 shuffle 后)round-robin
分发到所有现存 batch 填充剩余容量。这样小类聚集于单 batch、大类散布多 batch
且与小类共箱,自然产生多类混合 batch(纯类切片会被 multiclass 断言拒绝)。
nb = ceil(总题数/batch_size) 是初始 batch 数下界估计而非硬上限:小类装箱可能
新开 bin 使实际 batch 数超过 nb。每次新开 bin 都意味着总容量随之增加,故总容量
恒 ≥ 总题数,大类 round-robin 跳过满箱后仍能放下全部样本,不会违反 batch_size
上限。题型按名称排序处理以保证跨运行确定性,不依赖 dict 遍历顺序。
"""
_validate_params(batch_size, min_class_per_batch)
rng = random.Random(seed)
grouped = _select_mixed_by_task_type(items, correctness, correct_ratio, rng)
total = sum(len(g) for g in grouped.values())
if total == 0:
return [], 0
nb = max(1, math.ceil(total / batch_size))
batches: list[list[GeneratedQuestion]] = [[] for _ in range(nb)]
small, large = _split_by_size(grouped, min_class_per_batch)
for group in _small_groups_decreasing(small):
_pack_small_class(batches, group, batch_size)
_distribute_large_classes(batches, large, batch_size, rng)
result = [b for b in batches if b]
selected_count = sum(len(b) for b in result)
return result, selected_count
def _validate_params(batch_size: int, min_class_per_batch: int) -> None:
"""校验切分参数,非法值直接报错而非用默认值掩盖。
除各自 >= 1 外,强制 min_class_per_batch < batch_size:小类组大小 ≤
min_class_per_batch,唯有此前提成立才能保证小类整组放入单一 batch 而不超容;否则
_pack_small_class 新开的 bin 会装入超 batch_size 的整组,静默违反容量合约。此约束
与 config._validate_minibatch 一致,是 build_batches 对自身前提的防御性自校验(P5)。
"""
if batch_size < 1:
raise ValueError(f"batch_size 必须 >= 1, 实为 {batch_size}")
if min_class_per_batch < 1:
raise ValueError(f"min_class_per_batch 必须 >= 1, 实为 {min_class_per_batch}")
if min_class_per_batch >= batch_size:
raise ValueError(
f"min_class_per_batch 必须严格 < batch_size, 否则无法保证小类整组放入单一 "
f"batch 不超容; 实为 min_class_per_batch={min_class_per_batch}, "
f"batch_size={batch_size}"
)
def _split_by_size(
grouped: dict[str, list[GeneratedQuestion]],
min_class_per_batch: int,
) -> tuple[dict[str, list[GeneratedQuestion]], dict[str, list[GeneratedQuestion]]]:
"""按错题数把题型分为小类(≤ 阈值)与大类(> 阈值)两组。"""
small = {t: g for t, g in grouped.items() if len(g) <= min_class_per_batch}
large = {t: g for t, g in grouped.items() if len(g) > min_class_per_batch}
return small, large
def _select_mixed_by_task_type(
items: list[GeneratedQuestion],
correctness: dict[str, bool],
correct_ratio: float,
rng: random.Random,
) -> dict[str, list[GeneratedQuestion]]:
"""按题型分组,为每组错题按比例采样正确题混入。
只对有错题的题型做混合——无错题的题型不进 batch,即使有正确题。
``correct_ratio <= 0`` 时退化为纯错题模式(向后兼容)。
参数:
items: 候选题目全集。
correctness: question_id -> 基线是否答对。
correct_ratio: 正确题占比(0.0 ~ 1.0)。
rng: 随机数发生器,用于采样正确题。
返回:
task_type -> 该题型的混合题目列表(错题全部 + 按比例采样的正确题)。
"""
errors_by_type: dict[str, list[GeneratedQuestion]] = {}
correct_by_type: dict[str, list[GeneratedQuestion]] = {}
for q in items:
qid = q.question_id
if correctness.get(qid) is False:
errors_by_type.setdefault(q.task_type, []).append(q)
elif correctness.get(qid, False):
correct_by_type.setdefault(q.task_type, []).append(q)
if correct_ratio <= 0:
return errors_by_type
# 为每个有错题的 task_type 混入正确题
grouped: dict[str, list[GeneratedQuestion]] = {}
for task_type in sorted(errors_by_type):
errs = errors_by_type[task_type]
n_correct = round(len(errs) * correct_ratio / (1 - correct_ratio))
available = correct_by_type.get(task_type, [])
sampled = (
list(available) if len(available) <= n_correct else rng.sample(available, n_correct)
)
grouped[task_type] = errs + sampled
return grouped
def _small_groups_decreasing(
small: dict[str, list[GeneratedQuestion]],
) -> list[list[GeneratedQuestion]]:
"""按组大小降序、同大小按 task_type 升序排出小类组(first-fit-decreasing 顺序)。
参数:
small: task_type -> 小类错题列表。
返回:
排好序的小类组列表;降序处理可降低碎片,确定性 tie-break 保证跨运行一致。
"""
return [small[t] for t in sorted(small, key=lambda t: (-len(small[t]), t))]
def _pack_small_class(
batches: list[list[GeneratedQuestion]],
group: list[GeneratedQuestion],
batch_size: int,
) -> None:
"""用 first-fit 把一个小类整组放入首个容得下的 batch,装不下则新开 bin(就地修改)。
因小类组大小 ≤ min_class_per_batch < batch_size,新开的空 batch 必能容纳整组,
故此函数永不抛 ValueError,且整组不拆。
参数:
batches: 当前各 batch(就地追加,必要时 append 新空 batch)。
group: 待锁定的小类错题(整组不拆)。
batch_size: 单 batch 容量上限。
"""
for b in batches:
if len(b) + len(group) <= batch_size:
b.extend(group)
return
batches.append(list(group))
def _distribute_large_classes(
batches: list[list[GeneratedQuestion]],
large: dict[str, list[GeneratedQuestion]],
batch_size: int,
rng: random.Random,
) -> None:
"""将各大类样本 shuffle 后 round-robin 分发到所有现存 batch(就地修改)。
参数:
batches: 当前各 batch(含小类装箱可能新开的 bin,就地追加)。
large: task_type -> 大类错题列表。
batch_size: 单 batch 容量上限。
rng: 复用的随机数发生器,保证 shuffle 确定性。
异常:
ValueError: 所有 batch 均满仍有样本未放置(总容量估算异常,合法输入不可达)。
关键实现细节:
轮转范围是「所有现存 batch」而非固定 nb 个——小类装箱新开的 bin 也参与分发。
总容量 = 现存 batch 数 × batch_size,每次新开 bin 都同步抬高总容量,故总容量恒
≥ 总错题数,防御性 ValueError 在合法输入下不可达。全局指针在所有大类样本间持续
轮转(不为每类重置),满箱即跳过,使大类充分散布并与已锁定的小类共箱。题型按名称
排序以保证分发顺序确定。
"""
nb = len(batches)
pointer = 0
for task_type in sorted(large):
group = list(large[task_type])
rng.shuffle(group)
for q in group:
pointer = _place_round_robin(batches, q, pointer, batch_size, nb)
def _place_round_robin(
batches: list[list[GeneratedQuestion]],
q: GeneratedQuestion,
pointer: int,
batch_size: int,
nb: int,
) -> int:
"""从 pointer 起找第一个未满 batch 放入 q,返回下一次起始指针。
参数:
batches: 当前各 batch(就地追加)。
q: 待放置的样本。
pointer: 本次轮转起始 batch 下标。
batch_size: 单 batch 容量上限。
nb: batch 总数。
返回:
下一次轮转的起始指针(已前移一位)。
异常:
ValueError: 扫描一轮所有 batch 均满(总容量估算异常)。
"""
for offset in range(nb):
idx = (pointer + offset) % nb
if len(batches[idx]) < batch_size:
batches[idx].append(q)
return (idx + 1) % nb
raise ValueError("所有 batch 均满仍有样本待放置, 总容量估算异常")