为评估"更便宜的 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)。
106 lines
4.4 KiB
Python
106 lines
4.4 KiB
Python
"""从最终中文字幕反查各类"片段窗口",供人工挑选评测用例。
|
||
|
||
背景与目的
|
||
----------
|
||
评测翻译模型需要"原文 + 该原文对应的真实含义"的成对基准。本仓库同一部视频
|
||
(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()
|