docker / build (push) Successful in 1m20s
Переразбор всех страниц data/raw: legacy-страницы импорта, форматы префиксов ([*]Перевод (1):, Озвучка:, №2, диапазоны), дорожки только в строках «Аудио» (в т.ч. «(Русский, X)», «| Rus | Звук: …»), «Язык озвучки», несколько студий в одной строке, студия без типа. Кодек-строки: «Аудио #N Русский:», «Аудио pic RUS:», «Звук N:», «Аудиодорожка 01:», кириллические АVO/МVO. «Авторский» без числа голосов -> авт. одноголосый, «двугоголосый». Чистка студий: языки, кодеки, «по заказу», «в составе контейнера», отказы от озвучки. Итог: страниц с метками 30 260 (85.5 %), страниц с дорожкой без меток 1 259 -> 223; data/pages 30 444; после prepare_data 60 944 строки (train 54 686 / test 6 258). Новый tools/coverage_audit.py + reports/coverage_audit.md, docs/dataset-coverage.md. Переобучен models/best (base): на общем тесте type acc 0.976 против 0.949 у прежней, span 0.674 против 0.643; e2e kinozal 0.805/0.867, rt 0.771/0.740, nnm 0.752/0.693.
274 lines
13 KiB
Python
274 lines
13 KiB
Python
"""Аудит покрытия: все страницы data/raw против data/labels.csv.
|
||
|
||
Для каждой сохранённой страницы (35 тыс.) считает, что на ней есть строки дорожек
|
||
озвучки (независимыми от парсеров правилами), и что из этого попало в датасет:
|
||
|
||
* страницы без меток: есть ли на них дорожки вообще (русский оригинал/«не
|
||
требуется»/немой — не дорожка) и какие форматы строк не разобраны;
|
||
* страницы с метками: строки дорожек, которым не соответствует ни одна строка
|
||
датасета (пропущенные дорожки/студии);
|
||
* кластеры форматов (по префиксу строки) для правки парсеров.
|
||
|
||
Результат: reports/coverage_audit.md (цифры, кластеры, примеры),
|
||
data/raw/_coverage_pages.csv (по странице) и data/raw/_coverage_lines.csv
|
||
(непокрытые строки, до 20 тыс.).
|
||
|
||
.venv\\Scripts\\python tools\\coverage_audit.py [--cache data/raw/_coverage.pkl]
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import os
|
||
import pickle
|
||
import random
|
||
import re
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
RAW = ROOT / "data" / "raw"
|
||
LABELS = ROOT / "data" / "labels.csv"
|
||
OUT_MD = ROOT / "reports" / "coverage_audit.md"
|
||
OUT_PAGES = RAW / "_coverage_pages.csv"
|
||
OUT_LINES = RAW / "_coverage_lines.csv"
|
||
SOURCES = ("kinozal", "nnm", "rutracker")
|
||
NO_STUDIO = "(не указана)"
|
||
|
||
# ---------------------------------------------------------------- детектор строк
|
||
# латинские/кириллические двойники в аббревиатурах: «АVO», «МVO», «DVO»
|
||
_HOMO = str.maketrans("АВЕКМНОРСТУХ", "ABEKMHOPCTYX")
|
||
|
||
|
||
def up(s: str) -> str:
|
||
return s.translate(_HOMO).upper()
|
||
|
||
|
||
RE_KW = re.compile(
|
||
r"(?i)^[\s\W]{0,6}(?:перевод\w*|озвуч\w*|озвучиван\w*|дубляж\w*|дубль\w*|звук\w*"
|
||
r"|аудио\w*|audio\w*|аудиодорожк\w*|дорожк\w*|track\w*)"
|
||
r"\s*(?:№|#)?\s*(?:\(?\d+\)?|\d+\s*[-–]\s*\d+|rus|ukr|jap|eng|pic|ru|uk)?\s*[:.]")
|
||
RE_PREFIX = re.compile(
|
||
r"(?i)^[\s\W]{0,6}(?:перевод\w*|озвуч\w*|озвучиван\w*|дубляж\w*|дубль\w*|звук\w*"
|
||
r"|аудио\w*|audio\w*|аудиодорожк\w*|дорожк\w*|track\w*)"
|
||
r"\s*(?:№|#)?\s*(?:\(?\d+\)?|\d+\s*[-–]\s*\d+|rus|ukr|jap|eng|pic|ru|uk)?\s*[:.]\s*")
|
||
RE_TYPE = re.compile(
|
||
r"(?i)(?:\bMVO\b|\bDVO\b|\bAVO\b|\bDUB\b|\bVO\b|дубл|многоголос|двухголос|двуголос"
|
||
r"|одноголос|закадр|авторск|любительск|профессиональн|озвучен|перевод\b)")
|
||
RE_NO_DUB = re.compile(
|
||
r"(?i)не\s*требу|немо[йе]|без\s*слов|без\s*диалог|нет\s*диалог|оригинал\w*\s*(?:дорожк|звук|аудио)?\s*[:.]?\s*$"
|
||
r"|^оригинал|субтитр|без\s*перевода|отсутств|не\s*озвуч|только\s*(?:музык|звук)|музыкальное\s*сопровожд")
|
||
RE_RU = re.compile(r"(?i)русск|russian|\brus\b|\bru\b|украин|ukrain|\bukr\b|україн")
|
||
RE_LANG = re.compile(
|
||
r"(?i)\b(русск\w*|russian|english|англ\w*|украин\w*|ukrainian|японск\w*|japanese"
|
||
r"|китайск\w*|корейск\w*|немецк\w*|французск\w*|итальянск\w*|испанск\w*|казахск\w*"
|
||
r"|оригинал\w*|original)\b")
|
||
RE_CODEC = re.compile(
|
||
r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby|ddp?)\b"
|
||
r"|\d\s*(?:kbps|кбит|kb/s|khz|кгц)")
|
||
RE_PIPE = re.compile(r"\|([^|]{2,60})\|")
|
||
RE_PAREN = re.compile(r"\(([^()]{2,80})\)")
|
||
|
||
|
||
def track_kind(line: str) -> str | None:
|
||
"""'dub' — строка дорожки озвучки, 'other' — аудио без признаков озвучки, None — не строка."""
|
||
s = line.strip()
|
||
if not s or len(s) > 300:
|
||
return None
|
||
m = RE_KW.match(s)
|
||
if not m:
|
||
# строка без префикса: «Russian (Dolby AC3, 6 ch) … | LostFilm» (nnm, rutracker)
|
||
if RE_CODEC.search(s) and (RE_RU.search(s) or RE_PIPE.search(s)) and len(s) < 220:
|
||
pass
|
||
else:
|
||
return None
|
||
body = s[m.end():] if m else s
|
||
if RE_NO_DUB.search(body) or (m and RE_NO_DUB.search(s[m.start():m.end() + 40])):
|
||
return None
|
||
if RE_TYPE.search(up(body)):
|
||
return "dub"
|
||
if RE_PIPE.search(body) or re.search(r"\([^()]*(?:mvo|dvo|avo|dub|дубл)", up(body)):
|
||
return "dub"
|
||
if RE_RU.search(body) and (RE_CODEC.search(s) or m and "перевод" in up(s[:30]).lower()):
|
||
return "other" # русская дорожка без типа: русский оригинал или неизвестный дубляж
|
||
return None
|
||
|
||
|
||
def norm(s: str) -> str:
|
||
return re.sub(r"\s+", " ", (s or "").replace("\u00a0", " ")).strip().casefold()
|
||
|
||
|
||
def line_prefix(line: str) -> str:
|
||
s = norm(line)
|
||
s = re.sub(r"\d+", "N", s)
|
||
m = re.match(r"^(.{0,26}?[:.])", s)
|
||
return (m.group(1) if m else s[:26]).strip()
|
||
|
||
|
||
def page_text(src: str, rec: dict) -> str:
|
||
if src == "kinozal":
|
||
return "\n".join(x for x in (rec.get("h1"), rec.get("tech_tab"), rec.get("release_tab")) if x)
|
||
return rec.get("post") or ""
|
||
|
||
|
||
# ---------------------------------------------------------------- загрузка
|
||
def load_pages(cache: Path | None) -> list[tuple[str, str, str, bool, bool]]:
|
||
if cache and cache.exists():
|
||
return pickle.loads(cache.read_bytes())
|
||
out = []
|
||
for src in SOURCES:
|
||
d = RAW / src
|
||
for name in sorted(os.listdir(d)):
|
||
if not name.endswith(".json"):
|
||
continue
|
||
rid = name.split(".")[0]
|
||
try:
|
||
rec = json.loads((d / name).read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
continue
|
||
out.append((src, rid, page_text(src, rec), "legacy" in name, bool(rec.get("ok", True))))
|
||
if cache:
|
||
cache.write_bytes(pickle.dumps(out))
|
||
return out
|
||
|
||
|
||
def row_strings(rows: list[dict]) -> list[str]:
|
||
out = []
|
||
for r in rows:
|
||
for k in ("raw", "audio_raw", "role_raw"):
|
||
v = norm(r.get(k) or "")
|
||
if len(v) >= 4:
|
||
out.append(v)
|
||
return out
|
||
|
||
|
||
def covered(line: str, rstr: list[str]) -> bool:
|
||
l = norm(line)
|
||
return any(r in l or (len(l) >= 12 and l in r) for r in rstr)
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument("--cache", type=Path)
|
||
args = ap.parse_args()
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
|
||
pages = load_pages(args.cache)
|
||
rows = list(csv.DictReader(LABELS.open(encoding="utf-8-sig", newline="")))
|
||
by_rel = defaultdict(list)
|
||
for r in rows:
|
||
by_rel[(r["source"], re.sub(r"^(kz|nnm|rt)-", "", r["release_id"]))].append(r)
|
||
|
||
per = defaultdict(Counter)
|
||
fmt_unlab = defaultdict(Counter)
|
||
fmt_part = defaultdict(Counter)
|
||
per_rel = defaultdict(Counter)
|
||
gaps = [] # (src, rid, kind, line, why)
|
||
examples = defaultdict(list)
|
||
page_csv = []
|
||
rnd = random.Random(11)
|
||
|
||
for src, rid, text, legacy, ok in pages:
|
||
L = by_rel.get((src, rid), [])
|
||
lines = []
|
||
for raw in text.splitlines():
|
||
kind = track_kind(raw)
|
||
if kind:
|
||
lines.append((kind, re.sub(r"\s+", " ", raw).strip()))
|
||
dub = [l for k, l in lines if k == "dub"]
|
||
stat = per[src]
|
||
stat["страниц"] += 1
|
||
stat["legacy" if legacy else "обычных"] += 1
|
||
if not ok:
|
||
stat["ok=False"] += 1
|
||
page_csv.append({"source": src, "release_id": rid, "has_labels": int(bool(L)),
|
||
"n_rows": len(L), "n_lines": len(lines), "n_dub_lines": len(dub)})
|
||
if not L:
|
||
stat["без меток"] += 1
|
||
if not lines:
|
||
stat["без меток: нет аудио-строк"] += 1
|
||
elif not dub:
|
||
stat["без меток: только русская дорожка/не озвучка"] += 1
|
||
else:
|
||
stat["без меток: ЕСТЬ дорожка озвучки"] += 1
|
||
for l in dub:
|
||
key = line_prefix(l)
|
||
fmt_unlab[src][key] += 1
|
||
if len(examples[(src, "unlabeled", key)]) < 20:
|
||
examples[(src, "unlabeled", key)].append((rid, l))
|
||
gaps.append((src, rid, "no_labels", l, "есть дорожка, строки датасета нет"))
|
||
continue
|
||
|
||
stat["с метками"] += 1
|
||
rs = row_strings(L)
|
||
unmatched = [l for l in dub if not covered(l, rs)]
|
||
page_csv[-1]["n_unmatched"] = len(unmatched)
|
||
if unmatched:
|
||
per_rel[src]["релизов с непокрытыми строками"] += 1
|
||
per_rel[src]["непокрытых строк"] += len(unmatched)
|
||
for l in unmatched:
|
||
key = line_prefix(l)
|
||
fmt_part[src][key] += 1
|
||
if len(examples[(src, "covered", key)]) < 20:
|
||
examples[(src, "covered", key)].append((rid, l))
|
||
gaps.append((src, rid, "partial", l, "строка не попала в метки"))
|
||
stat["строк дорожек найдено"] += len(dub)
|
||
stat["строк покрыто"] += len(dub) - len(unmatched)
|
||
|
||
# ---------------------------------------------------------------- отчёт
|
||
out = ["# Покрытие страниц data/raw датасетом", "",
|
||
"Сгенерировано `tools/coverage_audit.py`. Строка «дорожки» — независимый от парсеров",
|
||
"детектор (префикс «Перевод/Озвучка/Аудио…», тип озвучки или блок `|студия|`);",
|
||
"«покрыта» — для строки есть строка `labels.csv` этого релиза (`raw`/`audio_raw`/`role_raw`).",
|
||
""]
|
||
for src in SOURCES:
|
||
stat = per[src]
|
||
out += [f"## {src}", ""]
|
||
for k in ("страниц", "обычных", "legacy", "ok=False", "без меток", "без меток: нет аудио-строк",
|
||
"без меток: только русская дорожка/не озвучка", "без меток: ЕСТЬ дорожка озвучки",
|
||
"с метками", "строк дорожек найдено", "строк покрыто"):
|
||
if stat[k]:
|
||
out.append(f"- {k}: {stat[k]}")
|
||
if stat["с метками"]:
|
||
found = stat["строк дорожек найдено"]
|
||
out.append(f"- покрытие строк: {stat['строк покрыто'] / max(found, 1):.1%}")
|
||
out.append("")
|
||
for key in sorted(fmt_unlab[src], key=fmt_unlab[src].get, reverse=True)[:12]:
|
||
out.append(f"**без меток · {key}** — {fmt_unlab[src][key]}")
|
||
for rid, l in examples[(src, "unlabeled", key)][:3]:
|
||
out.append(f" - {rid}: {l[:150]}")
|
||
out.append("")
|
||
for key in sorted(fmt_part[src], key=fmt_part[src].get, reverse=True)[:12]:
|
||
out.append(f"**не покрыто · {key}** — {fmt_part[src][key]}")
|
||
for rid, l in examples[(src, "covered", key)][:3]:
|
||
out.append(f" - {rid}: {l[:150]}")
|
||
out.append("")
|
||
OUT_MD.parent.mkdir(exist_ok=True)
|
||
OUT_MD.write_text("\n".join(out), encoding="utf-8")
|
||
|
||
with OUT_PAGES.open("w", encoding="utf-8-sig", newline="") as f:
|
||
w = csv.DictWriter(f, fieldnames=["source", "release_id", "has_labels", "n_rows",
|
||
"n_lines", "n_dub_lines", "n_unmatched"])
|
||
w.writeheader()
|
||
for r in page_csv:
|
||
w.writerow({**{"n_unmatched": ""}, **r})
|
||
with OUT_LINES.open("w", encoding="utf-8-sig", newline="") as f:
|
||
w = csv.writer(f)
|
||
w.writerow(["source", "release_id", "kind", "line", "why"])
|
||
w.writerows(gaps[:20000])
|
||
print(f"страниц {len(pages)}, непокрытых строк {len(gaps)}")
|
||
for src in SOURCES:
|
||
s = per[src]
|
||
print(f"== {src}: страниц {s['страниц']}, без меток {s['без меток']} "
|
||
f"(есть дорожка {s['без меток: ЕСТЬ дорожка озвучки']}), "
|
||
f"с метками {s['с метками']}, строк {s['строк дорожек найдено']}, "
|
||
f"непокрыто {s['строк дорожек найдено'] - s['строк покрыто']}")
|
||
print(f"отчёт: {OUT_MD.relative_to(ROOT)}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|