diff --git a/docs/architecture.md b/docs/architecture.md index abdfd41..4f37776 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,13 +73,23 @@ BIO-разметка строится по символьным спанам ч используются, если `models/segmenter` нет или модель не нашла ни одной строки; режим можно форсировать: `predict_release(..., segmenter="rules")`. -* Обучение: `python -m src.segmenter --train` (~1 мин). Разметка строк — по - `raw` из `labels.csv` (362 страницы в `data/pages`); синтетика: `raw` всех - train-строк (в т.ч. nnm/kinozal без страниц) вставляются в контекст реальных - строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест). -* Метрики: строки (test) P 0.936 / R 0.994; e2e на непросмотренных страницах - rutracker (n=64) — precision 0.904 → 0.946 против правил, recall 0.975 → 0.975 - (`tests/e2e_segmenter.py`; выборка мала, разброс большой). +* Обучение: `python -m src.segmenter --train --out models/segmenter_v2` (или меню + `tools/collect.py` → «2»). Разметка строк — по `raw` и `audio_raw` из `labels.csv` + (страницы `data/pages`); синтетика: `raw` всех train-строк вставляются в контекст + реальных строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест). +* Текст kinozal берётся из `data/raw/kinozal/.json` (`h1` + «Техданные» + + «Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни + «Техданных», откуда взята половина меток kinozal, а сервис (`fetch_html`) + видит обе вкладки. +* Кодек-строка русской дорожки («Аудио 3: AC3 … Русский (MVO, LostFilm)», + «Аудио Rus: … | LostFilm»), не попавшая в разметку, в обучение не идёт + (метка -1): у rutracker `raw` — строка «Перевод N:», и как негатив она учила + модель отбрасывать кодек-строки — а на kinozal студии только в них. +* В `predict_release` выбранные моделью кодек-строки — второстепенный источник + (`source: codec`): подключаются, только если у какой-то дорожки страницы нет + студии; если других строк нет (nnm), они и есть дорожки. +* e2e: `tests/e2e_segmenter.py [каталог]` — пары (тип, студия) на тестовых + страницах, не больше `E2E_MAX` (400) на трекер; `--train-pages` — ещё и train. * Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена (91 группа из 6735) — остаётся на правилах `_split_entries`, но только на строках, выбранных моделью. diff --git a/src/release.py b/src/release.py index 4110ab0..71e86ff 100644 --- a/src/release.py +++ b/src/release.py @@ -211,7 +211,8 @@ def same_studio(a: str | None, b: str | None) -> bool: def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str, - match=lambda a, b: (a or "").casefold() == (b or "").casefold()) -> None: + match=lambda a, b: (a or "").casefold() == (b or "").casefold(), + adopt_type: bool = False) -> None: """Добавляет дорожки второстепенного источника к дорожкам страницы (на месте). * такая студия уже есть (по `match`) -> пропуск; @@ -219,13 +220,21 @@ def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str, (source=page+): это та же дорожка; * иначе -> новая дорожка (source=). - Дорожки без студии или с типом «неизвестно» не берутся: тип без студии - страница уже даёт, а в кодек-строках он часто грубее («одноголосый» - вместо «авт. одноголосый»). + Дорожки без студии не берутся: тип без студии страница уже даёт, а в + кодек-строках он часто грубее («одноголосый» вместо «авт. одноголосый»). + Дорожка с типом «неизвестно» («Аудио Rus: AC3 … | LostFilm») берётся + только при `adopt_type` и если у страницы ровно один тип без студии + («Перевод: Профессиональный многоголосый»): она получает этот тип. """ + bare = {x["translation_type"] for x in tracks + if not x.get("studio") and x["translation_type"] != UNKNOWN_TYPE} for t in extra: - if not t.get("studio") or t["translation_type"] == UNKNOWN_TYPE: + if not t.get("studio"): continue + if t["translation_type"] == UNKNOWN_TYPE: + if not (adopt_type and len(bare) == 1): + continue + t["translation_type"] = next(iter(bare)) if any(match(x.get("studio"), t["studio"]) for x in tracks): continue same = next((x for x in tracks if not x.get("studio") @@ -245,11 +254,24 @@ def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: merge_extra_tracks(tracks, title_tracks, "title") -# кодек-строка дорожки с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; -# «Аудио 6: E-AC3 … English Dubbing» не подходит — нет русского маркера типа +# кодек-строка: «Аудио 3: AC3 …», «Аудио Rus: …», «Аудиодорожка 1: …», «Audio #2: …» +RE_AUDIO_PREFIX = re.compile(r"(?i)^\s*(?:Аудио\w*|Audio)\s*(?:№|#)?\s*(?:\d+|rus|ukr|eng)?\s*[:.]") +# с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; «English Dubbing» — не тип RE_CODEC_TRACK = re.compile( r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b" r"|дубл|многоголос|двухголос|одноголос|закадр|авторск)") +RE_RU_TRACK = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b") +RE_FOREIGN_TRACK = re.compile( + r"(?i)казах|english|англ|француз|немец|немецк|итальян|испан|японск|корейск|китайск" + r"|\boriginal\b|оригинал|\beng\b") + + +def codec_ok(line: str) -> bool: + """Кодек-строка русской (украинской) дорожки: «Казахский (Dub, TK Qazaqstan)», + «English Dubbing», «Original» — нет.""" + if RE_RU_TRACK.search(line): + return True + return not RE_FOREIGN_TRACK.search(line) and bool(RE_CODEC_TRACK.match(line)) def _entries(line: str) -> list[str]: @@ -258,31 +280,38 @@ def _entries(line: str) -> list[str]: def codec_segments(page_text: str) -> list[str]: - """Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)»). + """Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)») по правилу. - Сегментатор обучен на `raw` датасета, где у rutracker это строки - «Перевод N:», поэтому кодек-строки он отбрасывает. Они нужны там, где - студии есть только в них (kinozal: вкладка «Релиз» против «Перевод:» без - студий в «Техданных»), и идут второстепенным источником, см. - `merge_extra_tracks`. + Запасной путь к тому, что выбирает сегментатор (`model_segments`): старые + веса сегментатора кодек-строки отбрасывают. Они нужны там, где студии есть + только в них (kinozal: вкладка «Релиз» против «Перевод:» без студий в + «Техданных»), и идут второстепенным источником, см. `merge_extra_tracks`. """ out = [] for line in (page_text or "").splitlines(): - if RE_CODEC_TRACK.match(line): + if RE_CODEC_TRACK.match(line) and codec_ok(line): out += _entries(line) return out -def model_segments(segmenter, page_text: str) -> list[str]: +def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]: """Дорожки страницы: строки выбирает обученный сегментатор. + Возвращает (основные, кодек): строки «Перевод/Озвучка …» и кодек-строки + «Аудио N: AC3 … (MVO, LostFilm)». Кодек-строки обычно дублируют основные + в другом написании, поэтому они второстепенный источник (`predict_release`); + если основных нет (nnm: дорожки только в «Аудио N:») — они и есть дорожки. + Нарезка внутри строки («Дублированный, многоголосый») в данных не размечена и пока делается правилами `_split_entries`. """ - out = [] + main, codec = [], [] for line in segmenter.select_lines(page_text): - out += _entries(line) - return out + if not RE_AUDIO_PREFIX.match(line): + main += _entries(line) + elif codec_ok(line): + codec += _entries(line) + return main, codec def predict_release(predictor, *, html: str | None = None, text: str | None = None, @@ -300,10 +329,12 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No if (html is None) == (text is None): raise ValueError("нужно ровно одно из: html, text") page_text = html_to_text(html) if html is not None else text - segs_page, used = [], "rules" + segs_page, segs_codec, used = [], [], "rules" seg_model = getattr(predictor, "segmenter", None) if segmenter == "model" and seg_model is not None: - segs_page = model_segments(seg_model, page_text) + segs_page, segs_codec = model_segments(seg_model, page_text) + if not segs_page: + segs_page, segs_codec = segs_codec, [] used = "model" if segs_page else "rules" if not segs_page: segs_page = extract_segments(page_text) @@ -323,14 +354,15 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No # дорожки (kinozal: «Перевод: многоголосый» + «Аудио 3: … (MVO, LostFilm)»); # иначе они дублируют «Перевод N:» в другом написании и дают ложные студии seen = set(segs_page) - segs_codec = [x for x in codec_segments(page_text) if x not in seen] + segs_codec = list(dict.fromkeys(x for x in segs_codec + codec_segments(page_text) + if x not in seen)) if segs_codec and (not tracks or any(not t.get("studio") for t in tracks)): codec = predictor._predict_batch(segs_codec) for t in codec: ep = parse_episodes(t["segment"]) t["episode_ranges"] = ep["ranges"] t["episodes"] = format_ranges(ep["ranges"]) or None - merge_extra_tracks(tracks, codec, "codec", match=same_studio) + merge_extra_tracks(tracks, codec, "codec", match=same_studio, adopt_type=True) if title is None: title = title_from_html(html) if html is not None else title_from_text(text) segs = title_segments(title or "") diff --git a/src/segmenter.py b/src/segmenter.py index 63461d1..e57f0b9 100644 --- a/src/segmenter.py +++ b/src/segmenter.py @@ -11,8 +11,10 @@ Python API: seg = LineSegmenter("models/segmenter") seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят) -Разметка: строка страницы положительна, если содержит `raw` строки датасета -этого релиза (или наоборот). Положительные примеры добавляются синтетически: +Разметка: строка страницы положительна, если содержит `raw` (или `audio_raw`) +строки датасета этого релиза (или наоборот); неразмеченные кодек-строки русских +дорожок в обучение не идут (см. `label_page`). Текст kinozal собирается из +data/raw/kinozal/*.json (`kinozal_text`). Положительные примеры добавляются синтетически: `raw` всех train-строк датасета (включая nnm/kinozal, у которых нет страниц) вставляются в контекст реальных строк страниц. Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена и остаётся на правилах. @@ -69,8 +71,34 @@ def page_lines(text: str) -> list[str]: # ---------------------------------------------------------------- данные +RAW_KINOZAL = Path("data/raw/kinozal") + + +def kinozal_text(release_id: str, raw_dir: Path = RAW_KINOZAL) -> str | None: + """Текст страницы kinozal так, как его видит сервис: заголовок + «Техданные» + («Перевод: …») + «Релиз» («Аудио N: … (MVO, LostFilm)»). + + В data/pages/kz-*.txt нет ни заголовка, ни «Техданных», а метки kinozal + наполовину взяты именно оттуда, поэтому для kinozal берётся raw JSON. + """ + path = raw_dir / f"{str(release_id).removeprefix('kz-')}.json" + if not path.exists(): + return None + j = json.loads(path.read_text(encoding="utf-8")) + return "\n".join(x for x in (j.get("h1"), j.get("tech_tab"), j.get("release_tab")) if x) + + +def page_text(path: Path) -> str: + """Текст сохранённой страницы релиза (kinozal — из raw JSON, если он есть).""" + if path.stem.startswith("kz-"): + text = kinozal_text(path.stem, path.parent.parent / "raw" / "kinozal") + if text: + return text + return read_input(path) + + def load_pages(pages_dir: Path = Path("data/pages")) -> dict[str, list[str]]: - return {p.stem: page_lines(read_input(p)) for p in sorted(pages_dir.glob("*.txt"))} + return {p.stem: page_lines(page_text(p)) for p in sorted(pages_dir.glob("*.txt"))} def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]: @@ -78,12 +106,28 @@ def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]: return list(csv.DictReader(f)) +# кодек-строка русской/украинской дорожки: «Аудио 2: AC3 … Русский (MVO, X)», +# «Аудио Rus: AC3 … | LostFilm», «Аудиодорожка 1: … | Russian | TVShows»; +# сводка «Аудио: Русский, английский (AC3 …)» без номера — не дорожка +RE_AUDIO_TRACK = re.compile( + r"(?i)^\s*(?:(?:Аудио|Audio)\s*(?:rus|ukr)\s*[:.]" + r"|(?:Аудиодорожка\s*\d*|(?:Аудио|Audio)\s*(?:№|#)?\s*\d+)\s*[:.]" + r".*(?:русск|russian|\brus\b|украин|ukrain|\bukr\b))") + + def label_page(lines: list[str], raws: list[str]) -> list[int]: - """1, если строка содержит raw (или raw содержит строку ≥ 15 символов).""" + """1 — строка содержит raw (или raw содержит строку ≥ 15 символов); + -1 — неразмеченная кодек-строка русской дорожки: в обучение не идёт. + + Кодек-строка «Аудио N: … (MVO, LostFilm)» — дорожка, но у rutracker + `raw` — строка «Перевод N:», а кодек-строка в разметку не попадает. Как + негатив она учила модель отбрасывать такие строки (на kinozal студии + только в них), поэтому неразмеченные кодек-строки пропускаются. + """ out = [] for line in lines: hit = any(r and (r in line or (len(line) >= 15 and line in r)) for r in raws) - out.append(int(hit)) + out.append(1 if hit else (-1 if RE_AUDIO_TRACK.match(line) else 0)) return out @@ -93,7 +137,10 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l rows = load_raws() raws_by_rel: dict[str, list[str]] = {} for r in rows: - raws_by_rel.setdefault(r["release_id"], []).append(norm(r["raw"])) + # audio_raw — кодек-строка той же дорожки («AC3 … Русский (MVO, X)»), тоже позитив + for text in (r["raw"], r.get("audio_raw")): + if norm(text): + raws_by_rel.setdefault(r["release_id"], []).append(norm(text)) want_test = split == "test" page_items = [] # (release_id, lines, labels) @@ -105,7 +152,7 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l # все raw train-релизов (без тестовых страниц) — материал для синтетики test_pos = {l for rid, lines in pages.items() if is_test_release(rid) - for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y} + for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y == 1} pool = sorted({norm(r["raw"]) for r in rows if r["raw"] and not is_test_release(r["release_id"]) and norm(r["raw"]) not in test_pos}) @@ -113,13 +160,15 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l ex: list[tuple] = [] for rid, lines, labels in page_items: for i, (line, y) in enumerate(zip(lines, labels)): + if y < 0: + continue # неразмеченная кодек-строка: ни позитив, ни негатив prev = lines[i - 1] if i else "" nxt = lines[i + 1] if i + 1 < len(lines) else "" if not y and not HINT.search(line) and rng.random() > 0.15: continue # простые негативы прореживаем, трудные (с намёками) берём все ex.append((*make_input(prev, line, nxt), y)) if not want_test: - negs = [l for l, y in zip(lines, labels) if not y] + negs = [l for l, y in zip(lines, labels) if y == 0] for _ in range(synth_per_page if negs else 0): raw = rng.choice(pool) p, n = rng.choice(negs), rng.choice(negs) diff --git a/tests/e2e_segmenter.py b/tests/e2e_segmenter.py index 82af200..4fc52d3 100644 --- a/tests/e2e_segmenter.py +++ b/tests/e2e_segmenter.py @@ -2,24 +2,29 @@ Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по страницам, которых сегментатор не видел при обучении (is_test_release). -Запуск: .venv/Scripts/python tests/e2e_segmenter.py +Текст kinozal — из data/raw/kinozal/*.json (как у сервиса), см. segmenter.page_text. +На трекер берётся не больше E2E_MAX страниц (по умолчанию 400, детерминированно); +train-страницы считаются только с флагом --train-pages. +Запуск: .venv/Scripts/python tests/e2e_segmenter.py [каталог_сегментатора] [--train-pages] """ from __future__ import annotations +import hashlib import json +import os import sys from collections import defaultdict from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from src.html_extract import read_input # noqa: E402 from src.predict import DubPredictor # noqa: E402 from src.release import predict_release # noqa: E402 -from src.segmenter import is_test_release # noqa: E402 +from src.segmenter import is_test_release, page_text # noqa: E402 ROOT = Path(__file__).resolve().parents[1] +MAX_PER_SOURCE = int(os.environ.get("E2E_MAX", "400")) def run(predictor, rows, mode: str, test_only: bool) -> dict: @@ -27,17 +32,21 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict: for r in rows: by_rel[r["release_id"]].append(r) per_src = defaultdict(list) - for page in sorted((ROOT / "data" / "pages").glob("*.txt")): + pages = [p for p in (ROOT / "data" / "pages").glob("*.txt") + if is_test_release(p.stem) == test_only and p.stem in by_rel] + pages.sort(key=lambda p: hashlib.md5(p.stem.encode()).hexdigest()) + for page in pages: rid = page.stem - if is_test_release(rid) != test_only or rid not in by_rel: + src = by_rel[rid][0]["source"] + if len(per_src[src]) >= MAX_PER_SOURCE: continue gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]} - res = predict_release(predictor, text=read_input(page), segmenter=mode) + res = predict_release(predictor, text=page_text(page), segmenter=mode) pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]} tp = len(gold & pred) p = tp / len(pred) if pred else 0.0 r = tp / len(gold) if gold else 0.0 - per_src[by_rel[rid][0]["source"]].append((p, r)) + per_src[src].append((p, r)) out = {} for s, v in per_src.items(): out[s] = (len(v), sum(x[0] for x in v) / len(v), sum(x[1] for x in v) / len(v)) @@ -46,13 +55,16 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict: def main() -> None: rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8")) - seg_dir = sys.argv[1] if len(sys.argv) > 1 else "models/segmenter" + args = [a for a in sys.argv[1:] if not a.startswith("--")] + seg_dir = args[0] if args else "models/segmenter" predictor = DubPredictor(segmenter_dir=seg_dir) print(f"сегментатор: {seg_dir}") if predictor.segmenter is None: sys.exit("нет models/segmenter: python -m src.segmenter --train") - for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"), - (False, "train-страницы (для справки, оптимистично)")): + blocks = [(True, "тестовые страницы (не виделись сегментатору)")] + if "--train-pages" in sys.argv: + blocks.append((False, "train-страницы (для справки, оптимистично)")) + for test_only, name in blocks: print(f"== {name} ==") for mode in ("rules", "model"): res = run(predictor, rows, mode, test_only) diff --git a/tests/run_tests.py b/tests/run_tests.py index a53df13..f927d34 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -246,6 +246,46 @@ merge_extra_tracks(_page2, [ check("codec: студия вписана, новая добавлена, без студии — нет", [(x["studio"], x["source"]) for x in _page2] == [("HDrezka", "page+codec"), ("LostFilm", "codec")]) +_page3 = [dict(_t("проф. многоголосый", None), studio_span=None, studio_confidence=None)] +merge_extra_tracks(_page3, [ + dict(_t("неизвестно", "LostFilm"), studio_span="LostFilm", studio_confidence=1), + dict(_t("неизвестно", "TVShows"), studio_span="TVShows", studio_confidence=1)], + "codec", match=same_studio, adopt_type=True) +check("codec: «Аудио Rus: … | LostFilm» получает тип дорожки страницы", + [(x["translation_type"], x["studio"]) for x in _page3] + == [("проф. многоголосый", "LostFilm"), ("проф. многоголосый", "TVShows")]) + +from src.release import codec_ok, model_segments # noqa: E402 +from src.segmenter import kinozal_text, label_page # noqa: E402 + + +class _FakeSeg: + def select_lines(self, text): + return text.splitlines() + + +_main, _codec = model_segments(_FakeSeg(), "Перевод: Профессиональный многоголосый\n" + "Аудио 3: AC3 - Русский (MVO, LostFilm)\n" + "Аудио 6: AC3 - Казахский (Dub, TK Qazaqstan)") +check("model_segments: основные и кодек-строки раздельно, иноязычная отброшена", + _main == ["Профессиональный многоголосый"] and _codec == ["AC3 - Русский (MVO, LostFilm)"]) +check("codec_ok: «English Dubbing» и «Original» — не русская дорожка", + not codec_ok("Аудио 6: E-AC3 - English Dubbing") and not codec_ok("Аудио 5: E-AC3 - Original (французский)") + and codec_ok("Аудио Rus: AC3, 2 ch | LostFilm")) +check("label_page: raw — 1, неразмеченная кодек-строка — -1, сводка «Аудио:» — 0", + label_page(["Перевод: Дублированный", "Аудио 2: AC3 - Русский (MVO, X)", + "Аудио: Русский, английский (AC3)", "Размер: 6 ГБ"], ["Перевод: Дублированный"]) + == [1, -1, 0, 0]) +import json # noqa: E402 +import tempfile as _tmp # noqa: E402 +with _tmp.TemporaryDirectory() as _d: + Path(_d, "123.json").write_text(json.dumps({"h1": "X / Y / 2026 / ПМ (LostFilm)", + "tech_tab": "Перевод: Профессиональный многоголосый", + "release_tab": "Аудио Rus: AC3 | LostFilm"}), encoding="utf-8") + check("kinozal_text: заголовок + Техданные + Релиз из raw JSON", + kinozal_text("kz-123", Path(_d)).splitlines() + == ["X / Y / 2026 / ПМ (LostFilm)", "Перевод: Профессиональный многоголосый", "Аудио Rus: AC3 | LostFilm"] + and kinozal_text("kz-404", Path(_d)) is None) _dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n" "Гангстерленд / 2026 / ДБ (X) / WEB-DL") @@ -381,6 +421,7 @@ check("series_info: сезон, серии, total и «студия -> сери print("== сборщик датасета (tools/collect.py) ==") import csv as _csv # noqa: E402 import json as _json # noqa: E402 +import json # noqa: E402 import tempfile as _tmp # noqa: E402 from pathlib import Path as _P # noqa: E402