feat: aggregate inference by question unit with pair AND
Reuse build_units/unit_correctness (pair contract single entry) in the inference aggregation step: single questions count as one unit, AR pairs collapse original+mirror into one unit scored by bidirectional AND. total/ correct/per_task_type are unit-grained; orphan pairs (missing one side) are warned and dropped, not counted. Per-question predictions still land row by row (traceability unchanged).
This commit is contained in:
+94
-25
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.question_units import build_units, unit_correctness
|
||||
from core.agent.loop import AgentLoop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -28,7 +29,7 @@ if TYPE_CHECKING:
|
||||
from app.harness.log import HarnessLog
|
||||
from core.agent.types import LoopResult
|
||||
from core.protocols import LLMProvider
|
||||
from core.types import GeneratedQuestion
|
||||
from core.types import GeneratedQuestion, QuestionUnit
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -182,23 +183,25 @@ def _zero_result(run_id: str) -> InferenceResult:
|
||||
)
|
||||
|
||||
|
||||
def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""按 task_type 分组聚合正确率指标。
|
||||
def _group_by_task_type(graded: list[tuple[QuestionUnit, bool]]) -> dict[str, dict[str, Any]]:
|
||||
"""按 task_type 分组聚合 unit 级正确率指标。
|
||||
|
||||
pair 单元整体计 1 个 unit,归入其 task_type;single 单元计 1 个 unit。
|
||||
|
||||
参数:
|
||||
records: 预测记录列表。
|
||||
graded: (单元, 该单元是否整体正确) 元组列表。
|
||||
|
||||
返回:
|
||||
{task_type: {accuracy, total, correct}} 映射。
|
||||
{task_type: {accuracy, total, correct}} 映射(unit 粒度)。
|
||||
"""
|
||||
task_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for r in records:
|
||||
task_groups[r["task_type"]].append(r)
|
||||
task_groups: dict[str, list[bool]] = defaultdict(list)
|
||||
for unit, is_correct in graded:
|
||||
task_groups[unit.task_type].append(is_correct)
|
||||
|
||||
per_task_type: dict[str, dict[str, Any]] = {}
|
||||
for task_type, group in task_groups.items():
|
||||
t_total = len(group)
|
||||
t_correct = sum(1 for r in group if r["prediction"] == r["answer"])
|
||||
for task_type, verdicts in task_groups.items():
|
||||
t_total = len(verdicts)
|
||||
t_correct = sum(verdicts)
|
||||
per_task_type[task_type] = {
|
||||
"accuracy": t_correct / t_total,
|
||||
"total": t_total,
|
||||
@@ -207,35 +210,101 @@ def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, An
|
||||
return per_task_type
|
||||
|
||||
|
||||
def _aggregate_results(records: list[dict[str, Any]], run_id: str) -> InferenceResult:
|
||||
"""从内存 records 聚合推理指标。
|
||||
def _drop_orphan_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
|
||||
"""剔除收不齐 2 条 / 角色非法的孤儿 pair,告警不静默。
|
||||
|
||||
TRM4 从 DB 回读 predictions 表聚合;TRM5 改为从内存直接聚合,
|
||||
避免 DB 回读的同步开销和额外依赖。
|
||||
每条题目均会各答一次并逐题落库;能否合成 pair 单元仅取决于 questions
|
||||
是否同时含该 pair_id 的 original + mirror。收不齐者告警并整对剔除,使
|
||||
后续 build_units 只面对合法孪生对(不触发 fail-fast),孤儿 unit 不计入
|
||||
total(对齐设计 §8 聚合入口的"告警 + 剔除")。
|
||||
|
||||
参数:
|
||||
records: _run_single_question 返回的 record 列表。
|
||||
questions: 待聚合的题目列表(可混含 single 与孪生对成员)。
|
||||
|
||||
返回:
|
||||
可安全交给 build_units 的题目列表(single 全保留,pair 仅保留成对者)。
|
||||
"""
|
||||
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
|
||||
singles: list[GeneratedQuestion] = []
|
||||
for q in questions:
|
||||
if q.pair_id:
|
||||
by_pair[q.pair_id].append(q)
|
||||
else:
|
||||
singles.append(q)
|
||||
|
||||
kept_pairs: list[GeneratedQuestion] = []
|
||||
for pair_id, group in by_pair.items():
|
||||
originals = [q for q in group if q.question_role == "pair_original"]
|
||||
mirrors = [q for q in group if q.question_role == "pair_mirror"]
|
||||
if len(originals) == 1 and len(mirrors) == 1:
|
||||
kept_pairs.extend(group)
|
||||
else:
|
||||
logger.warning(
|
||||
"孤儿 pair {}:收不齐 2 条(original={} mirror={}),剔除该 unit 不计入 total",
|
||||
pair_id,
|
||||
len(originals),
|
||||
len(mirrors),
|
||||
)
|
||||
return singles + kept_pairs
|
||||
|
||||
|
||||
def _per_question_correctness(records: list[dict[str, Any]]) -> dict[str, bool]:
|
||||
"""由逐题 record 构造 question_id → 该题作答是否正确 的映射。
|
||||
|
||||
prediction 为 None(作答异常)时与 answer 不相等 → False,天然计错。
|
||||
|
||||
参数:
|
||||
records: _run_single_question 返回的逐题 record 列表。
|
||||
|
||||
返回:
|
||||
{question_id: prediction == answer} 映射,供 unit_correctness 取值。
|
||||
"""
|
||||
return {r["question_id"]: r["prediction"] == r["answer"] for r in records}
|
||||
|
||||
|
||||
def _aggregate_results(
|
||||
records: list[dict[str, Any]],
|
||||
questions: list[GeneratedQuestion],
|
||||
run_id: str,
|
||||
) -> InferenceResult:
|
||||
"""从内存 records + 题目列表按 unit 粒度聚合推理指标。
|
||||
|
||||
逐题 record 保留逐题溯源(token/steps/stop_reason 诊断仍按 record 汇总);
|
||||
正确率则按 unit 粒度计:single 计 1,AR pair 经 build_units 收齐 original +
|
||||
mirror 后走 unit_correctness 的双向 AND 判定,整对计 1 个 unit。孤儿 pair
|
||||
在 _drop_orphan_pairs 中告警 + 剔除,不计入 total。
|
||||
|
||||
参数:
|
||||
records: _run_single_question 返回的逐题 record 列表。
|
||||
questions: 与 records 对应的题目列表(提供 pair_id/question_role 元数据)。
|
||||
run_id: 当前运行标识。
|
||||
|
||||
返回:
|
||||
InferenceResult 冻结实例。
|
||||
InferenceResult 冻结实例(total/correct/per_task_type 为 unit 粒度)。
|
||||
"""
|
||||
total = len(records)
|
||||
if total == 0:
|
||||
if not records:
|
||||
return _zero_result(run_id)
|
||||
|
||||
correct = sum(1 for r in records if r["prediction"] == r["answer"])
|
||||
per_q = _per_question_correctness(records)
|
||||
units = build_units(_drop_orphan_pairs(questions))
|
||||
# unit 内任一题缺 record → unit_correctness 抛 KeyError(防静默兜底/读回校验)。
|
||||
graded = [(unit, unit_correctness(unit, per_q)) for unit in units]
|
||||
|
||||
total = len(graded)
|
||||
correct = sum(1 for _, is_correct in graded if is_correct)
|
||||
|
||||
stop_counts: dict[str, int] = defaultdict(int)
|
||||
for r in records:
|
||||
stop_counts[r["stop_reason"]] += 1
|
||||
|
||||
n_records = len(records)
|
||||
return InferenceResult(
|
||||
run_id=run_id,
|
||||
accuracy=correct / total,
|
||||
accuracy=correct / total if total else 0.0,
|
||||
total=total,
|
||||
correct=correct,
|
||||
per_task_type=_group_by_task_type(records),
|
||||
steps_mean=sum(r["steps_used"] for r in records) / total,
|
||||
per_task_type=_group_by_task_type(graded),
|
||||
steps_mean=sum(r["steps_used"] for r in records) / n_records,
|
||||
token_usage={
|
||||
"prompt_tokens": sum(r["prompt_tokens"] for r in records),
|
||||
"completion_tokens": sum(r["completion_tokens"] for r in records),
|
||||
@@ -399,7 +468,7 @@ async def run_inference(
|
||||
|
||||
if not questions:
|
||||
logger.info("题目列表为空,返回零值 InferenceResult")
|
||||
return _aggregate_results([], run_id)
|
||||
return _aggregate_results([], [], run_id)
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
total_count = len(questions)
|
||||
@@ -431,7 +500,7 @@ async def run_inference(
|
||||
|
||||
results = await asyncio.gather(*[_bounded(i, qa) for i, qa in enumerate(questions)])
|
||||
|
||||
inference_result = _aggregate_results(list(results), run_id)
|
||||
inference_result = _aggregate_results(list(results), questions, run_id)
|
||||
logger.info(
|
||||
"推理完成: accuracy={:.2%} ({}/{})",
|
||||
inference_result.accuracy,
|
||||
|
||||
Reference in New Issue
Block a user