mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
88e219eabc
为什么:总表里「没开评、停下、出错」的题以前只体现在计数里,准确率旁边看不出来;下一次开跑前的估算按每题平均花费算, 把没开评的题也摊进去会估低。 - stats.summarize 加 not_run;per_case_cost_usd 只按评完了的题平均 - test_eval_compare.py:逐题转移与归因、指纹差异、A/B 配对表、选基准(从 test_eval_stats 拆出来,对上细案的测试清单) - test_eval_runner:最近 10 题里 7 题超时就止损
160 lines
9.3 KiB
Python
160 lines
9.3 KiB
Python
"""评测的汇总与统计(细案 01 做法 11、12;评测-D10)。所有汇总都在后端算好,界面直接显示(界面层不放逻辑:同一个数不能
|
||
有两套算法)。纯计算,输入是一次评测的逐题结果(EvalResult 的列,result_row 的形状)。
|
||
|
||
- 准确率和可追溯准确率:分母是能判的题(通过 + 不通过);不能评分、跳过的单列,既不算对也不算错,没开评、停下、出错的
|
||
另列 not_run。都带 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
|
||
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": len(done) - len(evaluated),
|
||
"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"]
|