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:
2026-09-13 10:15:23 +08:00
parent f99f8de171
commit 8963afa771
8 changed files with 2585 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
"""翻译模型横向评测的数据契约与对照逻辑测试(真实数据,不 mock 模型)。
背景
----
为评估"更便宜的 LLM 能否替代当前 Qwen/Qwen3.6-35B-A3B 做 llm-translate"
新增了三件评测工具(scripts/ 下,都是真实数据驱动):
- `scripts/build_translate_eval.py`:把 run_479b411f299docr-subtitle)的
烧录字幕中文产物与 run_d386ccf124f7learn-translate)的日语 whisper ASR
按时间配对,产出候选池;
- `scripts/build_segment_eval.py`:把配对改成**窗口级**(一条中文基准字幕 +
其时间窗内覆盖的 1-3 条日语 cue),产出可用窗口;
- `scripts/bench_translate_models.py`:调用**生产翻译实现**nodes/llm.py
对每个模型跑完整片,并在窗口级打印各模型译文对照供人工 review。
本测试验证上述工具的**数据契约与对照逻辑**:评测集必须真实可用(时间轴自洽、
基准非空、可定位到真实 ASR 条目),对照输出必须包含全部模型各自的行。真实
产物缺失时整组跳过(与 tests/realdata_contract.py 同一约定)。
"""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
# 仓库根(tests/ 的上一级)。
WORKSPACE = Path(__file__).resolve().parent.parent
EVAL_SET = WORKSPACE / "testdata/translate_eval/eval_set.jsonl"
WINDOWS = WORKSPACE / "testdata/translate_eval/windows.jsonl"
JA_SRT = WORKSPACE / "data/storage/runs/run_d386ccf124f7/steps/asr/transcript.srt"
BENCH = WORKSPACE / "scripts/bench_translate_models.py"
# 评测中间产物目录(gitignored),各模型跑完后才存在。
EXPERIMENT_ROOT = WORKSPACE / "data/experiments/translate_models"
def _load(name: str, path: Path):
"""按文件路径加载 scripts 下的模块(scripts 不是包,无法 import)。"""
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec is not None and spec.loader is not None
spec.loader.exec_module(module)
return module
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 test_eval_set_is_real_and_self_consistent() -> None:
"""评测集必须来自真实产物:条数充足、基准非空、时间轴可定位真实日语 cue。
这是"人工 review 的样本必须真实可核对"的硬约束——如果窗口的时间区间里
找不到对应日语 cue,说明窗口是用假数据拼的,或时间轴与 ASR 不同源。
"""
if not EVAL_SET.is_file() or not JA_SRT.is_file():
pytest.skip("评测集或日语 ASR 产物缺失(先跑 build_segment_eval.py")
from nodes.srt import parse_srt
windows = [json.loads(line) for line in EVAL_SET.read_text(encoding="utf-8").splitlines()]
assert len(windows) >= 100, "评测集窗口过少,不足以支撑人工质量结论"
ja_cues = parse_srt(JA_SRT.read_text(encoding="utf-8"))
for window in windows:
assert window["ja_lines"], f"窗口 {window.get('id')} 没有日语原文"
assert window["zh_ref"].strip(), f"窗口 {window.get('id')} 基准中文为空"
# 基准中文必须位于窗口时间区间内(真实配对),且窗口跨度合理。
assert _seconds(window["zh_start"]) < _seconds(window["zh_end"])
assert _seconds(window["zh_end"]) - _seconds(window["zh_start"]) <= 15.0
# 每条日语 cue 都能在真实 ASR 里按时间戳找到(同源校验)。
for line in window["ja_lines"]:
assert any(cue.text == line for cue in ja_cues), f"日语行不在真实 ASR 中: {line!r}"
def test_window_builder_matches_production_parser() -> None:
"""窗口构建器与生产 SRT 解析器结果一致(不另写一套 SRT 规则)。
用 window.jsonl 的候选池与当场重算的窗口对比:同一输入必须得到同一数量与
同一序列,避免"导出一次、之后代码改动悄悄漂移"
"""
if not WINDOWS.is_file() or not JA_SRT.is_file():
pytest.skip("候选池缺失(先跑 build_segment_eval.py --export")
module = _load("build_segment_eval", WORKSPACE / "scripts/build_segment_eval.py")
zh_srt = WORKSPACE / "data/storage/runs/run_479b411f299d/steps/filter/filtered.srt"
if not zh_srt.is_file():
pytest.skip("中文基准产物缺失(ocr-subtitle 任务未产出 filtered.srt")
rebuilt = module.build_windows(JA_SRT, zh_srt)
exported = [json.loads(line) for line in WINDOWS.read_text(encoding="utf-8").splitlines()]
assert len(rebuilt) == len(exported)
assert [(w["zh_start"], tuple(w["ja_lines"])) for w in rebuilt] == [
(w["zh_start"], tuple(w["ja_lines"])) for w in exported
]
def test_bench_compare_prints_every_model_for_shared_window() -> None:
"""对照表必须为每个模型打印同一窗口的译文(人工 review 的数据来源)。
用两个真实已跑完的模型产物(基线 + 本地 30B)生成对照表,断言:任意窗口
区块里两个模型都出现且行数一致——否则 review 会因缺行而漏判。
"""
baseline = EXPERIMENT_ROOT / "_baseline/steps/cn.srt"
other = EXPERIMENT_ROOT / "local-qwen3-30b-a3b/steps/cn.srt"
if not EVAL_SET.is_file() or not baseline.is_file() or not other.is_file():
pytest.skip("评测集或某个模型产物缺失(先跑 bench_translate_models.py run")
result = subprocess.run(
[sys.executable, str(BENCH), "compare", "--tags", "_baseline", "local-qwen3-30b-a3b",
"--limit", "5"],
cwd=WORKSPACE, capture_output=True, text=True, check=True,
)
output = result.stdout
assert "基准中文(OCR)" in output
blocks = [b for b in output.split("\n### ") if b.strip()]
assert blocks, "对照表没有任何窗口区块"
for block in blocks:
assert "- _baseline:" in block
assert "- local-qwen3-30b-a3b:" in block
# 两个模型的行数(以 | 分隔的译文数)必须一致。
base_line = next(l for l in block.splitlines() if l.startswith("- _baseline:"))
other_line = next(l for l in block.splitlines() if l.startswith("- local-qwen3-30b-a3b:"))
assert base_line.count("") == other_line.count("")