Files
AgentLab/backend/app/eval/stats.py
T
ThearchyHelios ccb2b2baa8 评测停止、中断、到了上限后的计数照实分开:没跑的题单独计 not_run,stop_reason 写停下的原因
验收:评测停止或中断后,没跑的题计入 errors、done = total、stop_reason 为 null,读起来像全评完了、出了很多错。
原因:runner._counts 把 cancelled、skipped 都算进 done(TASK_DONE),cancelled 又算进 errors;停止、中断走 _close →
_conclude(status, None, error),原因写进了 error、stop_reason 是 None;服务重启后的整理(startup、reconcile_stale)
和排队中就停掉(cancel_queued)只改了逐项状态,计数还是旧的、stop_reason 也没写。

改法:
- _counts:done 只算真的跑过的(ok、partial、failed);停止、中断时没评完的(cancelled)和到了上限、止损之后没开评
  的(skipped)单独计 not_run;errors 只算 failed;skipped 只算评了、但期望算不出、不评分的(verdict),不再混进没开评
  的。done + not_run + running + pending = total。
- 停止、中断是「停下」不是「出错」:_close 把原因记进 stop_reason(STOPPED / INTERRUPTED),error 留空;整体出错照旧
  记 error。到了上限、止损本来就写 stop_reason,不变。还没备好几臂就停下的那一支(bare)同样写 stop_reason 并重算计数。
- core/job_runtime 的 close_job、cancel_queued 加一个可选的 finish:状态改好、提交之前在同一个事务里补一步。评测的
  startup、reconcile_stale、stop 用它记 stop_reason、按改好的逐项状态重算计数(_closing)。起草、剖析、探查不传,
  行为不变(它们的测试全过)。
- stats.summarize 的 not_run 也只算没跑的(cancelled、skipped),出错的另列 errors(以前 not_run 里混着出错的)。

前端:评测页总览「N 项没评(停止、中断,或者到了上限、止损之后没开评)」和「M 项出错」分两行;类型补上 not_run、
errors 的说明。停下的原因本来就照 stop_reason 显示。

测试(test_eval_runner):停止之后 stop_reason 是「评测已停止」、error 为空,errors 为 0、done 是评完的题数、not_run
是其余,总表的 not_run、errors 同样分开;重启整理成 interrupted 时 stop_reason 是中断的说法、计数重算(not_run 1、
running 0);新增一条排队中就停掉的:not_run 等于题数、pending 归零;到了金额上限没开评的那题记 not_run、不记跳过。
去掉修复回跑挂 4 条。check-evals 的停止夹具照服务端改,核对停下的原因、不当成出错、没评的单列。评测、起草、剖析、
探查、定时维护相关的测试 548 条全过;tsc、check-copy 通过,check-evals 103 项全过。
2026-10-17 09:10:52 +08:00

