Files
grabber-ai/src/runs.py
T
DenozordecandClaude Opus 5.5 8511744d3a
docker / build (push) Successful in 1m21s
Несколько студий в одной строке, разбор противоречий, правки парсеров студий
Парсеры (PARSE_VERSION 9): «т/к» и единицы битрейта не режутся по слэшу, техпараметры
(kb/s, Кбит/сек, 16 бит, channels, CBR, Atmos) и пол голоса (муж./жен.) не становятся студией,
«от X» -> X; «N-й канал», «5 канал», «Лексикон» больше не выбрасываются как мусор; строка без типа
со списком из 3+ имён (актёры, титры сабберов) не даёт дорожек; в кодек-строках имя рядом со
скобкой пола становится кандидатом, а строка только с мусором даёт дорожку с типом без студии.

Несколько авторов строки («Многоголосый (HDRezka Studio, LostFilm)» — верны все): prepare_data
склеивает строки релиза в одну со списком spans, обучение размечает все спаны BIO, span_exact
сравнивает наборы, decode_spans возвращает все прогоны; сервис отдаёт studio_spans / studios /
studio_confidences, в ответе релиза — дорожка на каждого автора. Противоречий в датасете
20 % -> 1.2 %; проверочная tiny2 находит весь набор студий в 71 % строк с 2+ авторами
(прежняя модель — 1 %).

Разбор противоречий (collect.py, пункт 8): тексты, которые релизы разметили по-разному;
решения в data/text_fixes.csv применяются в prepare_data поверх labels.csv.

Метки и модель не пересобраны: нужны collect.py build --relabel, prepare_data, переобучение.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:52 +07:00

