Files
AgentLab/backend/app/eval/stats.py
ThearchyHelios 7794794278 评测的两种分析期望(贡献、判定):期望 SQL 的探针快照上用平台的算子(app.analysis.run,随数据变化的调用错误记成算不了)算出期望的判定,评分沿用五种结论
- 题目(cases.py):contribution 贡献恒为变化拆解(可加、比率、乘积)、另写 top_n(期望点名主要来源的前几段,缺省 1);verdict 判定是波动基线、趋势判断、差异检验、可比性检查。参数同分析工具(不写输入、总数、下钻,不写阈值),文字参数可以带 {{ schedule.* }};来源、方向、判定不许人填(HUMAN_VALUE_KEYS 加 segment、subjects、direction、verdict 等);口径题、期望反问的题配不了
- 算期望(gold.py、judgements.py):遮罩列、目录事实、现查表的日期范围和分析工具同一套取法;阈值用这次评测的分析设置快照(Context.analysis),试算时现读设置;期望 SQL 只返回两期时可以不写基期、本期;算不了的这一项照实记(评分时不能评分),参数和结果对不上的整题没有可用的值;截断的快照照旧拒收。作答前后两次期望、和上一次比的「数据变了」都不比运行当天、现查时刻、输入快照的工件(stable_sets)
- 评分(score.py、judgements.py,SCORER_VERSION 3):判定码归到同一套判定类再比;答案引用了(判定语、具名的数、结果表的格、句末依据)判定对得上的分析结论是对且可追溯,引用它的那句却写反了不算;没引用但正文按说法表(engine/analysis_claims 同一个 scan、同一套绑定规则)写对了是对但没引用;判定、来源、方向对不上或写成相反、根本没做也没说是错。答案的分析结论从作答运行的台账读、引用从报告文档读,有判断词的句子一并读出;逐题 E1 引用分析结论时按它的输入查询比(下钻顺着上一层找);判定语不再当成答案里的数;老结果的明细里没有这两项按空读
- 执行(runner.py、answer.py):有分析期望的题集开评时拍一份分析设置,算期望、各臂作答的运行(Run.options 的 analysis)都按它,记进指纹(compare 的「分析设置」一项),重试沿用;别的题集的指纹、作答运行的选项不变。助手提示词的指纹加上分析方法(guide 的 id 和全文)、写作者的分析规则和说法表版本
- 总表(stats.py):各期望的结论另按期望的种类各一份(outcomes_by_kind)
2026-10-21 18:11:57 +08:00

161 lines
10 KiB
Python
Raw Permalink 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:只展示、不卡门)。
- 各期望的结论(必需的、文字要点除外)合起来一份,另按期望的种类各一份(outcomes_by_kind:贡献、判定这些分析题和
一个数、多行分开看)。
- 作答路径、出具档位(声明了出具的按 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
#: Wilson 区间和 95% 区间的 z 在分析的统计里(总方案 D13:分析和评测共用一套纯 Python 的统计),这里引用、行为不变
from app.analysis.stats import Z95, wilson
#: 问数据页等复核最多这么久(毫秒,同 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 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")
by_kind: dict[str, Counter] = {}
for r in done:
for s in r.get("scores") or []:
if s.get("required") and s.get("kind") != "text":
by_kind.setdefault(str(s.get("kind")), Counter())[s.get("outcome")] += 1
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),
"outcomes_by_kind": {kind: dict(found) for kind, found in by_kind.items()},
"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,
# 每题平均花费(下一次开跑前的估算按它):只按评完了的题平均,没开评的不摊。有估不出金额的调用时 cost_usd 只是估得出
# 的那部分:一分都没估出来(模型都不在价格目录里)的为 None,不写成 0(像不花钱);估出了一部分的照给,unpriced_calls
# 说明它只是下限
"per_case_cost_usd": round(cost / len(evaluated), 6) if evaluated and (cost > 0 or not unpriced) 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"]