Files
wangjia 50b49f3cbe
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 12s
fix(eval): 单条样本失败不再拖垮整轮评估
- cli:逐样本 transcribe 包 try/except,失败记为该样本 error 并继续;
  失败样本 counts 置空,不计入准确率,仅在 errors 列计数
- custom 数据集:manifest 引用的音频不存在时 warn 跳过,不再让 soundfile
  抛错崩掉整轮(修复默认 config 的 custom 示例集指向不存在音频导致的崩溃)

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

205 lines
7.4 KiB
Python

"""命令行入口:python -m asr_eval run --config config.yaml
流程:载 config → 物料化样本 → 逐引擎逐样本识别(本地引擎包资源探针) → 算分 → 出报告。
gummy 的 API key:优先 env DASHSCOPE_API_KEY,否则读 stdin 首行
(遵全局规则:rbw get dashscope-api-key | python -m asr_eval run ...)。
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime
import yaml
from rich.console import Console
from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
from .datasets import build_dataset
from .engines import Transcript, build_engine
from .metrics.err import empty_counts, score_sample
from .metrics.resource import ResourceProbe
from .report import aggregate, render_markdown, write_reports
console = Console()
def _resolve_api_key() -> str:
key = os.environ.get("DASHSCOPE_API_KEY", "").strip()
if key:
return key
if not sys.stdin.isatty():
line = sys.stdin.readline().strip()
if line:
return line
return ""
def _load_samples(cfg: dict, only: set[str] | None, limit: int | None) -> list:
audio_cache = cfg.get("audio_cache", "datasets/_audio_cache")
samples = []
for dcfg in cfg.get("datasets", []):
if only and dcfg["name"] not in only:
continue
ds = build_dataset(dcfg, audio_cache)
console.print(f"[cyan]载入数据集[/] {dcfg['name']} (type={dcfg['type']}) ...")
try:
got = list(ds.samples(limit=limit))
except Exception as ex:
console.print(f"[red]数据集 {dcfg['name']} 载入失败,跳过:{ex}[/]")
continue
console.print(f"{len(got)}")
samples.extend(got)
return samples
def _enabled_engines(cfg: dict, only: set[str] | None, api_key: str) -> list[dict]:
out = []
for ecfg in cfg.get("engines", []):
if not ecfg.get("enabled", True):
continue
if only and ecfg["name"] not in only:
continue
if ecfg["type"] == "gummy":
ecfg = {**ecfg, "api_key": api_key}
out.append(ecfg)
return out
def cmd_run(args: argparse.Namespace) -> int:
with open(args.config, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
limit = args.sample_limit if args.sample_limit is not None else cfg.get("sample_limit")
only_engines = set(args.engines.split(",")) if args.engines else None
only_datasets = set(args.datasets.split(",")) if args.datasets else None
engine_cfgs = _enabled_engines(cfg, only_engines, "")
need_key = any(e["type"] == "gummy" for e in engine_cfgs)
api_key = _resolve_api_key() if need_key else ""
if need_key and not api_key:
console.print("[red]gummy 已启用但未取到 API key(设 DASHSCOPE_API_KEY 或 stdin 传入)[/]")
return 2
engine_cfgs = _enabled_engines(cfg, only_engines, api_key)
samples = _load_samples(cfg, only_datasets, limit)
if not samples:
console.print("[red]没有可评估的样本,检查 datasets 配置或 manifest[/]")
return 1
rows: list[dict] = []
engines_meta: dict[str, dict] = {}
for ecfg in engine_cfgs:
name = ecfg["name"]
console.print(f"\n[bold magenta]引擎 {name}[/] (type={ecfg['type']}) 加载中 ...")
engine = build_engine(ecfg)
try:
engine.load()
except Exception as ex:
console.print(f"[red]引擎 {name} 加载失败,跳过:{ex}[/]")
continue
engines_meta[name] = {
"is_local": engine.is_local,
"kind": engine.kind,
"model_size_mb": engine.model_size_mb(),
"cost_per_min": engine.cost_per_min(),
}
with Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("{task.completed}/{task.total}"),
TimeElapsedColumn(),
console=console,
) as prog:
task = prog.add_task(f"{name}", total=len(samples))
for s in samples:
try:
tr = _transcribe_one(engine, s.audio_path)
except Exception as ex: # 单条样本失败不拖垮整轮评估
tr = Transcript(text="", audio_sec=0.0, proc_sec=0.0, error=str(ex))
# 失败样本不计入准确率(counts 置空),仅在 errors 列计数
sc = empty_counts() if tr.error else score_sample(s.ref_text, tr.text)
rows.append({
"engine": name,
"dataset": s.dataset,
"category": s.category,
"lang": s.lang,
"domain": s.domain,
"sample_id": s.id,
"ref": s.ref_text,
"hyp": tr.text,
"counts": sc,
"audio_sec": tr.audio_sec,
"proc_sec": tr.proc_sec,
"rtf": tr.rtf,
"first_partial_sec": tr.first_partial_sec,
"finalize_sec": tr.finalize_sec,
"peak_rss_mb": tr.peak_rss_mb,
"avg_cpu": tr.avg_cpu,
"error": tr.error,
})
prog.advance(task)
engine.unload()
if not rows:
console.print("[red]无结果(所有引擎都失败了?)[/]")
return 1
report = aggregate(rows, engines_meta)
out_dir = os.path.join(cfg.get("output_dir", "results"), datetime.now().strftime("%Y%m%d-%H%M%S"))
paths = write_reports(report, out_dir)
_dump_raw(rows, out_dir)
console.print("\n" + render_markdown(report))
console.print(f"\n[green]报告已写入[/] {out_dir}/ (report.json / report.md / report.html)")
return 0
def _transcribe_one(engine, audio_path: str):
"""本地引擎包资源探针;云端引擎直接调用。"""
if engine.is_local:
with ResourceProbe() as probe:
tr = engine.transcribe(audio_path)
if tr.peak_rss_mb is None:
tr.peak_rss_mb = probe.peak_rss_mb
if tr.avg_cpu is None:
tr.avg_cpu = probe.avg_cpu
return tr
return engine.transcribe(audio_path)
def _dump_raw(rows: list[dict], out_dir: str) -> None:
import json
path = os.path.join(out_dir, "results.jsonl")
with open(path, "w", encoding="utf-8") as f:
for r in rows:
rec = {k: v for k, v in r.items() if k != "counts"}
rec["cer"] = r["counts"]["cer"].rate
rec["wer"] = r["counts"]["wer"].rate
rec["mer"] = r["counts"]["mer"].rate
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
def main() -> None:
ap = argparse.ArgumentParser(prog="asr_eval", description="dudu ASR 模型评估框架")
sub = ap.add_subparsers(dest="cmd", required=True)
run = sub.add_parser("run", help="跑评估")
run.add_argument("--config", default="config.yaml")
run.add_argument("--engines", help="只跑这些引擎(逗号分隔,覆盖 enabled)")
run.add_argument("--datasets", help="只跑这些数据集(逗号分隔)")
run.add_argument("--sample-limit", type=int, default=None, help="每个数据集采样上限(覆盖 config)")
run.set_defaults(func=cmd_run)
args = ap.parse_args()
sys.exit(args.func(args))
if __name__ == "__main__":
main()