Files
grabber-ai/tools/coverage_audit.py
T
Denozordec 0f359837c5
docker / build (push) Successful in 1m20s
Датасет: полное покрытие data/raw (71 339 строк из 30 401 релиза), правки парсеров и аудит
Переразбор всех страниц 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.
2026-11-09 13:29:53 +07:00

274 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Аудит покрытия: все страницы 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()