DUB-AI: multi-task модель (тип озвучки + автор-спан + 18+), обучение/инференс/ONNX/сервис, тесты и документация
This commit is contained in:
commit
8dbca3f4a3
2873 files changed
+44479
No files matched your search
@@ -0,0 +1,157 @@
|
||||
"""E2E-проверка инференса (запуск: .venv/Scripts/python tests/e2e_check.py).
|
||||
|
||||
1. Дорожки, извлечённые из сохранённых страниц (data/pages/*.txt) и HTML-проб
|
||||
(data/samples/*.html), сравниваются с метками датасета по парам
|
||||
(тип, канонический автор) на уровне релиза (precision/recall).
|
||||
2. Smoke-тест на формулировках, которых нет в датасете дословно.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.predict import DubPredictor # noqa: E402
|
||||
from src.studio_norm import canonical # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def release_check(predictor: DubPredictor, rows: list[dict]) -> dict:
|
||||
"""E2E на текстовых дампах страниц: пары (тип, автор) по релизам."""
|
||||
by_release: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
by_release[(r["source"], r["release_id"])].append(r)
|
||||
|
||||
id2src: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for (src, rid) in by_release:
|
||||
id2src[rid].append((src, rid))
|
||||
|
||||
prec_sum = rec_sum = n = 0
|
||||
per_src: dict[str, list[list[float]]] = defaultdict(list)
|
||||
misses = []
|
||||
pages = sorted((ROOT / "data" / "pages").glob("*.txt"))
|
||||
for page in pages:
|
||||
cands = id2src.get(page.stem, [])
|
||||
if len(cands) != 1:
|
||||
continue # нет меток или идентификатор неоднозначен между источниками
|
||||
src, rid = cands[0]
|
||||
gt_rows = by_release[(src, rid)]
|
||||
gold = {(r["translation_type"], r["studio"] or None) for r in gt_rows}
|
||||
try:
|
||||
tracks = predictor.predict_file(page)
|
||||
except Exception as e: # noqa: BLE001
|
||||
misses.append(f"{page.name}: ошибка {e}")
|
||||
continue
|
||||
pred = {(t["translation_type"], t["studio"]) for t in tracks}
|
||||
tp = len(gold & pred)
|
||||
p = tp / len(pred) if pred else 0.0
|
||||
r = tp / len(gold) if gold else 0.0
|
||||
prec_sum += p
|
||||
rec_sum += r
|
||||
n += 1
|
||||
per_src[src].append([p, r])
|
||||
if p < 1 or r < 1:
|
||||
misses.append(f"[{src}] {page.name}: gold={sorted(map(str, gold))} "
|
||||
f"pred={sorted(map(str, pred))}")
|
||||
stats = {"n_releases": n,
|
||||
"mean_precision": prec_sum / max(n, 1),
|
||||
"mean_recall": rec_sum / max(n, 1),
|
||||
"per_source": {s: {"n": len(v),
|
||||
"precision": sum(x[0] for x in v) / len(v),
|
||||
"recall": sum(x[1] for x in v) / len(v)}
|
||||
for s, v in per_src.items()},
|
||||
"misses": misses[:8]}
|
||||
return stats
|
||||
|
||||
|
||||
# (текст, ожидаемый тип, ожидаемый автор-подстрока или None, ожидаемый 18+)
|
||||
SMOKE = [
|
||||
("Профессиональный (многоголосый закадровый) | HDRezka Studio 18+",
|
||||
"проф. многоголосый", "HDrezka", True),
|
||||
("Перевод: Дублированный | MovieDalen", "проф. дубляж", "MovieDalen", False),
|
||||
("Авторская одноголосая озвучка — Дмитрий Пучков",
|
||||
"авт. одноголосый", "Пучков", False),
|
||||
("Аудио: русский DVO (СВ Студия)", "проф. двухголосый", "СВ Студия", False),
|
||||
("перевод: любительский многоголосый, NewStudio",
|
||||
"люб. многоголосый", "NewStudio", False),
|
||||
("Одноголосый закадровый (Василий Горчаков)",
|
||||
"одноголосый", "Горчаков", False),
|
||||
("Закадровый одноголосый перевод: Юрий Немахов",
|
||||
"одноголосый", "Немахов", False),
|
||||
("Многоголосое озвучивание TVShows, 18+", "многоголосый", "TVShows", True),
|
||||
("Озвучено студией LostFilm (многоголосое закадровое)",
|
||||
"многоголосый", "LostFilm", False),
|
||||
("Русский DUB (Кинопоиск)", "проф. дубляж", "Кинопоиск", False),
|
||||
]
|
||||
|
||||
|
||||
def smoke_check(predictor: DubPredictor) -> dict:
|
||||
ok_type = ok_studio = ok_age = 0
|
||||
fails = []
|
||||
for text, want_type, want_studio, want_age in SMOKE:
|
||||
r = predictor.predict_text(text)
|
||||
t_ok = r["translation_type"] == want_type
|
||||
s_ok = bool((want_studio is None and r["studio"] is None)
|
||||
or (r["studio"] and want_studio.lower() in r["studio"].lower()))
|
||||
a_ok = r["is_18plus"] == want_age
|
||||
ok_type += t_ok
|
||||
ok_studio += s_ok
|
||||
ok_age += a_ok
|
||||
if not (t_ok and s_ok and a_ok):
|
||||
fails.append(f"«{text[:50]}» -> {r['translation_type']}, "
|
||||
f"studio={r['studio']}, 18+={r['is_18plus']} "
|
||||
f"(ожидалось {want_type}, {want_studio}, {want_age})")
|
||||
return {"n": len(SMOKE), "type_ok": ok_type, "studio_ok": ok_studio,
|
||||
"age_ok": ok_age, "fails": fails}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
rows = json.loads((ROOT / "data" / "processed" / "dataset.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
predictor = DubPredictor()
|
||||
|
||||
print("== e2e: страницы против меток ==")
|
||||
rel = release_check(predictor, rows)
|
||||
print(f"релизов проверено: {rel['n_releases']}, "
|
||||
f"mean precision {rel['mean_precision']:.3f}, "
|
||||
f"mean recall {rel['mean_recall']:.3f}")
|
||||
for s, v in rel.get("per_source", {}).items():
|
||||
print(f" [{s}] n={v['n']} precision={v['precision']:.3f} "
|
||||
f"recall={v['recall']:.3f}")
|
||||
for m in rel["misses"]:
|
||||
print(f" ! {m}")
|
||||
|
||||
print("\n== e2e: HTML-пробы (проверка кодировок/структуры) ==")
|
||||
for f in sorted((ROOT / "data" / "samples").glob("*.html")):
|
||||
try:
|
||||
tracks = predictor.predict_file(f)
|
||||
names = ", ".join(f"{t['translation_type']}/{t['studio'] or '—'}"
|
||||
for t in tracks[:4])
|
||||
print(f" {f.name}: {len(tracks)} дорожек: {names}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {f.name}: ошибка {e}")
|
||||
|
||||
print("\n== smoke: формулировки вне датасета ==")
|
||||
sm = smoke_check(predictor)
|
||||
print(f"тип {sm['type_ok']}/{sm['n']}, автор {sm['studio_ok']}/{sm['n']}, "
|
||||
f"18+ {sm['age_ok']}/{sm['n']}")
|
||||
for f in sm["fails"]:
|
||||
print(f" ! {f}")
|
||||
|
||||
rt = rel.get("per_source", {}).get("rutracker", {})
|
||||
# жёсткий критерий: тип на OOD-формулировках и полнота на rutracker-страницах;
|
||||
# studio на придуманных форматах — диагностика (см. README «Ограничения»)
|
||||
hard_fail = (sm["type_ok"] < sm["n"] - 2
|
||||
or rel["n_releases"] == 0
|
||||
or (rt and rt["recall"] < 0.75))
|
||||
print("\nитог:", "OK" if not hard_fail else "ЕСТЬ ПРОБЛЕМЫ")
|
||||
sys.exit(1 if hard_fail else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Юнит-тесты DUB-AI (запуск: .venv/Scripts/python tests/run_tests.py).
|
||||
|
||||
Проверяют ключевые части без обращения к GPU:
|
||||
* поиск спана автора (точно / алиас / инициалы / кавычки / разделители);
|
||||
* канонизация имён студий;
|
||||
* снятие префикса строки дорожки;
|
||||
* сегментацию страниц на дорожки;
|
||||
* HTML -> текст;
|
||||
* BIO-выравнивание спана по offsets токенизатора.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.html_extract import (RE_LINE_PREFIX, _split_entries, # noqa: E402
|
||||
extract_segments, html_to_text)
|
||||
from src.studio_norm import canonical, find_studio_span # noqa: E402
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def check(name: str, cond: bool, extra: str = "") -> None:
|
||||
global PASS, FAIL
|
||||
if cond:
|
||||
PASS += 1
|
||||
print(f" ok {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f"FAIL {name} {extra}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- спаны
|
||||
print("== find_studio_span ==")
|
||||
txt = "Профессиональный (многоголосый закадровый) | HDRezka Studio 18+"
|
||||
s, e = find_studio_span(txt, "HDrezka")
|
||||
check("HDrezka найден", s is not None and "HDRezka" in txt[s:e], f"span={(s, e)}")
|
||||
|
||||
s, e = find_studio_span("Авторский (одноголосый закадровый) - Ю. Сербин", "Юрий Сербин")
|
||||
check("инициалы Ю. Сербин", "Сербин" in "Авторский (одноголосый закадровый) - Ю. Сербин"[s:e] if s is not None else False)
|
||||
|
||||
s, e = find_studio_span("Авторский (одноголосый закадровый) Гаврилов", "Андрей Гаврилов")
|
||||
check("фамилия Гаврилов", s is not None and e is not None and s > 0)
|
||||
|
||||
s, e = find_studio_span('Профессиональный (многоголосый закадровый) Студия «Кравец», LostFilm',
|
||||
"Студия Кравец, LostFilm")
|
||||
check("кавычки в тексте", s is not None)
|
||||
|
||||
s, e = find_studio_span("Профессиональный (многоголосый закадровый) - SDI Media | TV 1000",
|
||||
"SDI Media TV 1000")
|
||||
check("разделитель |", s is not None)
|
||||
|
||||
check("нет автора -> None", find_studio_span("Профессиональный (дублированный)", "") is None)
|
||||
check("нет вхождения -> None", find_studio_span("Профессиональный (дублированный)", "HDrezka") is None)
|
||||
|
||||
# ---------------------------------------------------------------- канонизация
|
||||
print("== canonical ==")
|
||||
check("HDRezka Studio -> HDrezka", canonical("HDRezka Studio") == "HDrezka")
|
||||
check("hdrezka -> HDrezka", canonical("hdrezka") == "HDrezka")
|
||||
check("Read Head Sound -> Red Head Sound", canonical("Read Head Sound") == "Red Head Sound")
|
||||
check("Ultradox Studio -> Ultradox", canonical("Ultradox Studio") == "Ultradox")
|
||||
check("Jaskier ненормативная лексика -> Jaskier",
|
||||
canonical("Jaskier ненормативная лексика") == "Jaskier")
|
||||
check("официальный -> ''", canonical("официальный") == "")
|
||||
|
||||
# ---------------------------------------------------------------- префиксы
|
||||
print("== RE_LINE_PREFIX ==")
|
||||
for line, body in [
|
||||
("Перевод: Профессиональный (дублированный)", "Профессиональный (дублированный)"),
|
||||
("Перевод 2: Любительский (одноголосый закадровый)", "Любительский (одноголосый закадровый)"),
|
||||
("Перевод #1: Дублированный", "Дублированный"),
|
||||
("Озвучивание: Дублированное | Official", "Дублированное | Official"),
|
||||
("Аудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)",
|
||||
"Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)"),
|
||||
("Перевод:Профессиональный (дублированный)", "Профессиональный (дублированный)"),
|
||||
]:
|
||||
check(f"префикс «{line[:20]}…»", RE_LINE_PREFIX.sub("", line) == body)
|
||||
|
||||
# ---------------------------------------------------------------- сегментация
|
||||
print("== extract_segments ==")
|
||||
segs = extract_segments(
|
||||
"Описание релиза\n"
|
||||
"Перевод 1: Профессиональный (дублированный)\n"
|
||||
"Перевод 2: Профессиональный (двухголосый закадровый) tycoon\n"
|
||||
"Субтитры: русские, английские\n")
|
||||
check("две дорожки", len(segs) == 2, f"got {segs}")
|
||||
|
||||
segs = extract_segments("Перевод 1: Профессиональный (дублированный) 2. Авторский (одноголосый закадровый) Гоблин")
|
||||
check("нумерация 1./2.", len(segs) == 2, f"got {segs}")
|
||||
|
||||
segs = extract_segments("Перевод: Одноголосый, многоголосый закадровый")
|
||||
check("запятая-перечисление", len(segs) == 2, f"got {segs}")
|
||||
|
||||
segs = extract_segments("Перевод: Профессиональный (многоголосый, закадровый)")
|
||||
check("скобки-уточнение не режутся", len(segs) == 1, f"got {segs}")
|
||||
|
||||
segs = extract_segments("Перевод:\nПрофессиональный (дублированный) Невафильм")
|
||||
check("значение на следующей строке",
|
||||
segs == ["Профессиональный (дублированный) Невафильм"], f"got {segs}")
|
||||
|
||||
segs = extract_segments("Перевод: Профессиональный (двухголосый закадровый) + Любительский (одноголосый закадровый)")
|
||||
check("разделитель +", len(segs) == 2, f"got {segs}")
|
||||
|
||||
segs = extract_segments("Аудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)\n"
|
||||
"Аудио 2: Opus, 6 ch, 199 Kbps | Русский DVO (Кубик в Кубе)")
|
||||
check("nnm аудио-дорожки", len(segs) == 2, f"got {segs}")
|
||||
|
||||
segs = extract_segments("Аудио: Русский (AC3, 6 ch, 640 Кбит/с), Английский (AC3)")
|
||||
check("codec-строка без типа пропущена", len(segs) == 0, f"got {segs}")
|
||||
|
||||
check("_split_entries одиночная", _split_entries("Дублированный") == ["Дублированный"])
|
||||
|
||||
# ---------------------------------------------------------------- html
|
||||
print("== html_to_text ==")
|
||||
html = ("<html><body><div>меню форума</div>"
|
||||
'<div class="post_body">Перевод: Профессиональный (дублированный) Невафильм</div>'
|
||||
"</body></html>")
|
||||
text = html_to_text(html)
|
||||
check("селектор .post_body", "Невафильм" in text and "меню форума" not in text)
|
||||
|
||||
html2 = "<html><body><p>hello</p></body></html>"
|
||||
check("fallback на весь текст", "hello" in html_to_text(html2))
|
||||
|
||||
print("== read_input / mojibake ==")
|
||||
import tempfile
|
||||
|
||||
from src.html_extract import read_input
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "mojibake.html"
|
||||
p.write_bytes("Перевод: Дублированный".encode("utf-8").decode("cp1251",
|
||||
errors="ignore").encode("utf-8"))
|
||||
check("mojibake восстановлен", "Дублированный" in read_input(p))
|
||||
p2 = Path(td) / "cp1251.txt"
|
||||
p2.write_bytes("Перевод: Дублированный".encode("cp1251"))
|
||||
check("cp1251 прочитан", "Дублированный" in read_input(p2))
|
||||
|
||||
# ---------------------------------------------------------------- BIO
|
||||
print("== BIO-выравнивание (требует токенизатор) ==")
|
||||
try:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from src.train import make_collate
|
||||
|
||||
tok = AutoTokenizer.from_pretrained("models/best")
|
||||
collate = make_collate(tok, {"проф. дубляж": 0})
|
||||
text = "Авторский (одноголосый закадровый) - Сергей Визгунов"
|
||||
s0 = text.index("Сергей")
|
||||
enc, types, bio, ages, offsets = collate(
|
||||
[{"text": text, "translation_type": "проф. дубляж",
|
||||
"span": [s0, s0 + len("Сергей Визгунов")], "is_18plus": False}])
|
||||
marked = {i for i, lab in enumerate(bio[0].tolist()) if lab >= 1}
|
||||
span_toks = [tok.convert_ids_to_tokens(int(enc["input_ids"][0, i])) for i in sorted(marked)]
|
||||
joined = "".join(t.lstrip("#") for t in span_toks).lower()
|
||||
# у ruBert-base токенизатор strips accents: «й» может стать «и»
|
||||
check("токены спана = ФИ", "визгунов" in joined and joined.startswith("сер"),
|
||||
f"tokens={span_toks}")
|
||||
check("первый токен B", bio[0, min(marked)].item() == 1 if marked else False)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" пропущено (нет модели models/best): {e}")
|
||||
|
||||
print(f"\nитог: {PASS} ok, {FAIL} failed")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
Reference in new issue
Block a user