161 lines
9.5 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.
"""评测的汇总与统计(细案 01 做法 11、12;评测-D10)。所有汇总都在后端算好,界面直接显示(界面层不放逻辑:同一个数不能
有两套算法)。纯计算,输入是一次评测的逐题结果(EvalResult 的列,result_row 的形状)。
- 准确率和可追溯准确率:分母是能判的题(通过 + 不通过);不能评分、跳过的单列,既不算对也不算错,没跑的(停止、中断、
到了上限、止损没开评)另列 not_run,出错的另列 errors。都带 Wilson 95% 区间和样本数——题集小、模型有随机性,差两三题就可能被读成「变好了」「变差了」(评测-D10:只展示、不卡门)。
- 作答路径、出具档位(声明了出具的按 formal / degraded / withheld,没声明的单列「探索问答」)、复核结论(verdict × severity)
和自动重来的次数、复核用时超过问数据页等待(25 秒)的题数。
- 结论裁判(打开了才有):supported / partial / contradicted / insufficient / 未判的句数;文字要点的判断单列。
- look:每题平均几次(分模型和系统),用满这一轮上限的比例;主模型(助手)调用次数。
- 耗时:建图、运行(墙钟、执行)、复核、整题的 P50 / P90。
- token 和成本按角色:助手 = 建图 + 挑表 + 复核;写作 = 答案运行里的模型调用(按模型分);裁判;期望探针不调模型,为 0。
- 报告引用率:numbers_cited / numbers,另给 uncited_numbers、unresolved、unknown_entities。
- 逐题 E1:答案的查询和期望的取数等价的比例、差异方面的分布(score.sql_match,口径账本同一个比对函数)。
- 重复跑 k 次的题:每题的通过率。
McNemar(精确、双侧)给 A/B 的配对表用(compare.py):只看「只 A 对」「只 B 对」的那几题,二项分布 p=0.5 的双侧概率。
"""
from __future__ import annotations
import math
from collections import Counter
from collections.abc import Iterable, Mapping, Sequence
from typing import Any
#: 95% 区间的 z
Z95 = 1.959963984540054
#: 问数据页等复核最多这么久(毫秒,同 answer.REVIEW_PAGE_WAIT_S)
REVIEW_PAGE_WAIT_MS = 25_000
TIERS = ("formal", "degraded", "withheld", "explore")
TIMING_KEYS = ("build_ms", "run_wall_ms", "run_active_ms", "review_ms", "total_ms")
def wilson(passed: int, n: int, z: float = Z95) -> tuple[float, float] | None:
"""Wilson 区间(缺省 95%)。n 为 0 时没有区间。"""
if n <= 0:
return None
p = passed / n
denom = 1 + z * z / n
centre = (p + z * z / (2 * n)) / denom
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
return round(max(0.0, centre - half), 4), round(min(1.0, centre + half), 4)
def mcnemar_exact(only_a: int, only_b: int) -> float:
"""精确 McNemar 双侧 p 值:不一致的那几题里,「只 A 对」服从 Binomial(b + c, 0.5)。没有不一致的题时为 1。"""
n = only_a + only_b
if n == 0:
return 1.0
k = min(only_a, only_b)
tail = sum(math.comb(n, i) for i in range(k + 1)) / (2 ** n)
return round(min(1.0, 2 * tail), 6)
def percentile(values: Sequence[float], q: float) -> float | None:
"""最近秩法的百分位(P50、P90)。没有值时为 None。"""
data = sorted(v for v in values if v is not None)
if not data:
return None
rank = max(1, math.ceil(q / 100 * len(data)))
return data[min(rank, len(data)) - 1]
def rate(part: int, whole: int) -> dict[str, Any]:
"""{passed, n, rate, ci}:比例和它的 Wilson 区间。"""
return {"passed": part, "n": whole, "rate": round(part / whole, 4) if whole else None, "ci": wilson(part, whole)}
def _usage_total(rows: Iterable[Mapping[str, Any] | None]) -> dict[str, Any]:
from app.eval.answer import add_usage
out = add_usage(None, None)
for row in rows:
out = add_usage(out, row)
return out
def summarize(results: Sequence[Mapping[str, Any]], *, judge_on: bool = False) -> dict[str, Any]:
"""一次评测(一臂)的总表。results 是逐题结果(result_row 的形状),只算已经结束的那些。"""
from app.eval.score import SCORER_VERSION
done = [r for r in results if r.get("status") in ("ok", "skipped", "failed", "cancelled", "partial")]
#: 评完了的(status=ok);没跑的(停止、中断、到了上限、止损没开评)单列成 not_run,出错的单列成 errors(同 runner._counts)
evaluated = [r for r in done if r.get("status") == "ok"]
verdicts = Counter(r.get("verdict") or "none" for r in done)
graded = [r for r in done if r.get("verdict") in ("pass", "fail")]
passed = [r for r in graded if r.get("verdict") == "pass"]
traced = [r for r in passed if r.get("traced")]
outcomes = Counter(s.get("outcome") for r in done for s in r.get("scores") or []
if s.get("required") and s.get("kind") != "text")
text = Counter(s.get("outcome") for r in done for s in r.get("scores") or [] if s.get("kind") == "text")
answered = [r for r in done if r.get("path")]
tiers = Counter((r.get("tier") or "explore") for r in answered if r.get("path") == "ran")
reviews = [r.get("review") or {} for r in answered if r.get("review")]
looks = [r.get("looks") or {} for r in answered]
timings = {k: [float((r.get("timings") or {}).get(k)) for r in answered if (r.get("timings") or {}).get(k) is not None]
for k in TIMING_KEYS}
usage = {role: _usage_total((r.get("usage") or {}).get(role) for r in done)
for role in ("assistant", "writer", "judge")}
cost = round(sum(u["cost_usd"] for u in usage.values()), 6)
unpriced = sum(int(u.get("unpriced_calls") or 0) for u in usage.values())
stats = [((r.get("answer_stats") or {})) for r in answered]
numbers = sum(int(s.get("numbers") or 0) for s in stats)
cited = sum(int(s.get("numbers_cited") or 0) for s in stats)
e1 = [r.get("sql_match") for r in done if isinstance(r.get("sql_match"), Mapping)
and (r["sql_match"].get("status") in ("proven", "related", "none"))]
aspects = Counter(a for m in e1 if m.get("status") == "related" for a in m.get("aspects") or [])
claims = Counter()
for r in done:
for key, n in (((r.get("judge") or {}).get("claims") or {}).get("counts") or {}).items():
claims[key] += int(n or 0)
by_case: dict[str, list[Mapping[str, Any]]] = {}
for r in graded:
by_case.setdefault(str(r.get("case_id")), []).append(r)
repeated = {cid: rate(sum(1 for r in rows if r.get("verdict") == "pass"), len(rows))
for cid, rows in by_case.items() if len(rows) > 1}
return {
"scorer_version": SCORER_VERSION,
"items": len(results), "done": len(done),
"cases": len({r.get("case_id") for r in results}),
"verdicts": {k: verdicts.get(k, 0) for k in ("pass", "fail", "ungradable", "skipped")},
"not_run": sum(1 for r in done if r.get("status") in ("cancelled", "skipped")),
"errors": sum(1 for r in done if r.get("status") == "failed"),
"accuracy": rate(len(passed), len(graded)),
"traced_accuracy": rate(len(traced), len(graded)),
"outcomes": dict(outcomes),
"text": dict(text),
"paths": dict(Counter(r.get("path") for r in answered)),
"tiers": {k: tiers.get(k, 0) for k in TIERS},
"review": {
"verdicts": dict(Counter(str(v.get("verdict") or "ok") for v in reviews)),
"severity": dict(Counter(str(v.get("severity")) for v in reviews if v.get("severity"))),
"retries": sum(1 for r in answered if int(r.get("attempts") or 0) > 0),
"slow": sum(1 for r in answered if ((r.get("timings") or {}).get("review_ms") or 0) > REVIEW_PAGE_WAIT_MS),
},
"judge": {"on": judge_on, "claims": dict(claims) if judge_on else None},
"looks": {
"model": sum(int(x.get("model") or 0) for x in looks),
"system": sum(int(x.get("system") or 0) for x in looks),
"per_case": round(sum(int(x.get("model") or 0) + int(x.get("system") or 0) for x in looks) / len(looks), 2)
if looks else None,
"limit_hit": rate(sum(1 for x in looks if x.get("limit_hit")), len(looks))["rate"] if looks else None,
},
"calls": {"assistant": int(usage["assistant"].get("calls") or 0),
"per_case": round(int(usage["assistant"].get("calls") or 0) / len(answered), 2) if answered else None},
"timings": {k: {"p50": percentile(v, 50), "p90": percentile(v, 90), "n": len(v)} for k, v in timings.items()},
"usage": {**usage, "gold": {"cost_usd": 0.0, "calls": 0}},
"cost_usd": cost, "unpriced_calls": unpriced,
# 每题平均花费(下一次开跑前的估算按它):只按评完了的题平均,没开评的不摊
"per_case_cost_usd": round(cost / len(evaluated), 6) if evaluated else None,
"citations": {"numbers": numbers, "numbers_cited": cited,
"uncited_numbers": sum(int(s.get("uncited_numbers") or 0) for s in stats),
"unresolved": sum(int(s.get("unresolved") or 0) for s in stats),
"unknown_entities": sum(int(s.get("unknown_entities") or 0) for s in stats),
"rate": round(cited / numbers, 4) if numbers else None},
"e1": {**rate(sum(1 for m in e1 if m.get("status") == "proven"), len(e1)), "aspects": dict(aspects)},
"pass_rates": repeated,
}
__all__ = ["REVIEW_PAGE_WAIT_MS", "TIERS", "Z95", "mcnemar_exact", "percentile", "rate", "summarize", "wilson"]