feat(question_gen): loader 按 unit 分层采样 + load_benchmark 读回 pair 字段
stratified_sample 先 build_units 聚合,以 QuestionUnit 为采样原子做 分层/去重/补足/rng.sample,返回前 flatten_units 展开为逐题列表; size/correct_ratio/min_per_class 均按 unit 计数,单元正确性走成员 AND, 孪生对两题永不被劈开。纯 single 输入下 build_units 1:1 折叠、顺序不变, rng 消耗与旧逐题实现字节级一致(新增回归测试守护)。 _backfill_per_class candidates 改按 unit 枚举去重;build_units/flatten_units 函数内延迟导入以规避 question_gen<->harness 循环依赖(沿用 adversarial_filter)。 load_benchmark 反序列化补 pair_id/question_role/flip_axis/unit_id 四字段, 用 .get 兼容旧 JSON(缺失退化为 single,unit_id 由 __post_init__ 回填)。 pools._sample_excluding 随之改为透传 flatten_units(candidates) 给已单元化的 stratified_sample(不再用 lone pair-original 代表),行为对 single-only 保持等价。
This commit is contained in:
+78
-41
@@ -15,6 +15,8 @@ from core.types import GeneratedQuestion
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from core.types import QuestionUnit
|
||||
|
||||
_LEGACY_DEFAULT_DIFFICULTY = "medium"
|
||||
|
||||
|
||||
@@ -26,6 +28,11 @@ def load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]:
|
||||
video_id),v2 生成题把多视频题目合并在单个 JSON 中(每条记录自带
|
||||
``video_id``),两种格式均兼容。
|
||||
|
||||
pair 契约字段(``pair_id`` / ``question_role`` / ``flip_axis`` / ``unit_id``)
|
||||
用 ``.get`` 读取:旧 benchmark 无这些键时退化为 single(``question_role``
|
||||
默认 "single",``unit_id`` 留空由 __post_init__ 回填为 question_id),
|
||||
保证历史题库可无缝加载。
|
||||
|
||||
参数:
|
||||
questions_dir: 包含 *.json 文件的目录路径。
|
||||
|
||||
@@ -52,6 +59,12 @@ def load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]:
|
||||
skill_target=qa.get("skill_target"),
|
||||
difficulty_steps=qa.get("difficulty_steps"),
|
||||
sub_pattern=qa.get("sub_pattern"),
|
||||
# pair 契约字段:旧 benchmark 无这些键时按 single 默认兜底,
|
||||
# unit_id 留空交由 GeneratedQuestion.__post_init__ 回填。
|
||||
pair_id=qa.get("pair_id"),
|
||||
question_role=qa.get("question_role", "single"),
|
||||
flip_axis=qa.get("flip_axis"),
|
||||
unit_id=qa.get("unit_id", ""),
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -66,62 +79,88 @@ def stratified_sample(
|
||||
seed: int,
|
||||
min_per_class: int | None,
|
||||
) -> list[GeneratedQuestion]:
|
||||
"""按题型过滤后采样 size 道题,可选按对错比例分层并按题型保底。
|
||||
"""按题型过滤后采样 size 个单元,可选按对错比例分层并按题型保底。
|
||||
|
||||
参数:
|
||||
questions: 候选题目全集。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
size: 采样总量。
|
||||
correct_ratio: 采样中"基线答对"题的占比;None 表示自然分布。
|
||||
questions: 候选题目全集(single 与孪生对成员可混含)。
|
||||
correctness: question_id -> 基线是否答对(单元级正确性取成员 AND)。
|
||||
size: 采样单元总量(single 计 1、pair 计 1)。
|
||||
correct_ratio: 采样中"基线答对"单元的占比;None 表示自然分布。
|
||||
task_types: 限定题型;None 表示不限。
|
||||
seed: 随机种子,保证可复现。
|
||||
min_per_class: 每个题型补足到的下限;None 表示不补足。
|
||||
min_per_class: 每个题型补足到的单元下限;None 表示不补足。
|
||||
|
||||
返回:
|
||||
采样后的题目列表。
|
||||
采样后的题目列表(pair 单元展开为原始的两道题)。
|
||||
|
||||
异常:
|
||||
ValueError: 自然分布时池不足 size,或分层时某层题目不足。
|
||||
ValueError: 自然分布时单元池不足 size,或分层时某层单元不足。
|
||||
|
||||
关键实现:
|
||||
以 **QuestionUnit 为采样原子**(single 计 1、pair 计 1),size /
|
||||
correct_ratio / min_per_class 均按 unit 计数,孪生对两题永不被劈开。
|
||||
采样完成后 flatten_units 展开回逐题列表。纯 single 输入时 build_units
|
||||
与题目一一对应、顺序不变,rng 消耗与旧逐题实现完全一致(字节级回归)。
|
||||
|
||||
build_units / flatten_units 采用函数内延迟导入:loader 属 question_gen,
|
||||
question_units 属 harness,而 harness 包初始化会反向 import question_gen,
|
||||
模块级导入将触发循环依赖(沿用 adversarial_filter 的既有做法)。
|
||||
"""
|
||||
from app.harness.question_units import build_units, flatten_units
|
||||
|
||||
rng = random.Random(seed)
|
||||
pool = [q for q in questions if task_types is None or q.task_type in task_types]
|
||||
units = build_units(questions)
|
||||
pool = [u for u in units if task_types is None or u.task_type in task_types]
|
||||
|
||||
if correct_ratio is None:
|
||||
if len(pool) < size:
|
||||
raise ValueError(f"自然分布采样不足: 需 {size} 道, 实有 {len(pool)} 道")
|
||||
raise ValueError(f"自然分布采样不足: 需 {size} 个单元, 实有 {len(pool)} 个")
|
||||
sampled = rng.sample(pool, size)
|
||||
else:
|
||||
sampled = _ratio_stratified_sample(pool, correctness, size, correct_ratio, rng)
|
||||
|
||||
if min_per_class is not None:
|
||||
sampled = _backfill_per_class(sampled, pool, min_per_class, rng)
|
||||
return sampled
|
||||
return flatten_units(sampled)
|
||||
|
||||
|
||||
def _unit_correct(unit: QuestionUnit, correctness: dict[str, bool]) -> bool:
|
||||
"""单元级正确性:成员全部答对才算对(缺失按 False,宽松口径)。
|
||||
|
||||
参数:
|
||||
unit: 目标单元(single 1 题,pair 2 题)。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
|
||||
返回:
|
||||
pair 走双向 AND、single 即单题正确性;任一成员缺失或答错即 False。
|
||||
"""
|
||||
return all(correctness.get(q.question_id, False) for q in unit.questions)
|
||||
|
||||
|
||||
def _ratio_stratified_sample(
|
||||
pool: list[GeneratedQuestion],
|
||||
pool: list[QuestionUnit],
|
||||
correctness: dict[str, bool],
|
||||
size: int,
|
||||
correct_ratio: float,
|
||||
rng: random.Random,
|
||||
) -> list[GeneratedQuestion]:
|
||||
"""按对错比例分层采样:对题占 correct_ratio,其余为错题。
|
||||
) -> list[QuestionUnit]:
|
||||
"""按对错比例分层采样:对单元占 correct_ratio,其余为错单元。
|
||||
|
||||
参数:
|
||||
pool: 题型过滤后的候选题。
|
||||
pool: 题型过滤后的候选单元。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
size: 采样总量。
|
||||
correct_ratio: 对题占比。
|
||||
size: 采样单元总量。
|
||||
correct_ratio: 对单元占比。
|
||||
rng: 随机数发生器。
|
||||
|
||||
返回:
|
||||
采样后的题目列表(对题在前、错题在后)。
|
||||
采样后的单元列表(对单元在前、错单元在后)。
|
||||
|
||||
异常:
|
||||
ValueError: 对题或错题层不足。
|
||||
ValueError: 对单元或错单元层不足。
|
||||
"""
|
||||
correct = [q for q in pool if correctness.get(q.question_id, False)]
|
||||
wrong = [q for q in pool if not correctness.get(q.question_id, False)]
|
||||
correct = [u for u in pool if _unit_correct(u, correctness)]
|
||||
wrong = [u for u in pool if not _unit_correct(u, correctness)]
|
||||
n_correct = round(size * correct_ratio)
|
||||
n_wrong = size - n_correct
|
||||
if len(correct) < n_correct or len(wrong) < n_wrong:
|
||||
@@ -132,42 +171,40 @@ def _ratio_stratified_sample(
|
||||
|
||||
|
||||
def _backfill_per_class(
|
||||
sampled: list[GeneratedQuestion],
|
||||
pool: list[GeneratedQuestion],
|
||||
sampled: list[QuestionUnit],
|
||||
pool: list[QuestionUnit],
|
||||
min_per_class: int,
|
||||
rng: random.Random,
|
||||
) -> list[GeneratedQuestion]:
|
||||
"""对候选池中出现的每个题型,将采样结果补足到 min_per_class 道。
|
||||
) -> list[QuestionUnit]:
|
||||
"""对候选池中出现的每个题型,将采样单元补足到 min_per_class 个。
|
||||
|
||||
遍历对象是候选池 pool 里出现的全部题型(非仅 sampled 命中的),
|
||||
保证任意稀疏题型都能拿到足额样本。
|
||||
保证任意稀疏题型都能拿到足额样本。补足以 unit 为原子,孪生对整进整出。
|
||||
|
||||
参数:
|
||||
sampled: 主采样结果(不修改,返回新列表)。
|
||||
pool: 候选题全集(补足来源 + 题型枚举来源)。
|
||||
min_per_class: 每个题型的下限。
|
||||
sampled: 主采样结果单元(不修改,返回新列表)。
|
||||
pool: 候选单元全集(补足来源 + 题型枚举来源)。
|
||||
min_per_class: 每个题型的单元下限。
|
||||
rng: 随机数发生器。
|
||||
|
||||
返回:
|
||||
补足后的题目列表。
|
||||
补足后的单元列表。
|
||||
"""
|
||||
selected_ids = {q.question_id for q in sampled}
|
||||
selected_ids = {u.unit_id for u in sampled}
|
||||
result = list(sampled)
|
||||
counts: dict[str, int] = {}
|
||||
for q in sampled:
|
||||
counts[q.task_type] = counts.get(q.task_type, 0) + 1
|
||||
for u in sampled:
|
||||
counts[u.task_type] = counts.get(u.task_type, 0) + 1
|
||||
ordered_task_types: dict[str, None] = {}
|
||||
for q in pool:
|
||||
ordered_task_types.setdefault(q.task_type, None)
|
||||
for u in pool:
|
||||
ordered_task_types.setdefault(u.task_type, None)
|
||||
for task_type in ordered_task_types:
|
||||
deficit = min_per_class - counts.get(task_type, 0)
|
||||
if deficit <= 0:
|
||||
continue
|
||||
candidates = [
|
||||
q for q in pool if q.task_type == task_type and q.question_id not in selected_ids
|
||||
]
|
||||
candidates = [u for u in pool if u.task_type == task_type and u.unit_id not in selected_ids]
|
||||
take = rng.sample(candidates, min(deficit, len(candidates)))
|
||||
for q in take:
|
||||
selected_ids.add(q.question_id)
|
||||
result.append(q)
|
||||
for u in take:
|
||||
selected_ids.add(u.unit_id)
|
||||
result.append(u)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user