docker / build (push) Failing after 39s
- Docker: Dockerfile (CPU), compose с FlareSolverr, настройка через DUB_* переменные, токен доступа к /predict, tools/pack_models.py, workflow Gitea Actions, docs/docker.md - Модель грузится офлайн (config.json рядом с весами); общий код вынесен в src/spans.py, сервису не нужны sklearn/matplotlib/pandas - Обучение: профили tiny2/base/large/modern, запуски в models/runs, реестр и честное сравнение на одной тестовой выборке (src/runs.py), пункт меню «Модели» - Датасет: язык и аудио-параметры дорожек, 18+ по строкам «Аудио», ручные метки по ссылкам, переиндексация сохранённого HTML, параллельная сборка, сбалансированная выборка - Тексты страниц (data/pages) больше не хранятся в git Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
162 lines
7.3 KiB
Python
162 lines
7.3 KiB
Python
"""Переобучение и проверка моделей DUB-AI (используется из tools/collect.py).
|
|
|
|
Шаги вынесены в функции, чтобы меню вызывало их по очереди:
|
|
prepare() data/labels.csv -> data/processed
|
|
train_segmenter(out_dir) обучение сегментатора страниц
|
|
train_main() основная модель (тип, автор, 18+): долго
|
|
run_tests() tests/run_tests.py
|
|
compare(dirs) правила vs сегментаторы по трекерам
|
|
promote(new_dir) новые веса -> models/segmenter (старые в архив)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MODELS = ROOT / "models"
|
|
MIN_N = 30 # трекеры с меньшим числом страниц в вывод попадают, но решение не определяют
|
|
|
|
RE_CELL = re.compile(r"(\w+) n=(\d+) P=([\d.]+) R=([\d.]+)")
|
|
|
|
|
|
def run(args: list[str]) -> int:
|
|
"""Запуск шага тем же Python; вывод идёт прямо в консоль."""
|
|
print("\n$ python " + " ".join(args), flush=True)
|
|
return subprocess.call([sys.executable, *args], cwd=ROOT)
|
|
|
|
|
|
def prepare() -> bool:
|
|
return run(["-m", "src.prepare_data"]) == 0
|
|
|
|
|
|
def train_segmenter(out_dir: str = "models/segmenter_v2", epochs: int = 4) -> bool:
|
|
return run(["-m", "src.segmenter", "--train", "--out", out_dir,
|
|
"--epochs", str(epochs)]) == 0
|
|
|
|
|
|
def backup_main() -> str | None:
|
|
"""Копия models/best перед обучением: src.train перезаписывает каталог."""
|
|
import shutil
|
|
import time
|
|
src = ROOT / "models" / "best"
|
|
if not src.exists():
|
|
return None
|
|
name = f"best_backup_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
shutil.copytree(src, ROOT / "models" / name)
|
|
return name
|
|
|
|
|
|
def train_main() -> bool:
|
|
"""Основная модель: 5-fold, ~15-20 минут на GPU. Старый models/best сохраняется в models/best_backup_*."""
|
|
saved = backup_main()
|
|
if saved:
|
|
print(f"копия прежней модели: models/{saved}", flush=True)
|
|
return run(["-m", "src.train"]) == 0
|
|
|
|
|
|
PROFILES = { # те же имена, что в src/train.py:MODEL_CONFIGS (без импорта torch в меню)
|
|
"tiny2": "rubert-tiny2, 30M — самая быстрая, годится для ONNX/CPU",
|
|
"base": "ruBert-base, 178M — текущий рабочий выбор",
|
|
"large": "ruBert-large, 427M — та же семья, втрое больше: обучение ~3× дольше, инференс на CPU медленнее",
|
|
"modern": "RuModernBERT-base, 149M (2024) — новая архитектура; ONNX-экспорт проекта её не поддерживает",
|
|
}
|
|
|
|
|
|
def train_models(profiles: list[str], folds: int = 0, tag: str = "") -> bool:
|
|
"""Обучение профилей отдельными запусками в models/runs/: models/best и reports/ не меняются."""
|
|
args = ["-m", "src.train", "--runs", "--models", *profiles, "--folds", str(folds)]
|
|
if tag:
|
|
args += ["--tag", tag]
|
|
return run(args) == 0
|
|
|
|
|
|
def evaluate_models(out_json: Path, refresh: bool = False) -> dict | None:
|
|
"""Оценка всех моделей на текущей тестовой выборке (подпроцесс: GPU освобождается после выхода)."""
|
|
out_json.unlink(missing_ok=True)
|
|
args = ["-m", "src.runs", "--out", str(out_json)] + (["--refresh"] if refresh else [])
|
|
if run(args) != 0 or not out_json.exists():
|
|
return None
|
|
import json
|
|
return json.loads(out_json.read_text(encoding="utf-8"))
|
|
|
|
|
|
def run_tests() -> bool:
|
|
return run(["tests/run_tests.py"]) == 0
|
|
|
|
|
|
def _e2e(seg_dir: str) -> dict:
|
|
"""{'rules': {трекер: (n, P, R)}, 'model': {...}} на страницах, которых модель не видела."""
|
|
proc = subprocess.run([sys.executable, "tests/e2e_segmenter.py", seg_dir], cwd=ROOT,
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
if proc.returncode != 0:
|
|
tail = "\n".join(proc.stderr.strip().splitlines()[-6:])
|
|
raise RuntimeError(f"e2e_segmenter упал на {seg_dir}:\n{tail}")
|
|
test_block = proc.stdout.split("train-страницы")[0]
|
|
out: dict[str, dict] = {}
|
|
for line in test_block.splitlines():
|
|
m = re.match(r"\s+(rules|model)\s*:(.*)", line)
|
|
if m:
|
|
out[m.group(1)] = {t: (int(n), float(p), float(r))
|
|
for t, n, p, r in RE_CELL.findall(m.group(2))}
|
|
return out
|
|
|
|
|
|
def compare(seg_dirs: list[str]) -> dict:
|
|
"""Печатает таблицу: правила и каждый сегментатор по трекерам.
|
|
|
|
Возвращает {'rules': {...}, <dir>: {...}} для решения о подмене весов.
|
|
"""
|
|
results: dict[str, dict] = {}
|
|
for d in seg_dirs:
|
|
print(f"\nзамер: {d} (несколько минут)…", flush=True)
|
|
res = _e2e(d)
|
|
results[d] = res.get("model", {})
|
|
results.setdefault("rules", res.get("rules", {}))
|
|
trackers = sorted(results["rules"])
|
|
cols = ["rules"] + list(seg_dirs)
|
|
print("\nPrecision / Recall на страницах, которых сегментатор не видел при обучении")
|
|
print(f"{'трекер':10s} {'n':>4s} " + " ".join(f"{Path(c).name:>16s}" for c in cols))
|
|
for t in trackers:
|
|
n = results["rules"][t][0]
|
|
cells = []
|
|
for c in cols:
|
|
v = results[c].get(t)
|
|
cells.append(f"{v[1]:.3f} / {v[2]:.3f}" if v else "-")
|
|
warn = " (мало страниц)" if n < MIN_N else ""
|
|
print(f"{t:10s} {n:4d} " + " ".join(f"{x:>16s}" for x in cells) + warn)
|
|
return results
|
|
|
|
|
|
def verdict(results: dict, old: str, new: str) -> tuple[bool, str]:
|
|
"""Новые веса лучше старых? Только по трекерам с n >= MIN_N.
|
|
|
|
Лучше: суммарная precision не ниже, recall не просел больше чем на 0.02
|
|
ни на одном трекере.
|
|
"""
|
|
big = [t for t, v in results["rules"].items() if v[0] >= MIN_N]
|
|
if not big:
|
|
return False, "нет трекеров с достаточным числом страниц"
|
|
dp = sum(results[new][t][1] - results[old][t][1] for t in big if t in results[new] and t in results[old])
|
|
worst_r = min(results[new][t][2] - results[old][t][2] for t in big
|
|
if t in results[new] and t in results[old])
|
|
ok = dp >= 0 and worst_r >= -0.02
|
|
return ok, (f"Δprecision (сумма по {', '.join(big)}) = {dp:+.3f}, "
|
|
f"худшее Δrecall = {worst_r:+.3f}")
|
|
|
|
|
|
def promote(new_dir: str, current: str = "models/segmenter") -> str:
|
|
"""Старые веса -> models/segmenter_old_<время>, новые -> models/segmenter."""
|
|
cur, new = ROOT / current, ROOT / new_dir
|
|
archive = ""
|
|
if cur.exists():
|
|
archive = f"{cur.name}_old_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
cur.rename(cur.with_name(archive))
|
|
shutil.move(str(new), str(cur))
|
|
return archive
|