feat(batching): unit 粒度切分——pair 整锁 + 单元级分桶 + 非 AR 独立 rng
build_batches 改以 QuestionUnit 为原子调度单元:孪生对 2 题整锁进同一 batch、 按单元级正确性(双向 AND)落 correct/error 桶,不再因 P 对 Q 错被劈或被 FFD 拆箱。 - 非 AR(single)用 random.Random(seed) 复现旧逐题算法确切 draw 序列,AR(pair) 用 _rng_ns(seed,"AR") SHA-256 派生独立流;二者 draw 流互不干扰,故 AR 折叠不改变 非 AR 抽样/洗牌序列——纯非 AR 输入 build_batches 结果与引入 QuestionUnit 前逐字节一致。 - FFD 容量按 unit.size(pair 占 2),round-robin 遇碎片新开 bin 兜底而非报错。 - _select_mixed_by_task_type 分流各跑一次后合并,大类洗牌按 kind 拆分各用对应 rng。 新增黄金测试 test_batching_pair_lock.py 覆盖三条铁律(同 batch / 单元分桶 / 非 AR byte-identical + draw 流独立);既有 batching 测试全绿。
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"""app/harness/batching.py 的 unit 粒度切分测试(Task 5)。
|
||||
|
||||
覆盖 pair 契约在 mini-batch 构建中的三条铁律:
|
||||
- (a) 同 pair_id 两题整锁进同一 batch(像小类整组不拆);
|
||||
- (b) pair 按 unit correctness(双向 AND)落 correct/error 桶,不因 P 对 Q 错被劈;
|
||||
- (c) 非 AR byte-identical:AR unit 折叠不改变非 AR 的 rng.sample/shuffle 抽样序列——
|
||||
纯非 AR 输入下 build_batches 结果与"引入 QuestionUnit 前"的旧算法逐字节一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from app.harness.batching import build_batches
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助构造
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_single(
|
||||
qid: str,
|
||||
task_type: str = "default",
|
||||
video_id: str = "v1",
|
||||
) -> GeneratedQuestion:
|
||||
"""构造最小 single GeneratedQuestion。"""
|
||||
return GeneratedQuestion(
|
||||
question_id=qid,
|
||||
video_id=video_id,
|
||||
task_type=task_type,
|
||||
question=f"question_{qid}",
|
||||
options=("A. a", "B. b", "C. c", "D. d"),
|
||||
answer="A",
|
||||
source_nodes=("n1",),
|
||||
difficulty="medium",
|
||||
question_role="single",
|
||||
)
|
||||
|
||||
|
||||
def _make_pair(
|
||||
pair_id: str,
|
||||
task_type: str = "AR",
|
||||
video_id: str = "v1",
|
||||
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
|
||||
"""构造一个孪生对(original + mirror),共享 pair_id / flip_axis。"""
|
||||
common = {
|
||||
"video_id": video_id,
|
||||
"task_type": task_type,
|
||||
"question": "?",
|
||||
"options": ("A. a", "B. b", "C. c", "D. d"),
|
||||
"answer": "A",
|
||||
"source_nodes": ("n1",),
|
||||
"difficulty": "medium",
|
||||
"pair_id": pair_id,
|
||||
"flip_axis": "before_after",
|
||||
}
|
||||
original = GeneratedQuestion(
|
||||
question_id=f"{pair_id}_o", question_role="pair_original", **common
|
||||
)
|
||||
mirror = GeneratedQuestion(question_id=f"{pair_id}_m", question_role="pair_mirror", **common)
|
||||
return original, mirror
|
||||
|
||||
|
||||
def _batch_of(batches: list[list[GeneratedQuestion]], qid: str) -> int:
|
||||
"""返回 qid 所在 batch 的下标;不存在返回 -1。"""
|
||||
for i, b in enumerate(batches):
|
||||
if any(q.question_id == qid for q in b):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) 同 pair_id 两题落同一 batch(整锁不拆)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPairStaysInSameBatch:
|
||||
"""孪生对两题必须整锁进同一 batch,无论散布在多少 single 之间。"""
|
||||
|
||||
def test_single_pair_same_batch(self) -> None:
|
||||
po, pm = _make_pair("pairA", task_type="AR")
|
||||
singles = [_make_single(f"s{i}", task_type="RETRIEVAL") for i in range(10)]
|
||||
items = [*singles, po, pm]
|
||||
correctness = {q.question_id: False for q in items}
|
||||
batches, count = build_batches(
|
||||
items, correctness, batch_size=4, min_class_per_batch=2, seed=3
|
||||
)
|
||||
assert count == 12
|
||||
idx_o = _batch_of(batches, "pairA_o")
|
||||
idx_m = _batch_of(batches, "pairA_m")
|
||||
assert idx_o != -1 and idx_o == idx_m
|
||||
|
||||
def test_many_pairs_each_intact(self) -> None:
|
||||
items: list[GeneratedQuestion] = []
|
||||
for k in range(5):
|
||||
po, pm = _make_pair(f"p{k}", task_type="AR")
|
||||
items.extend([po, pm])
|
||||
items.extend(_make_single(f"s{i}", task_type="SPATIAL") for i in range(6))
|
||||
correctness = {q.question_id: False for q in items}
|
||||
batches, _ = build_batches(items, correctness, batch_size=6, min_class_per_batch=2, seed=11)
|
||||
for k in range(5):
|
||||
idx_o = _batch_of(batches, f"p{k}_o")
|
||||
idx_m = _batch_of(batches, f"p{k}_m")
|
||||
assert idx_o != -1 and idx_o == idx_m, f"pair p{k} 被拆到不同 batch"
|
||||
# 每个 batch 容量不超限(pair 占 2)
|
||||
for b in batches:
|
||||
assert len(b) <= 6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b) pair 按 unit correctness(双向 AND)分桶
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPairBucketedByUnitCorrectness:
|
||||
"""P 对 Q 错的 pair 是 error 单元,不因单题分歧被劈到两个桶。"""
|
||||
|
||||
def test_p_correct_q_wrong_pair_is_error_unit(self) -> None:
|
||||
po, pm = _make_pair("pairErr", task_type="AR")
|
||||
# 混一个 single 错题避免空池边界
|
||||
s0 = _make_single("s0", task_type="AR")
|
||||
items = [po, pm, s0]
|
||||
# original 对、mirror 错 → 单元级 AND = 错 → 应作为 error 单元整体进 batch
|
||||
correctness = {"pairErr_o": True, "pairErr_m": False, "s0": False}
|
||||
batches, count = build_batches(
|
||||
items, correctness, batch_size=6, min_class_per_batch=2, seed=0, correct_ratio=0.0
|
||||
)
|
||||
idx_o = _batch_of(batches, "pairErr_o")
|
||||
idx_m = _batch_of(batches, "pairErr_m")
|
||||
# 两题都在(未被"P 对"劈掉)且同 batch
|
||||
assert idx_o != -1 and idx_o == idx_m
|
||||
# 纯错题模式下 pair 单元整体被选入
|
||||
assert count == 3
|
||||
|
||||
def test_both_correct_pair_excluded_in_pure_error_mode(self) -> None:
|
||||
po, pm = _make_pair("pairOk", task_type="AR")
|
||||
s_err = _make_single("s_err", task_type="AR")
|
||||
items = [po, pm, s_err]
|
||||
correctness = {"pairOk_o": True, "pairOk_m": True, "s_err": False}
|
||||
batches, count = build_batches(
|
||||
items, correctness, batch_size=6, min_class_per_batch=2, seed=0, correct_ratio=0.0
|
||||
)
|
||||
# both-correct pair 是 correct 单元,纯错题模式下不入池
|
||||
assert count == 1
|
||||
assert _batch_of(batches, "pairOk_o") == -1
|
||||
assert _batch_of(batches, "pairOk_m") == -1
|
||||
assert _batch_of(batches, "s_err") != -1
|
||||
|
||||
def test_both_correct_pair_stays_intact_when_mixed(self) -> None:
|
||||
"""correct_ratio>0 时 both-correct pair 可作为整体 correct 单元混入。"""
|
||||
errs = [_make_single(f"e{i}", task_type="AR") for i in range(4)]
|
||||
po, pm = _make_pair("pairOk", task_type="AR")
|
||||
items = [*errs, po, pm]
|
||||
correctness = {f"e{i}": False for i in range(4)}
|
||||
correctness["pairOk_o"] = True
|
||||
correctness["pairOk_m"] = True
|
||||
batches, _ = build_batches(
|
||||
items, correctness, batch_size=10, min_class_per_batch=2, seed=1, correct_ratio=0.5
|
||||
)
|
||||
idx_o = _batch_of(batches, "pairOk_o")
|
||||
idx_m = _batch_of(batches, "pairOk_m")
|
||||
# 若被混入,两题必须整体同 batch;若未被采样,两题都不在
|
||||
if idx_o == -1:
|
||||
assert idx_m == -1
|
||||
else:
|
||||
assert idx_o == idx_m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c) 非 AR byte-identical:与"引入 QuestionUnit 前"旧算法逐字节一致
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _reference_select_mixed(
|
||||
items: list[GeneratedQuestion],
|
||||
correctness: dict[str, bool],
|
||||
correct_ratio: float,
|
||||
rng: random.Random,
|
||||
) -> dict[str, list[GeneratedQuestion]]:
|
||||
"""旧版 _select_mixed_by_task_type 的忠实副本(逐题、单一 rng)。"""
|
||||
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
|
||||
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 _reference_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]:
|
||||
"""引入 QuestionUnit 前的旧版 build_batches 的忠实副本(逐题、单一 rng)。"""
|
||||
rng = random.Random(seed)
|
||||
grouped = _reference_select_mixed(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 = {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}
|
||||
for t in sorted(small, key=lambda t: (-len(small[t]), t)):
|
||||
group = small[t]
|
||||
placed = False
|
||||
for b in batches:
|
||||
if len(b) + len(group) <= batch_size:
|
||||
b.extend(group)
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
batches.append(list(group))
|
||||
nb_live = len(batches)
|
||||
pointer = 0
|
||||
for task_type in sorted(large):
|
||||
group = list(large[task_type])
|
||||
rng.shuffle(group)
|
||||
for q in group:
|
||||
for offset in range(nb_live):
|
||||
idx = (pointer + offset) % nb_live
|
||||
if len(batches[idx]) < batch_size:
|
||||
batches[idx].append(q)
|
||||
pointer = (idx + 1) % nb_live
|
||||
break
|
||||
result = [b for b in batches if b]
|
||||
return result, sum(len(b) for b in result)
|
||||
|
||||
|
||||
class TestNonARByteIdentical:
|
||||
"""纯 single 输入下 build_batches 与旧逐题算法逐字节一致(黄金对照)。"""
|
||||
|
||||
def _assert_identical(
|
||||
self,
|
||||
items: list[GeneratedQuestion],
|
||||
correctness: dict[str, bool],
|
||||
batch_size: int,
|
||||
min_class_per_batch: int,
|
||||
seed: int,
|
||||
correct_ratio: float,
|
||||
) -> None:
|
||||
got_batches, got_count = build_batches(
|
||||
items, correctness, batch_size, min_class_per_batch, seed, correct_ratio
|
||||
)
|
||||
ref_batches, ref_count = _reference_build_batches(
|
||||
items, correctness, batch_size, min_class_per_batch, seed, correct_ratio
|
||||
)
|
||||
got_ids = [[q.question_id for q in b] for b in got_batches]
|
||||
ref_ids = [[q.question_id for q in b] for b in ref_batches]
|
||||
assert got_ids == ref_ids
|
||||
assert got_count == ref_count
|
||||
|
||||
def test_pure_errors_identical(self) -> None:
|
||||
items = [_make_single(f"q{i}", task_type=f"type_{i % 3}") for i in range(20)]
|
||||
correctness = {f"q{i}": False for i in range(20)}
|
||||
self._assert_identical(items, correctness, 5, 2, 42, 0.0)
|
||||
|
||||
def test_mixed_ratio_identical(self) -> None:
|
||||
items = [_make_single(f"q{i}", task_type=f"type_{i % 4}") for i in range(40)]
|
||||
correctness = {f"q{i}": (i % 3 == 0) for i in range(40)}
|
||||
self._assert_identical(items, correctness, 8, 3, 7, 0.5)
|
||||
|
||||
def test_large_round_robin_identical(self) -> None:
|
||||
items = [_make_single(f"q{i}", task_type="big") for i in range(30)]
|
||||
correctness = {f"q{i}": False for i in range(30)}
|
||||
self._assert_identical(items, correctness, 6, 2, 99, 0.0)
|
||||
|
||||
def test_ar_folding_does_not_shift_nonar_draws(self) -> None:
|
||||
"""加入 AR pair 不改变非 AR single 的抽样序列(draw 流独立)。"""
|
||||
singles = [_make_single(f"q{i}", task_type=f"type_{i % 3}") for i in range(24)]
|
||||
s_correctness = {f"q{i}": (i % 4 == 0) for i in range(24)}
|
||||
base, _ = build_batches(singles, s_correctness, 6, 2, 5, 0.5)
|
||||
base_ids = {q.question_id for b in base for q in b}
|
||||
|
||||
# 追加若干 AR pair(不同 task_type),非 AR single 的入选集合应不变
|
||||
items = list(singles)
|
||||
correctness = dict(s_correctness)
|
||||
for k in range(3):
|
||||
po, pm = _make_pair(f"p{k}", task_type="AR")
|
||||
items.extend([po, pm])
|
||||
correctness[po.question_id] = False
|
||||
correctness[pm.question_id] = False
|
||||
with_ar, _ = build_batches(items, correctness, 6, 2, 5, 0.5)
|
||||
with_ar_single_ids = {q.question_id for b in with_ar for q in b if q.pair_id is None}
|
||||
assert with_ar_single_ids == base_ids
|
||||
Reference in New Issue
Block a user