为评估"更便宜的 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)。
132 lines
6.4 KiB
Python
132 lines
6.4 KiB
Python
"""翻译模型横向评测的数据契约与对照逻辑测试(真实数据,不 mock 模型)。
|
||
|
||
背景
|
||
----
|
||
为评估"更便宜的 LLM 能否替代当前 Qwen/Qwen3.6-35B-A3B 做 llm-translate",
|
||
新增了三件评测工具(scripts/ 下,都是真实数据驱动):
|
||
|
||
- `scripts/build_translate_eval.py`:把 run_479b411f299d(ocr-subtitle)的
|
||
烧录字幕中文产物与 run_d386ccf124f7(learn-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("|")
|