Files
vrsub/scripts/build_segment_eval.py
T
cat-shark 8963afa771 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)。
2026-09-13 10:15:23 +08:00

106 lines
4.4 KiB
Python
Raw 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.
"""从最终中文字幕反查各类"片段窗口",供人工挑选评测用例。
背景与目的
----------
评测翻译模型需要"原文 + 该原文对应的真实含义"的成对基准。本仓库同一部视频
(CJOD-255,2 小时)有两条真实产物:
- 日语原文:run_d386ccf124f7learn-translate)的 whisper ASR
1161 条 cue(约每 1-4 句一条);
- 中文基准:run_479b411f299docr-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()