Docker-сервис, выбор основы и сравнение моделей, сборщик датасета
docker / build (push) Failing after 39s
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d155b8584b
commit
efc9a4f56a
411 files changed
+87495
-27363
No files matched your search
@@ -0,0 +1,161 @@
|
||||
"""Переобучение и проверка моделей 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
|
||||
Reference in new issue
Block a user