Files
wangjia 25acf9db6e feat(eval): ASR 模型评估框架
横向对比云端 gummy 与本地开源模型(faster-whisper/SenseVoice/Paraformer),
重点覆盖中英混说,产出准确率(CER/WER/MER)/速度(延迟/RTF)/资源(cpu/mem/模型大小)
对比报告。公共集(ASCEND/AISHELL/LibriSpeech)统一走 HF 适配器 + 自定义 JSONL manifest。
gummy 引擎对照 server/internal/asr/gummy.go 协议移植。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:25:04 +08:00

235 lines
9.5 KiB
Python

"""汇总每条样本结果 → 语料级指标 + 延迟分位 + 资源,产出 JSON / Markdown / HTML。
错误率在语料级聚合(累加 S/D/I/N 再求率)。延迟取 p50/p95。资源取均值。
"""
from __future__ import annotations
import json
import os
from datetime import datetime
import numpy as np
from .metrics.err import Counts
from .metrics.resource import hardware_info
def _pct(values: list[float], p: float) -> float | None:
vals = [v for v in values if v is not None]
if not vals:
return None
return float(np.percentile(vals, p))
def _mean(values: list[float]) -> float | None:
vals = [v for v in values if v is not None]
if not vals:
return None
return float(np.mean(vals))
def _group_stats(rows: list[dict], cost_per_min: float | None) -> dict:
acc = {"cer": Counts(), "wer": Counts(), "mer": Counts()}
for r in rows:
for k in acc:
acc[k] = acc[k] + r["counts"][k]
audio_total = sum(r["audio_sec"] for r in rows)
return {
"samples": len(rows),
"errors": sum(1 for r in rows if r["error"]),
"cer": acc["cer"].rate,
"wer": acc["wer"].rate,
"mer": acc["mer"].rate,
"rtf_mean": _mean([r["rtf"] for r in rows]),
"first_partial_p50": _pct([r["first_partial_sec"] for r in rows], 50),
"first_partial_p95": _pct([r["first_partial_sec"] for r in rows], 95),
"finalize_p50": _pct([r["finalize_sec"] for r in rows], 50),
"finalize_p95": _pct([r["finalize_sec"] for r in rows], 95),
"peak_rss_mb_mean": _mean([r["peak_rss_mb"] for r in rows]),
"avg_cpu_mean": _mean([r["avg_cpu"] for r in rows]),
"audio_sec_total": audio_total,
"cost_total": (audio_total / 60.0 * cost_per_min) if cost_per_min else None,
}
def aggregate(rows: list[dict], engines_meta: dict) -> dict:
report = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"hardware": hardware_info(),
"engines": {},
}
by_engine: dict[str, list[dict]] = {}
for r in rows:
by_engine.setdefault(r["engine"], []).append(r)
for eng, erows in by_engine.items():
meta = engines_meta.get(eng, {})
cost = meta.get("cost_per_min")
cats: dict[str, list[dict]] = {}
dsets: dict[str, list[dict]] = {}
for r in erows:
cats.setdefault(r["category"], []).append(r)
dsets.setdefault(r["dataset"], []).append(r)
report["engines"][eng] = {
"is_local": meta.get("is_local"),
"kind": meta.get("kind"),
"model_size_mb": meta.get("model_size_mb"),
"cost_per_min": cost,
"overall": _group_stats(erows, cost),
"by_category": {c: _group_stats(rs, cost) for c, rs in sorted(cats.items())},
"by_dataset": {d: _group_stats(rs, cost) for d, rs in sorted(dsets.items())},
}
return report
# ---------- 渲染 ----------
def _pct_str(v: float | None) -> str:
return f"{v * 100:.2f}%" if v is not None else ""
def _num(v: float | None, fmt: str = "{:.2f}") -> str:
return fmt.format(v) if v is not None else ""
def render_markdown(report: dict) -> str:
hw = report["hardware"]
lines = [
"# ASR 模型评估报告",
"",
f"- 生成时间:{report['generated_at']}",
f"- 硬件:{hw['platform']} · {hw['machine']} · {hw['cpu_count']} vCPU · {hw['total_mem_gb']} GB",
"",
"## 总览(各引擎全量)",
"",
"| 引擎 | 本地 | 样本 | 错误 | MER | CER | WER | RTF | 首包 p50 | 定稿 p50 | 峰值内存MB | 模型大小MB | 成本 |",
"|---|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|",
]
for eng, e in report["engines"].items():
o = e["overall"]
lines.append(
"| {eng} | {loc} | {n} | {err} | {mer} | {cer} | {wer} | {rtf} | {fp} | {fin} | {mem} | {sz} | {cost} |".format(
eng=eng,
loc="" if e["is_local"] else "",
n=o["samples"],
err=o["errors"],
mer=_pct_str(o["mer"]),
cer=_pct_str(o["cer"]),
wer=_pct_str(o["wer"]),
rtf=_num(o["rtf_mean"]),
fp=_num(o["first_partial_p50"], "{:.2f}s") if o["first_partial_p50"] is not None else "",
fin=_num(o["finalize_p50"], "{:.2f}s") if o["finalize_p50"] is not None else "",
mem=_num(o["peak_rss_mb_mean"], "{:.0f}"),
sz=_num(e["model_size_mb"], "{:.0f}"),
cost=_num(o["cost_total"], "¥{:.4f}") if o["cost_total"] is not None else "",
)
)
# 中英混说专项
lines += ["", "## 中英混说(code-switch)专项 MER", "", "| 引擎 | 样本 | MER | CER | WER |", "|---|--:|--:|--:|--:|"]
for eng, e in report["engines"].items():
cs = e["by_category"].get("code-switch")
if not cs:
continue
lines.append(
f"| {eng} | {cs['samples']} | {_pct_str(cs['mer'])} | {_pct_str(cs['cer'])} | {_pct_str(cs['wer'])} |"
)
# 按数据集
lines += ["", "## 按数据集 MER", "", "| 引擎 | 数据集 | 样本 | MER | CER | WER |", "|---|---|--:|--:|--:|--:|"]
for eng, e in report["engines"].items():
for ds, g in e["by_dataset"].items():
lines.append(
f"| {eng} | {ds} | {g['samples']} | {_pct_str(g['mer'])} | {_pct_str(g['cer'])} | {_pct_str(g['wer'])} |"
)
lines.append("")
lines.append(
"> 注:流式(gummy)与离线(本地)延迟口径不同——gummy 的 RTF/延迟含 2x 推流与网络,"
"非纯算力;本地引擎为整段离线处理。资源(内存/CPU)仅本地引擎测量,含 Python 进程基底。"
)
return "\n".join(lines)
_HTML_TMPL = """<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>ASR 评估报告</title>
<style>
body{{font-family:-apple-system,"PingFang SC",sans-serif;max-width:1100px;margin:32px auto;padding:0 20px;color:#1B1E26;background:#F6F7FA}}
h1{{font-size:24px}} h2{{font-size:18px;margin-top:32px;border-left:3px solid #4F6EF7;padding-left:10px}}
table{{border-collapse:collapse;width:100%;background:#fff;border:1px solid #E4E6EB;border-radius:10px;overflow:hidden;margin:12px 0;font-size:13px}}
th,td{{padding:8px 10px;border-bottom:1px solid #F0F1F4;text-align:right}}
th{{background:#FCFCFD;color:#5A6072;font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.04em}}
td:first-child,th:first-child{{text-align:left;font-weight:600}}
.meta{{color:#5A6072;font-size:13px}} .note{{color:#7A8090;font-size:12px;margin-top:8px}}
tr:hover td{{background:#F6F7FA}}
</style></head><body>
<h1>ASR 模型评估报告</h1>
<p class="meta">生成时间 {generated_at} · {hw}</p>
{body}
<p class="note">流式(gummy)与离线(本地)延迟口径不同;资源仅本地引擎测量,含 Python 进程基底。</p>
</body></html>"""
def render_html(report: dict) -> str:
hw = report["hardware"]
def table(headers, rows):
h = "".join(f"<th>{x}</th>" for x in headers)
body = ""
for row in rows:
body += "<tr>" + "".join(f"<td>{x}</td>" for x in row) + "</tr>"
return f"<table><thead><tr>{h}</tr></thead><tbody>{body}</tbody></table>"
parts = ["<h2>总览(各引擎全量)</h2>"]
rows = []
for eng, e in report["engines"].items():
o = e["overall"]
rows.append([
eng, "" if e["is_local"] else "", o["samples"], o["errors"],
_pct_str(o["mer"]), _pct_str(o["cer"]), _pct_str(o["wer"]),
_num(o["rtf_mean"]),
_num(o["first_partial_p50"], "{:.2f}s") if o["first_partial_p50"] is not None else "",
_num(o["peak_rss_mb_mean"], "{:.0f}"), _num(e["model_size_mb"], "{:.0f}"),
_num(o["cost_total"], "¥{:.4f}") if o["cost_total"] is not None else "",
])
parts.append(table(
["引擎", "本地", "样本", "错误", "MER", "CER", "WER", "RTF", "首包p50", "峰值内存MB", "模型MB", "成本"], rows
))
parts.append("<h2>中英混说专项 MER</h2>")
cs_rows = []
for eng, e in report["engines"].items():
cs = e["by_category"].get("code-switch")
if cs:
cs_rows.append([eng, cs["samples"], _pct_str(cs["mer"]), _pct_str(cs["cer"]), _pct_str(cs["wer"])])
parts.append(table(["引擎", "样本", "MER", "CER", "WER"], cs_rows))
parts.append("<h2>按数据集</h2>")
ds_rows = []
for eng, e in report["engines"].items():
for ds, g in e["by_dataset"].items():
ds_rows.append([eng, ds, g["samples"], _pct_str(g["mer"]), _pct_str(g["cer"]), _pct_str(g["wer"])])
parts.append(table(["引擎", "数据集", "样本", "MER", "CER", "WER"], ds_rows))
return _HTML_TMPL.format(
generated_at=report["generated_at"],
hw=f"{hw['platform']} · {hw['cpu_count']} vCPU · {hw['total_mem_gb']}GB",
body="\n".join(parts),
)
def write_reports(report: dict, out_dir: str) -> dict:
os.makedirs(out_dir, exist_ok=True)
paths = {
"json": os.path.join(out_dir, "report.json"),
"md": os.path.join(out_dir, "report.md"),
"html": os.path.join(out_dir, "report.html"),
}
with open(paths["json"], "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(paths["md"], "w", encoding="utf-8") as f:
f.write(render_markdown(report))
with open(paths["html"], "w", encoding="utf-8") as f:
f.write(render_html(report))
return paths