feat: 新增翻译模型横向评测工具链与窗口级评测集
为评估"更便宜的 LLM 能否替换 llm-translate 默认模型"新增真实数据驱动的 评测工具(不 mock 模型,直接调用生产节点实现,模型仅作为 params 变量): - scripts/build_translate_eval.py:把 ocr-subtitle 的中文烧录字幕产物与 learn-translate 的日语 whisper ASR 按时间配对,产出候选池; - scripts/build_segment_eval.py:改为**窗口级**配对(一条中文基准字幕 + 其时间窗内 1-3 条日语 cue)。逐条配对不可用——烧录字幕是按屏幕合并的 整行,与 whisper 的 cue 切分不同,直接逐条对照会被基准错位污染; - scripts/bench_translate_models.py:对每个模型跑完整片、记录单次调用耗时 与 token、输出窗口级多模型对照表供人工 review;VL 模型需剔除 enable_thinking(Qwen3-VL 不接受该参数,生产代码固定携带); - scripts/run_translate_bench_queue.py:批量评测队列; - tests/test_translate_model_bench.py:评测数据契约与对照输出一致性测试 (含先红后绿修复:stdout 与 markdown 两套输出格式漂移); - testdata/translate_eval/:候选池、窗口池与 124 窗口人工评测集资产。 评测结论见 data/experiments/translate_models/REPORT.md(gitignored)。
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""翻译模型横向评测执行器(真实节点代码,模型是唯一变量)。
|
||||
|
||||
设计原则(对齐 AGENTS.md 与本仓库测试约定)
|
||||
------------------------------------------------
|
||||
1. **不改生产逻辑**:评测直接调用 `nodes/llm.py` 的真实实现
|
||||
(`_system_prompt` / `translate_lines` / `invoke`:提示词、专名规则注入、
|
||||
ID 严格校验、重试、幻觉清洗、SRT 写出全部走生产路径);被评测的模型只通过
|
||||
`params["model"]` 传入。
|
||||
2. **只在 I/O 边界做手脚**(AGENTS 允许的最小 mock 面):包装
|
||||
`urllib.request.urlopen`,用于
|
||||
a) 记录每次请求的耗时与 token 用量(生产日志只有批级耗时,这里要精确到
|
||||
单次调用,用于"每行摊薄秒数"判定);
|
||||
b) `strip_thinking=True` 时把请求体里的 `enable_thinking` 键删掉——
|
||||
Qwen3-VL 系列不接受该参数,生产代码固定携带(实测返回 400
|
||||
code 20015)。这是**评测侧兼容**,生产代码保持原样,报告里单独标注。
|
||||
3. **不并发**:llm-translate 生产实现是逐批串行的,评测同样串行(用户已确认
|
||||
本次不评估并发能力)。
|
||||
|
||||
用法
|
||||
----
|
||||
# 用某个模型跑完整 SRT(产出 cn.srt + calls.jsonl + summary.json)
|
||||
uv run python scripts/bench_translate_models.py run \
|
||||
--model Qwen/Qwen3-14B --tag cloud-qwen3-14b
|
||||
|
||||
# 本地 ollama(OpenAI 兼容端点)
|
||||
uv run python scripts/bench_translate_models.py run \
|
||||
--model qwen3:14b --tag local-qwen3-14b \
|
||||
--api-base http://localhost:11434/v1/chat/completions --api-key ""
|
||||
|
||||
# 评测窗口对照(打印各模型在同一窗口的译文,供人工 review)
|
||||
uv run python scripts/bench_translate_models.py compare --tags cloud-qwen3-14b local-qwen3-14b
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
# 所有评测产物落在 data/experiments(gitignored),不污染 testdata。
|
||||
OUT_ROOT = PROJECT_ROOT / "data/experiments/translate_models"
|
||||
|
||||
# 评测输入:run_d386ccf124f7 的日语 ASR(与 run_479b411f299d 同音轨,已核对
|
||||
# 前 5 分钟 16k 单声道 PCM md5 一致),1161 条 cue。
|
||||
DEFAULT_INPUT = PROJECT_ROOT / "data/storage/runs/run_d386ccf124f7/steps/asr/transcript.srt"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _instrument(model: str, log_path: Path, strip_thinking: bool):
|
||||
"""包装 urlopen:记录每次 LLM 请求耗时/token,并可按需剔除 enable_thinking。
|
||||
|
||||
只替换 `urllib.request.urlopen` 这一个 I/O 入口,返回对象与异常语义保持
|
||||
不变;非 LLM 请求(如 ollama 其它端点)原样透传。
|
||||
"""
|
||||
original = urllib.request.urlopen
|
||||
calls: list[dict] = []
|
||||
fh = log_path.open("w", encoding="utf-8")
|
||||
|
||||
def patched(request, *args, **kwargs):
|
||||
body = None
|
||||
if hasattr(request, "data") and request.data:
|
||||
try:
|
||||
body = json.loads(request.data.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
# 评测侧兼容:Qwen3-VL 不接受 enable_thinking(生产代码固定携带)。
|
||||
had_thinking = isinstance(body, dict) and "enable_thinking" in body
|
||||
if strip_thinking and had_thinking:
|
||||
body.pop("enable_thinking")
|
||||
request.data = json.dumps(body).encode("utf-8")
|
||||
started = time.monotonic()
|
||||
record: dict = {"model": model, "had_enable_thinking": had_thinking}
|
||||
try:
|
||||
response = original(request, *args, **kwargs)
|
||||
payload = response.read()
|
||||
elapsed = time.monotonic() - started
|
||||
record["elapsed_s"] = round(elapsed, 3)
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
usage = parsed.get("usage") or {}
|
||||
record["prompt_tokens"] = usage.get("prompt_tokens")
|
||||
record["completion_tokens"] = usage.get("completion_tokens")
|
||||
record["total_tokens"] = usage.get("total_tokens")
|
||||
record["finish_reason"] = (parsed.get("choices") or [{}])[0].get("finish_reason")
|
||||
record["ok"] = True
|
||||
except ValueError:
|
||||
record["ok"] = True
|
||||
calls.append(record)
|
||||
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
fh.flush()
|
||||
|
||||
class _Replay:
|
||||
"""把已读出的响应体重新包装成文件式对象,交给上层原样消费。"""
|
||||
|
||||
def __init__(self, inner, data: bytes):
|
||||
self._inner = inner
|
||||
self._data = data
|
||||
|
||||
def read(self, *a):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
# 生产代码用 with urlopen(...) as response,包装对象必须
|
||||
# 支持上下文管理器协议,否则断链。
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return self._inner.__exit__(*a)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
return _Replay(response, payload)
|
||||
except Exception as exc: # noqa: BLE001 - 记录后原样抛出,行为不变
|
||||
record["elapsed_s"] = round(time.monotonic() - started, 3)
|
||||
record["ok"] = False
|
||||
record["error"] = f"{type(exc).__name__}: {exc}"
|
||||
calls.append(record)
|
||||
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
fh.flush()
|
||||
raise
|
||||
|
||||
urllib.request.urlopen = patched
|
||||
try:
|
||||
yield calls
|
||||
finally:
|
||||
urllib.request.urlopen = original
|
||||
fh.close()
|
||||
|
||||
|
||||
def run_model(args: argparse.Namespace) -> None:
|
||||
"""用指定模型跑完整翻译,产出 cn.srt / calls.jsonl / summary.json。"""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from wov_sdk.models import InvokeRequest
|
||||
from nodes import llm
|
||||
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
if args.api_base:
|
||||
os.environ["LLM_API_BASE"] = args.api_base
|
||||
# 本地 ollama 不需要密钥;显式传空串时清掉环境里的云端 key,避免误带。
|
||||
if args.api_key is not None:
|
||||
os.environ["LLM_API_KEY"] = args.api_key
|
||||
if args.timeout:
|
||||
os.environ["LLM_TIMEOUT_SECONDS"] = str(args.timeout)
|
||||
|
||||
out_dir = OUT_ROOT / args.tag
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
input_path = Path(args.input)
|
||||
# --limit 仅用于冒烟验证(会截断 SRT 到前 N 条 cue)。
|
||||
if args.limit:
|
||||
from nodes.srt import parse_srt, serialize_srt
|
||||
|
||||
cues = parse_srt(input_path.read_text(encoding="utf-8"))[: args.limit]
|
||||
input_path = out_dir / f"input_first{args.limit}.srt"
|
||||
input_path.write_text(serialize_srt(cues), encoding="utf-8")
|
||||
|
||||
if args.warmup:
|
||||
# 预热:本地 ollama 首次请求包含模型加载(实测 qwen3:14b 约 10s),
|
||||
# 会把"每行摊薄秒数"算高。预热会丢弃结果,只让模型常驻显存。
|
||||
from nodes.srt import parse_srt, serialize_srt
|
||||
|
||||
warm_input = out_dir / "warmup.srt"
|
||||
warm_input.write_text(
|
||||
serialize_srt(parse_srt(input_path.read_text(encoding="utf-8"))[:2]), encoding="utf-8"
|
||||
)
|
||||
warm_started = time.monotonic()
|
||||
llm.invoke(
|
||||
InvokeRequest(
|
||||
run_id=f"warmup_{args.tag}",
|
||||
node_instance_id="translate",
|
||||
inputs={"srt_uri": str(warm_input)},
|
||||
params={"model": args.model, "target_language": "zh-CN"},
|
||||
output_dir=str(out_dir / "warmup_steps"),
|
||||
)
|
||||
)
|
||||
print(f"[warmup] {time.monotonic() - warm_started:.1f}s")
|
||||
|
||||
print(f"[run] model={args.model} tag={args.tag} input={input_path.name} "
|
||||
f"strip_thinking={args.strip_thinking} base={os.environ.get('LLM_API_BASE')}")
|
||||
started = time.monotonic()
|
||||
with _instrument(args.model, out_dir / "calls.jsonl", args.strip_thinking) as calls:
|
||||
response = llm.invoke(
|
||||
InvokeRequest(
|
||||
run_id=f"bench_{args.tag}",
|
||||
node_instance_id="translate",
|
||||
inputs={"srt_uri": str(input_path)},
|
||||
params={"model": args.model, "target_language": "zh-CN"},
|
||||
output_dir=str(out_dir / "steps"),
|
||||
)
|
||||
)
|
||||
wall = time.monotonic() - started
|
||||
|
||||
summary = {
|
||||
"model": args.model,
|
||||
"tag": args.tag,
|
||||
"api_base": os.environ.get("LLM_API_BASE"),
|
||||
"strip_thinking": args.strip_thinking,
|
||||
"input": str(input_path),
|
||||
"status": response.status,
|
||||
"error": response.error,
|
||||
"wall_s": round(wall, 2),
|
||||
"calls": len(calls),
|
||||
"failed_calls": sum(1 for c in calls if not c.get("ok")),
|
||||
"total_tokens": sum(int(c.get("total_tokens") or 0) for c in calls),
|
||||
}
|
||||
if response.status == "completed":
|
||||
srt_path = Path(response.outputs["cn_srt_uri"])
|
||||
cues = sum(1 for line in srt_path.read_text(encoding="utf-8").splitlines() if "-->" in line)
|
||||
summary["cn_srt"] = str(srt_path)
|
||||
summary["cues_out"] = cues
|
||||
summary["per_cue_s"] = round(wall / cues, 3) if cues else None
|
||||
summary["cue_per_s"] = round(cues / wall, 2) if wall else None
|
||||
(out_dir / "summary.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
def _window_rows(tags: list[str], limit: int, only: str | None, emit: str | None = None,
|
||||
eval_set: str | None = None) -> None:
|
||||
"""打印/写出评测窗口的多模型译文对照表(人工 review 用)。
|
||||
|
||||
默认使用人工确认的评测集 testdata/translate_eval/eval_set.jsonl(124 个窗口,
|
||||
按时间分层抽样);未提供时回退到候选池 windows.jsonl。
|
||||
|
||||
stdout 与 markdown 文件**共用同一批文本行**(单一出口):此前两套格式
|
||||
各自拼接,导致 review 材料与实际打印内容漂移(回归见
|
||||
tests/test_translate_model_bench.py 的对照表一致性用例)。
|
||||
"""
|
||||
from nodes.srt import parse_srt
|
||||
|
||||
source = Path(eval_set) if eval_set else PROJECT_ROOT / "testdata/translate_eval/eval_set.jsonl"
|
||||
if not source.is_file():
|
||||
source = PROJECT_ROOT / "testdata/translate_eval/windows.jsonl"
|
||||
windows = [json.loads(line) for line in source.read_text(encoding="utf-8").splitlines()]
|
||||
ja_cues = parse_srt(DEFAULT_INPUT.read_text(encoding="utf-8"))
|
||||
# 每个 tag 读 cn.srt,按"时间戳 -> 译文"建索引(翻译不改变时间戳;被幻觉
|
||||
# 清洗删掉的 cue 缺失属预期,用 None 表示)。
|
||||
outputs: dict[str, dict[str, str]] = {}
|
||||
for tag in tags:
|
||||
path = OUT_ROOT / tag / "steps" / "cn.srt"
|
||||
if not path.is_file():
|
||||
print(f"!! 缺少 {path}")
|
||||
continue
|
||||
outputs[tag] = {
|
||||
f"{c.start}-->{c.end}": c.text for c in parse_srt(path.read_text(encoding="utf-8"))
|
||||
}
|
||||
selected = windows if only is None else [w for w in windows if only in w["zh_start"]]
|
||||
lines_out: list[str] = []
|
||||
for window in selected[: limit or None]:
|
||||
ja = window["ja_lines"]
|
||||
keys = [f"{ja_cues[i - 1].start}-->{ja_cues[i - 1].end}" for i in window["ja_ids"]]
|
||||
header = f"### {window.get('id', '?')}. {window['zh_start']} [{len(ja)}条]"
|
||||
lines_out.append(header)
|
||||
lines_out.append("- JA: " + " | ".join(ja))
|
||||
lines_out.append(f"- 基准中文(OCR): {window['zh_ref']}")
|
||||
for tag, index in outputs.items():
|
||||
texts = [index.get(k) or "<已删除>" for k in keys]
|
||||
lines_out.append(f"- {tag}: " + " | ".join(texts))
|
||||
lines_out.append("")
|
||||
text = "\n".join(lines_out)
|
||||
print(text)
|
||||
if emit:
|
||||
Path(emit).write_text(text, encoding="utf-8")
|
||||
print(f"\n对照表已写入 {emit}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="翻译模型横向评测")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
run = sub.add_parser("run", help="用某模型跑完整翻译")
|
||||
run.add_argument("--model", required=True)
|
||||
run.add_argument("--tag", required=True, help="产物目录名(data/experiments/translate_models/<tag>)")
|
||||
run.add_argument("--input", default=str(DEFAULT_INPUT))
|
||||
run.add_argument("--api-base", default=None, help="覆盖 LLM_API_BASE(本地 ollama 用)")
|
||||
run.add_argument("--api-key", default=None, help="覆盖 LLM_API_KEY(本地传空串)")
|
||||
run.add_argument("--timeout", type=float, default=None, help="LLM_TIMEOUT_SECONDS")
|
||||
run.add_argument("--strip-thinking", action="store_true", help="请求体剔除 enable_thinking(VL 模型)")
|
||||
run.add_argument("--warmup", action="store_true", help="先跑 2 条预热(本地模型加载耗时不计入)")
|
||||
run.add_argument("--limit", type=int, default=0, help="只跑前 N 条 cue(冒烟)")
|
||||
run.set_defaults(func=run_model)
|
||||
|
||||
cmp_parser = sub.add_parser("compare", help="打印窗口级多模型译文对照")
|
||||
cmp_parser.add_argument("--tags", nargs="+", required=True)
|
||||
cmp_parser.add_argument("--limit", type=int, default=0)
|
||||
cmp_parser.add_argument("--only", default=None, help="只看时间戳包含该字符串的窗口")
|
||||
cmp_parser.add_argument("--emit", default=None, help="把对照表写入 markdown 文件")
|
||||
cmp_parser.add_argument("--eval-set", default=None, help="改用指定评测集 jsonl")
|
||||
cmp_parser.set_defaults(func=lambda a: _window_rows(a.tags, a.limit, a.only, a.emit, a.eval_set))
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""从最终中文字幕反查各类"片段窗口",供人工挑选评测用例。
|
||||
|
||||
背景与目的
|
||||
----------
|
||||
评测翻译模型需要"原文 + 该原文对应的真实含义"的成对基准。本仓库同一部视频
|
||||
(CJOD-255,2 小时)有两条真实产物:
|
||||
|
||||
- 日语原文:run_d386ccf124f7(learn-translate)的 whisper ASR,
|
||||
1161 条 cue(约每 1-4 句一条);
|
||||
- 中文基准:run_479b411f299d(ocr-subtitle)过滤后的烧录字幕 1144 条
|
||||
——烧录字幕是画面的一部分,也可视为官方/人工字幕,但它**是一条条按屏幕
|
||||
显示合并的整行**(约 2-8 秒一条),与 whisper 的 cue 切分不同。
|
||||
|
||||
因此不能逐条对照。正确口径是**窗口级**:取中文基准一条字幕的时间窗,收集该窗
|
||||
内覆盖的日语 cue(1-3 条),窗口即为一个评测用例——比较"模型对这几条 cue 的
|
||||
译文合起来"是否表达了该中文行。本脚本把这类窗口导出,供人工确认与挑选。
|
||||
|
||||
用法:
|
||||
uv run python scripts/build_segment_eval.py --dump-window 1150 # 看某处的窗口细节
|
||||
uv run python scripts/build_segment_eval.py --export testdata/translate_eval/windows.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 两条真实产物(相对仓库根)。
|
||||
DEFAULT_JA = PROJECT_ROOT / "data/storage/runs/run_d386ccf124f7/steps/asr/transcript.srt"
|
||||
DEFAULT_ZH = PROJECT_ROOT / "data/storage/runs/run_479b411f299d/steps/filter/filtered.srt"
|
||||
|
||||
|
||||
def seconds(ts: str) -> float:
|
||||
"""SRT 时间戳 -> 秒。"""
|
||||
hours, minutes, rest = ts.split(":")
|
||||
secs, millis = rest.split(",")
|
||||
return int(hours) * 3600 + int(minutes) * 60 + int(secs) + int(millis) / 1000
|
||||
|
||||
|
||||
def build_windows(ja_path: Path, zh_path: Path, max_ja: int = 3, max_duration: float = 15.0) -> list[dict]:
|
||||
"""返回可用评测窗口列表。
|
||||
|
||||
窗口 = 一条中文基准字幕 + 其时间窗内的日语 cue 组(1..max_ja 条)。
|
||||
过滤条件:窗口时长 <= max_duration(避免整段合并的长窗口无法逐句核对)、
|
||||
cue 数在 1..max_ja 之间(0 条说明 OCR 行无对应日语,>max_ja 说明配对太碎)。
|
||||
"""
|
||||
from nodes.srt import parse_srt
|
||||
|
||||
ja_cues = parse_srt(ja_path.read_text(encoding="utf-8"))
|
||||
zh_cues = parse_srt(zh_path.read_text(encoding="utf-8"))
|
||||
|
||||
windows: list[dict] = []
|
||||
for zh in zh_cues:
|
||||
start, end = seconds(zh.start), seconds(zh.end)
|
||||
group = [c for c in ja_cues if seconds(c.start) < end and start < seconds(c.end)]
|
||||
if not 1 <= len(group) <= max_ja or (end - start) > max_duration:
|
||||
continue
|
||||
windows.append(
|
||||
{
|
||||
"zh_start": zh.start,
|
||||
"zh_end": zh.end,
|
||||
"zh_ref": zh.text,
|
||||
"ja_start": group[0].start,
|
||||
"ja_end": group[-1].end,
|
||||
"ja_lines": [c.text for c in group],
|
||||
"ja_ids": [ja_cues.index(c) + 1 for c in group],
|
||||
}
|
||||
)
|
||||
return windows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="导出窗口级翻译评测候选")
|
||||
parser.add_argument("--ja", type=Path, default=DEFAULT_JA)
|
||||
parser.add_argument("--zh", type=Path, default=DEFAULT_ZH)
|
||||
parser.add_argument("--export", type=Path, default=None, help="写出 windows.jsonl")
|
||||
parser.add_argument("--dump-window", type=float, default=None, help="打印指定秒附近的窗口")
|
||||
parser.add_argument("--limit", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
windows = build_windows(args.ja, args.zh)
|
||||
print(f"可用窗口 {len(windows)} 个(1-3 条日语 cue 且窗口 <=15s)")
|
||||
|
||||
if args.export:
|
||||
args.export.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.export.open("w", encoding="utf-8") as fh:
|
||||
for index, item in enumerate(windows, 1):
|
||||
fh.write(json.dumps({"id": index, **item}, ensure_ascii=False) + "\n")
|
||||
print(f"已写入 {args.export}")
|
||||
|
||||
if args.dump_window is not None:
|
||||
for item in windows:
|
||||
if abs(seconds(item["zh_start"]) - args.dump_window) <= 12:
|
||||
print(json.dumps(item, ensure_ascii=False, indent=1))
|
||||
|
||||
for item in (windows if args.limit <= 0 else windows[: args.limit]):
|
||||
joined = " | ".join(item["ja_lines"])
|
||||
print(f"{item['zh_start']} JA[{len(item['ja_lines'])}]: {joined!r} -> {item['zh_ref']!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""构建"翻译质量评测集"候选池(真实数据,不造样本)。
|
||||
|
||||
背景与目的
|
||||
----------
|
||||
评测"更便宜的 LLM 模型能否用于 llm-translate 节点"时,需要一份**可人工核对
|
||||
的基准**:日语原文 + 该句真实中文含义。本仓库正好有同一部视频的两条真实产物:
|
||||
|
||||
- 日语原文:run_d386ccf124f7(learn-translate)的 whisper ASR 产物
|
||||
`steps/asr/transcript.srt`(large-v2 + decode_full,1161 条);
|
||||
- 中文基准:run_479b411f299d(ocr-subtitle)的烧录字幕 OCR + LLM 过滤产物
|
||||
`steps/filter/filtered.srt`(1144 条)——烧录字幕是画面的一部分,其文本
|
||||
约等于官方/人工中文字幕,可作为"该句真实含义"的近似 ground truth。
|
||||
|
||||
两条产物来自**同一音轨**(已核对前 5 分钟 16k 单声道 PCM md5 一致),因此可按
|
||||
时间重叠配对。但配对并非一一对应(OCR 会把多句合并、漏识别呻吟段),所以本
|
||||
脚本只负责**生成候选池**,最终是否入选由人工逐条确认(脚本不保证正确性)。
|
||||
|
||||
用法(仓库根目录):
|
||||
|
||||
uv run python scripts/build_translate_eval.py # 打印候选池供人工挑选
|
||||
uv run python scripts/build_translate_eval.py --emit-pool testdata/translate_eval/candidates.jsonl
|
||||
|
||||
产物:candidates.jsonl,每行
|
||||
{"start","end","ja","zh_ref","type_hint","kana_len"}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 默认输入:两条真实产物路径(相对仓库根)。
|
||||
DEFAULT_JA = PROJECT_ROOT / "data/storage/runs/run_d386ccf124f7/steps/asr/transcript.srt"
|
||||
DEFAULT_REF = PROJECT_ROOT / "data/storage/runs/run_479b411f299d/steps/filter/filtered.srt"
|
||||
|
||||
# 类型提示用到的字符集合(仅用于给候选打标签,方便人工按类型均衡挑选)。
|
||||
_KANA = re.compile(r"[ぁ-んァ-ヴー]")
|
||||
_KANJI = re.compile(r"[\u4e00-\u9fff]")
|
||||
_LATIN = re.compile(r"[A-Za-z]")
|
||||
# 纯呻吟/喘息:整句只由呻吟字符与标点组成。
|
||||
_MOAN_ONLY = re.compile(r"^[あいうえおんはぁっアンー…、。!?!?\s]+$")
|
||||
|
||||
|
||||
def _seconds(ts: str) -> float:
|
||||
"""SRT 时间戳 HH:MM:SS,mmm -> 秒(浮点)。"""
|
||||
hours, minutes, rest = ts.split(":")
|
||||
seconds, millis = rest.split(",")
|
||||
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
|
||||
|
||||
|
||||
def _type_hint(text: str) -> str:
|
||||
"""给候选打类型提示(供人工按类型均衡取样,不代表语义正确性)。
|
||||
|
||||
- 呻吟:整句只有呻吟字符;
|
||||
- 短碎片:有效字符 ≤ 4(助词/应答词,最考验"结合上下文独立成行");
|
||||
- 拟声/外来语:含长音符或拉丁字母(オーラル/パンパン 等);
|
||||
- 长句:字符数 ≥ 15(考验长句切分与语序重组);
|
||||
- 对话:其余。
|
||||
"""
|
||||
stripped = re.sub(r"[\s、。!?!?…]", "", text)
|
||||
if _MOAN_ONLY.fullmatch(text):
|
||||
return "呻吟"
|
||||
if len(stripped) <= 4:
|
||||
return "短碎片"
|
||||
if _LATIN.search(text) or "ー" in text:
|
||||
return "拟声/外来语"
|
||||
if len(stripped) >= 15:
|
||||
return "长句"
|
||||
return "对话"
|
||||
|
||||
|
||||
def build_candidates(ja_path: Path, ref_path: Path) -> list[dict]:
|
||||
"""按"时间重叠且唯一"把日语 cue 与中文字幕基准配对,返回候选列表。
|
||||
|
||||
只保留**恰好与一条**参考字幕重叠的日语 cue——多条重叠说明参考字幕把多句
|
||||
合并(或 OCR 漏句),无法确定该句的真实含义,这类不进入候选池(人工也无法
|
||||
可靠核对)。重叠判定用半开区间(start < other.end and other.start < end)。
|
||||
"""
|
||||
from nodes.srt import parse_srt # 复用生产解析器,避免另写一套 SRT 规则
|
||||
|
||||
ja_cues = parse_srt(ja_path.read_text(encoding="utf-8"))
|
||||
ref_cues = parse_srt(ref_path.read_text(encoding="utf-8"))
|
||||
|
||||
candidates: list[dict] = []
|
||||
for cue in ja_cues:
|
||||
overlaps = [
|
||||
ref for ref in ref_cues
|
||||
if _seconds(ref.start) < _seconds(cue.end) and _seconds(cue.start) < _seconds(ref.end)
|
||||
]
|
||||
if len(overlaps) != 1:
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"start": cue.start,
|
||||
"end": cue.end,
|
||||
"ja": cue.text,
|
||||
"zh_ref": overlaps[0].text,
|
||||
"type_hint": _type_hint(cue.text),
|
||||
"kana_len": len(_KANA.findall(cue.text)),
|
||||
"has_kanji": bool(_KANJI.search(cue.text)),
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="生成翻译质量评测集候选池")
|
||||
parser.add_argument("--ja", type=Path, default=DEFAULT_JA, help="日语 ASR srt")
|
||||
parser.add_argument("--ref", type=Path, default=DEFAULT_REF, help="中文基准 srt")
|
||||
parser.add_argument("--emit-pool", type=Path, default=None, help="写出候选池 jsonl")
|
||||
parser.add_argument("--limit", type=int, default=0, help="只打印前 N 条(0=全部)")
|
||||
args = parser.parse_args()
|
||||
|
||||
candidates = build_candidates(args.ja, args.ref)
|
||||
print(f"候选池共 {len(candidates)} 条(时间唯一配对)")
|
||||
# 类型分布:用于人工按类型均衡挑选。
|
||||
dist: dict[str, int] = {}
|
||||
for item in candidates:
|
||||
dist[item["type_hint"]] = dist.get(item["type_hint"], 0) + 1
|
||||
print("类型分布:", dist)
|
||||
|
||||
if args.emit_pool:
|
||||
args.emit_pool.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.emit_pool.open("w", encoding="utf-8") as fh:
|
||||
for item in candidates:
|
||||
fh.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||||
print(f"候选池已写入 {args.emit_pool}")
|
||||
|
||||
shown = candidates if args.limit <= 0 else candidates[: args.limit]
|
||||
for index, item in enumerate(shown, 1):
|
||||
print(f"[{index}] {item['start']} ({item['type_hint']})\n JA: {item['ja']!r}\n ZH: {item['zh_ref']!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""按序跑完全部评测模型(云端优先,本地模型等下载完成后再跑)。
|
||||
|
||||
每个模型一条命令,串行执行(不并发,符合本次评测目标);stdout/耗时写入
|
||||
各 tag 目录下的 run.log。中断后重跑会跳过已有 summary.json 的 tag。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT_ROOT = PROJECT_ROOT / "data/experiments/translate_models"
|
||||
|
||||
CLOUD_BASE = "https://api.siliconflow.cn/v1/chat/completions"
|
||||
LOCAL_BASE = "http://localhost:11434/v1/chat/completions"
|
||||
|
||||
# (tag, model, 额外参数)。基线 Qwen3.6-35B-A3B 单列,不重跑(已有 run_d386 产物)。
|
||||
TASKS: list[tuple[str, str, list[str]]] = [
|
||||
("cloud-qwen3.5-35b-a3b", "Qwen/Qwen3.5-35B-A3B", []),
|
||||
("cloud-qwen3-vl-30b-a3b", "Qwen/Qwen3-VL-30B-A3B-Instruct", ["--strip-thinking"]),
|
||||
("cloud-qwen3-14b", "Qwen/Qwen3-14B", []),
|
||||
("cloud-qwen3-8b", "Qwen/Qwen3-8B", []),
|
||||
("cloud-qwen3.5-9b", "Qwen/Qwen3.5-9B", []),
|
||||
("local-qwen3-14b", "qwen3:14b", ["--api-base", LOCAL_BASE, "--api-key", "", "--timeout", "900", "--warmup"]),
|
||||
("local-qwen3-30b-a3b", "qwen3:30b-a3b", ["--api-base", LOCAL_BASE, "--api-key", "", "--timeout", "900", "--warmup"]),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
only = sys.argv[1:] or None
|
||||
for tag, model, extra in TASKS:
|
||||
if only and tag not in only:
|
||||
continue
|
||||
out_dir = OUT_ROOT / tag
|
||||
if (out_dir / "summary.json").is_file():
|
||||
print(f"[skip] {tag} 已有 summary.json", flush=True)
|
||||
continue
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
sys.executable, str(PROJECT_ROOT / "scripts/bench_translate_models.py"), "run",
|
||||
"--model", model, "--tag", tag, *extra,
|
||||
]
|
||||
print(f"[start] {tag} :: {' '.join(cmd[2:])}", flush=True)
|
||||
started = time.monotonic()
|
||||
with (out_dir / "run.log").open("w", encoding="utf-8") as log:
|
||||
proc = subprocess.run(cmd, cwd=PROJECT_ROOT, stdout=log, stderr=subprocess.STDOUT)
|
||||
elapsed = time.monotonic() - started
|
||||
print(f"[done] {tag} rc={proc.returncode} {elapsed/60:.1f}min", flush=True)
|
||||
summary = out_dir / "summary.json"
|
||||
if summary.is_file():
|
||||
data = json.loads(summary.read_text(encoding="utf-8"))
|
||||
print(" ", {k: data.get(k) for k in
|
||||
("status", "cues_out", "wall_s", "per_cue_s", "failed_calls", "error")}, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user