312 lines
15 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.
"""Реестр обученных моделей: честное сравнение на одной и той же тестовой выборке и выбор models/best.
Зачем отдельный модуль. Метрики, записанные при обучении, относятся к тестовой выборке того момента;
после пересборки датасета (другие тексты, другие группы) выборка меняется, и «0.966 тогда» нельзя
сравнивать с «0.97 сейчас». Поэтому любая модель (запуск из models/runs, models/best, архивные копии)
оценивается на ТЕКУЩИХ тестовых строках, результат кэшируется в <каталог модели>/eval.json под
отпечатком выборки. Модель получает вход в том формате, на котором обучена: без «[RUS AC3 …]»,
если в её label_maps нет audio_context.
Сравнение парное: по каждой строке видно, что новая модель починила и что сломала; при малой
разнице показывается, что она в пределах шума (знаковый критерий).
"""
from __future__ import annotations
import hashlib
import json
import math
import re
import shutil
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODELS = ROOT / "models"
RUNS = MODELS / "runs"
DATASET = ROOT / "data" / "processed" / "dataset.json"
EVAL_FILE = "eval.json"
UNSCORABLE = "__unscorable__"
RE_TAIL = re.compile(r"\s\[(?:[A-Z]{3}|\?)(?: [^\]\n]*)?\]$") # «[RUS AC3 6ch 448kbps 18+]» в конце входа
# ---------------------------------------------------------------- данные
def plain_text(row: dict) -> str:
"""Вход без аудио-контекста (для моделей, обученных на чистом тексте дорожки)."""
tail = row.get("tail")
if tail is None: # датасет собран до появления поля tail
return RE_TAIL.sub("", row["text"])
suffix = " " + tail
return row["text"][:-len(suffix)] if tail and row["text"].endswith(suffix) else row["text"]
def test_rows(dataset: Path | str | None = None) -> list[dict]:
rows = json.loads(Path(dataset or DATASET).read_text(encoding="utf-8"))
return [r for r in rows if r["split"] == "test"]
def fingerprint(rows: list[dict]) -> str:
"""Отпечаток тестовой выборки: те же строки с теми же метками (порядок не важен)."""
keys = sorted(json.dumps([r["text"], r["translation_type"], r.get("spans") or r.get("span"), bool(r.get("is_18plus")),
r.get("lang")], ensure_ascii=False) for r in rows)
return hashlib.sha1("\n".join(keys).encode("utf-8")).hexdigest()[:10]
# ---------------------------------------------------------------- статистика
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
"""95% интервал доли k из n."""
if not n:
return 0.0, 0.0
p = k / n
d = 1 + z * z / n
c = (p + z * z / (2 * n)) / d
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
return max(0.0, c - h), min(1.0, c + h)
def sign_test(fixed: int, broken: int) -> float:
"""Двусторонний точный знаковый критерий: случайна ли разница между «починил» и «сломал»."""
n = fixed + broken
if not n:
return 1.0
k = min(fixed, broken)
return min(1.0, 2 * sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n)
def paired(base: list[int], new: list[int]) -> dict:
"""Построчное сравнение двух моделей на одних и тех же строках."""
fixed = sum(1 for a, b in zip(base, new) if not a and b)
broken = sum(1 for a, b in zip(base, new) if a and not b)
return {"fixed": fixed, "broken": broken, "p": sign_test(fixed, broken)}
# ---------------------------------------------------------------- оценка чекпоинта
def store_eval(model_dir: Path | str, fp: str, entry: dict) -> None:
path = Path(model_dir) / EVAL_FILE
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
data[fp] = entry
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def load_eval(model_dir: Path | str, fp: str) -> dict | None:
path = Path(model_dir) / EVAL_FILE
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8")).get(fp)
except ValueError:
return None
def eval_checkpoint(model_dir: Path | str, rows: list[dict], device: str | None = None) -> dict:
"""Оценка чекпоинта на строках rows (тестовая выборка). Нужен GPU/CPU и torch."""
import torch
from torch.utils.data import DataLoader
from transformers import AutoTokenizer
from .model import load_model
from .train import Rows, evaluate, make_collate
d = Path(model_dir)
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
maps = json.loads((d / "label_maps.json").read_text(encoding="utf-8"))
type2id = {t: i for i, t in enumerate(maps["types"])}
type2id[UNSCORABLE] = len(type2id) # типы, которых модель не знает, считаются ошибкой
id2type = {i: t for t, i in type2id.items()}
model = load_model(d, device)
lang2id = ({l: i for i, l in enumerate(maps.get("langs") or [])} or None) if model.lang_head is not None else None
use_ctx = bool(maps.get("audio_context"))
view, n_unscorable = [], 0
for r in rows:
known = r["translation_type"] in type2id
n_unscorable += not known
view.append({**r, "text": r["text"] if use_ctx else plain_text(r),
"translation_type": r["translation_type"] if known else UNSCORABLE})
tokenizer = AutoTokenizer.from_pretrained(d)
loader = DataLoader(Rows(view), batch_size=64, collate_fn=make_collate(tokenizer, type2id, lang2id=lang2id))
per_row: list[int] = []
metrics = evaluate(model, loader, device, id2type, per_row=per_row)
del model
if device == "cuda":
torch.cuda.empty_cache()
return {"metrics": metrics, "per_row": per_row, "n_unscorable": n_unscorable}
def cached_eval(model_dir: Path | str, rows: list[dict], fp: str, refresh: bool = False,
device: str | None = None) -> dict:
hit = None if refresh else load_eval(model_dir, fp)
if hit:
return hit
entry = eval_checkpoint(model_dir, rows, device)
store_eval(model_dir, fp, entry)
return entry
# ---------------------------------------------------------------- каталог моделей
def _read_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def describe(model_dir: Path | str) -> dict:
d = Path(model_dir)
cfg = _read_json(d / "dub_config.json")
run = _read_json(d / "run.json") or _read_json(d / "train_config.json")
maps = _read_json(d / "label_maps.json")
tok_cfg = _read_json(d / "tokenizer_config.json")
weights = d / "model.pt"
return {
"dir": d, "name": d.name, "profile": run.get("name", ""), "trained_fp": run.get("fingerprint"),
"backbone": cfg.get("backbone", "?"),
"mb": round(weights.stat().st_size / 1e6) if weights.exists() else 0,
"created": run.get("created") or time.strftime("%Y-%m-%d %H:%M", time.localtime(d.stat().st_mtime)),
"seconds": (run.get("final") or {}).get("train_seconds"),
"audio_context": bool(maps.get("audio_context")), "lang_head": bool(cfg.get("n_langs")),
"token_type_ids": "token_type_ids" in (tok_cfg.get("model_input_names") or ["token_type_ids"]),
}
def candidates() -> list[Path]:
"""Модели, из которых можно выбирать: best, запуски, архивные копии, прежние models/<имя>."""
found: list[Path] = []
best = MODELS / "best"
if (best / "model.pt").exists():
found.append(best)
others = [p for p in list(RUNS.glob("*")) + list(MODELS.glob("best_backup_*")) + [MODELS / "tiny2", MODELS / "base"]
if (p / "model.pt").exists()]
others.sort(key=lambda p: p.stat().st_mtime, reverse=True)
seen = {(p / "train_config.json").read_bytes() for p in found if (p / "train_config.json").exists()}
for p in others:
cfg = p / "train_config.json"
key = cfg.read_bytes() if cfg.exists() else None
if key is not None and key in seen: # models/base — копия models/best и т.п.
continue
if key is not None:
seen.add(key)
found.append(p)
return found
def compare(dirs: list[Path | str], rows: list[dict] | None = None, baseline: Path | str | None = None,
refresh: bool = False, on_step=None) -> list[dict]:
"""Метрики каждой модели на ТЕКУЩЕЙ тестовой выборке + парное сравнение с baseline (models/best)."""
rows = rows if rows is not None else test_rows()
fp = fingerprint(rows)
base_dir = Path(baseline) if baseline else None
base_entry = None
if base_dir and (base_dir / "model.pt").exists():
base_entry = cached_eval(base_dir, rows, fp, refresh, None)
out = []
for i, d in enumerate(dirs):
d = Path(d)
if on_step:
on_step(i, len(dirs), d)
entry = base_entry if base_dir and d.resolve() == base_dir.resolve() else cached_eval(d, rows, fp, refresh)
m = entry["metrics"]
k = sum(entry["per_row"])
item = {**describe(d), "metrics": m, "n": len(entry["per_row"]), "n_unscorable": entry.get("n_unscorable", 0),
"ci": wilson(k, len(entry["per_row"])), "per_row": entry["per_row"], "fingerprint": fp, "vs": None}
# модель обучена не на этом разбиении: часть текущих тестовых строк она могла видеть при обучении
item["leak_risk"] = item["trained_fp"] != fp
if base_entry and d.resolve() != base_dir.resolve():
item["vs"] = paired(base_entry["per_row"], entry["per_row"])
out.append(item)
return out
def verdict(item: dict) -> str:
"""Короткий вывод про модель относительно models/best."""
vs = item.get("vs")
if not vs:
return "текущая" if item["name"] == "best" else "—"
net = vs["fixed"] - vs["broken"]
if vs["p"] > 0.05:
return f"в пределах шума (+{vs['fixed']}/-{vs['broken']}, p={vs['p']:.2f})"
return f"{'лучше' if net > 0 else 'хуже'} (+{vs['fixed']}/-{vs['broken']}, p={vs['p']:.3f})"
def warnings_for(model_dir: Path | str) -> list[str]:
"""Что учесть перед заменой models/best этой моделью."""
info = describe(model_dir)
best = describe(MODELS / "best") if (MODELS / "best" / "model.pt").exists() else None
out = []
if best and info["backbone"] != best["backbone"]:
repo = "models--" + info["backbone"].replace("/", "--")
cached = (Path.home() / ".cache" / "huggingface" / "hub" / repo).exists()
out.append(f"другая основа ({info['backbone']}): на машине сервиса её нужно скачать с Hugging Face "
f"(у вас {'уже в кэше' if cached else 'в кэше нет'}).")
if best and info["mb"] > best["mb"] * 1.5:
out.append(f"вес {info['mb']} МБ против {best['mb']} МБ: инференс на CPU заметно медленнее.")
if not info["token_type_ids"]:
out.append("у токенизатора нет token_type_ids: src/export_onnx.py и клиенты ONNX (Rust/Node) "
"рассчитаны на BERT и такую модель не примут без доработки.")
if (MODELS / "best-onnx").exists():
out.append("models/best-onnx останется от прежней модели: пересоздайте `python -m src.export_onnx`.")
return out
def promote(model_dir: Path | str) -> str | None:
"""Копирует модель в models/best; прежняя best уходит в models/best_backup_<время>."""
src, best = Path(model_dir), MODELS / "best"
if best.exists() and src.resolve() == best.resolve():
return None
tmp = MODELS / ".best_new"
shutil.rmtree(tmp, ignore_errors=True)
shutil.copytree(src, tmp)
archive = None
if best.exists():
archive = f"best_backup_{time.strftime('%Y%m%d_%H%M%S')}"
best.rename(MODELS / archive)
try:
tmp.rename(best)
except OSError:
if archive:
(MODELS / archive).rename(best)
raise
return archive
def deletable(model_dir: Path | str) -> bool:
"""Удалять можно запуски и архивные копии, но не models/best и не models/<профиль>."""
p = Path(model_dir)
return p.name != "best" and (p.parent == RUNS or p.name.startswith("best_backup_"))
def delete(model_dir: Path | str) -> None:
if not deletable(model_dir):
raise ValueError(f"{model_dir}: удалять можно только запуски и архивные копии")
shutil.rmtree(model_dir)
def _jsonable(items: list[dict]) -> list[dict]:
out = []
for it in items:
d = {k: v for k, v in it.items() if k != "per_row"}
d["dir"] = str(it["dir"])
d["ci"] = list(it["ci"])
out.append(d)
return out
def main() -> None:
import argparse
ap = argparse.ArgumentParser(description="Оценка моделей на текущей тестовой выборке (кэш в <модель>/eval.json)")
ap.add_argument("--out", required=True, help="куда записать JSON с результатами")
ap.add_argument("--dirs", nargs="*", help="каталоги моделей (по умолчанию все кандидаты)")
ap.add_argument("--data", default=None)
ap.add_argument("--refresh", action="store_true", help="пересчитать, игнорируя кэш")
args = ap.parse_args()
dirs = [Path(d) for d in args.dirs] if args.dirs else candidates()
rows = test_rows(args.data)
print(f"тестовая выборка: {len(rows)} строк, отпечаток {fingerprint(rows)}", flush=True)
items = compare(dirs, rows, baseline=MODELS / "best", refresh=args.refresh,
on_step=lambda i, n, d: print(f"оценка {i + 1}/{n}: {d.name}", flush=True))
Path(args.out).write_text(json.dumps({"fingerprint": fingerprint(rows), "n": len(rows),
"items": _jsonable(items)}, ensure_ascii=False), encoding="utf-8")
if __name__ == "__main__":
main()