DUB-AI: multi-task модель (тип озвучки + автор-спан + 18+), обучение/инференс/ONNX/сервис, тесты и документация

This commit is contained in:
DUB-AI committed 2026-10-27 16:43:36 +07:00
commit 8dbca3f4a3
2873 files changed
+44479

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Документация DUB-AI
| Документ | Содержание |
|---|---|
| [architecture.md](architecture.md) | Схема пайплайна, multi-task модель (тип + BIO-спан + 18+), почему извлечение вместо классификации |
| [dataset.md](dataset.md) | Схема labels.csv, семантика 14 классов, словарь алиасов, находки аудита и как они обработаны |
| [training-windows.md](training-windows.md) | Пошаговое обучение на Windows с GPU (RTX 5070 Ti / Blackwell), гиперпараметры, troubleshooting, переобучение на свежем датасете |
| [inference.md](inference.md) | CLI, Python API, JSON-контракт (таблица полей), обработка HTML, ограничения |
| [integration.md](integration.md) | Интеграция из Rust и TypeScript: HTTP-сервис (путь А) и ONNX без Python (путь Б), трейдоффы |
Быстрый старт — в [README.md](../README.md) корня проекта.
+66
View File
@@ -0,0 +1,66 @@
# Архитектура
## Пайплайн
```
labels.csv (снимок датасета) HTML/текст страницы (инференс)
│ │
▼ ▼
src/prepare_data.py src/html_extract.py
• снять префикс «Перевод/Аудио N:» • HTML -> текст (BeautifulSoup:
• BIO-разметка автора (спан в тексте) .post_body / #tabs / весь текст)
• фильтры: мусорный studio, • сегментация на дорожки
«оригинал/субтитры», противоречия (строки «Перевод/Озвучка/Аудио N:»,
• 18+ флаг (age_18 / note / «18+» в raw) нумерация, «+ ; /», запятые-типы)
• сплиты: test 20% + 5-fold CV │
(группы = релиз ∪ одинаковый текст) ▼
│ src/predict.py (DubPredictor)
▼ • токенизация (max_len=128,
src/train.py offsets)
• multi-task обучение (CUDA, bf16) • модель -> тип, спан, 18+
• 5-fold CV + финальная модель • нормализация автора
• метрики, reports/, models/best • JSON
```
## Модель (src/model.py)
Один BERT-подобный энкодер и три головы (общий `[CLS]`/токенные состояния):
| Голова | Тип | Выход | Задача |
|---|---|---|---|
| `type_head` | Linear(hidden, 14) | `[CLS]` → класс | тип озвучки |
| `bio_head` | Linear(hidden, 3) | по токенам → O/B/I | позиция имени автора в тексте |
| `age_head` | Linear(hidden, 2) | `[CLS]` → 0/1 | дорожка 18+ |
Loss = CE(тип) + 1.0·CE(BIO, ignore_index=-100) + 0.5·CE(18+).
BIO-разметка строится по символьным спанам через `offsets_mapping`
токенизатора: токены, пересекающие спан, получают `B` (первый) и `I`
(остальные); все реальные токены вне спана — `O`; паддинг — `-100`.
Спецтокены ([CLS]/[SEP]) размечены как `O` — иначе их необученные логиты
при инференсе дают ложный «B» (эта ошибка была найдена при обучении и
исправлена; см. `decode_span` — он дополнительно пропускает токены с
пустыми offsets).
Декодирование спана: первый `B`-токен, затем цепочка `I` → символьный
диапазон через offsets → текст спана → каноническое имя автора через
словарь алиасов (`src/studio_norm.py`, порт из генератора датасета).
## Почему автор — извлечение, а не классификация
В датасете 253+ уникальных авторов, 177 из них встречаются ровно 1 раз.
Классификацией такие классы не выучить. При этом имя автора почти всегда
дословно присутствует в тексте дорожки (95.8% + варианты написания),
поэтому BIO-извлечение решает задачу и автоматически работает для авторов,
которых в обучении не было вовсе.
## Сравнение конфигураций
Обе модели учатся одинаково (5-fold CV + финальный запуск, ранний stopping
по среднему type-F1/span/age-F1):
* `cointegrated/rubert-tiny2` — 30M, lr 1e-4, batch 64, до 20 эпох;
* `ai-forever/ruBert-base` — 110M, lr 3e-5, batch 32, до 10 эпох.
Лучшая по joint exact на held-out test копируется в `models/best`.
Итог — в `reports/summary.md`.
+72
View File
@@ -0,0 +1,72 @@
# Датасет
## Откуда берётся
`data/labels.csv` — снимок датасета, собранного пайплайном
`deepseek-harness/default-workspace/dataset.py`: системный Chrome по CDP
обходит релизы трекеров, из страницы извлекается вкладка описания (kinozal),
пост (rutracker, nnm), регулярки разбирают строки дорожек, а имена авторов
канонизируются словарём алиасов. **Метки автоматически сгенерированы
регулярками** — модель учится воспроизводить эту разметку и обобщать её на
новые формулировки.
Снимок: 3286 строк, 3 источника — rutracker 1660, nnm 1549, kinozal 77.
После подготовки: 3284 строки + 458 аугментированных морфологических
вариантов («многоголосое», «дублированное» и т.п.), всего 3742 обучающих
примера. Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
по источникам и по релизам; модель используется только `labels.csv`.
## Колонки labels.csv
| Колонка | Смысл | Использование моделью |
|---|---|---|
| `source`, `release_id` | трекер и id релиза | группировка сплитов |
| `raw` | строка дорожки целиком (с префиксом «Перевод:»/«Аудио N:») | **вход** (после снятия префикса) |
| `translation_type` | тип озвучки | **метка 1** |
| `studio` | автор («(не указана)» если нет) | **метка 2** (спан в тексте) |
| `age_18`, `note` | флаг/пометка 18+ | **метка 3** |
| `role_raw` | скобочная часть / конкретный тип из перечисления | вход для nnm tech-строк |
| остальные (`ru_title`, `codec`, …) | контекст релиза | не используются |
## Форматы входа по источникам
* **rutracker**: `raw` = тело после «Перевод N:» («Профессиональный
(двухголосый закадровый) tycoon»); несколько дорожек уже разнесены по строкам.
* **kinozal**: `raw` = строка целиком с префиксом («Перевод: Дублированный |
HDRezka Studio»); префикс снимается при подготовке.
* **nnm**: два вида строк — «Аудио N: Opus, 6 ch, 221 Kbps \| Русский AVO
(Дмитрий Пучков)» (автор в скобках) и «Перевод: Одноголосый, многоголосый
закадровый» (одна строка на несколько дорожек; при подготовке вместо общей
`raw` берётся `role_raw` конкретной дорожки, чтобы одинаковый текст не
получал разные метки).
## Семантика translation_type (14 классов)
Составные метки «кто делал × сколько голосов»:
* префикс: `проф.` (профессиональная), `люб.` (любительская), `авт.`
(авторская), без префикса — в тексте не указано;
* вид: `дубляж`/`дублированный`, `многоголосый`, `двухголосый`,
`одноголосый` (обычно закадровые), `неизвестно`.
Все «*неизвестно»-варианты объединены в `неизвестно`; типы «оригинал» и
«субтитры» (1–2 строки-проскока генератора) удалены.
## Аудит (reports/data_audit.md) и что с находками сделано
| Находка | Обработка |
|---|---|
| Из 3286 строк уникальных текстов ~2000, 377+ строк повторяются в разных релизах | Сплиты группируются не только по релизу, но и по тексту — одинаковые строки не попадают одновременно в train и test |
| 26 «мусорных» studio («лицензия R5», «+ вставки», «x4», суффиксы «ненормативная лексика») | Считаются «автора нет»; суффиксы дополнительно чистятся в словаре алиасов |
| Слитые дорожки (одна строка = несколько типов) | nnm решается через `role_raw`; оставшихся противоречий — 0 |
| Спан автора не найден в тексте | 11 строк: автор в метке есть, но в тексте написан иначе; BIO-разметка для них пустая |
| 18+ | 156 строк (в 1 строке флаг есть только в note, в тексте не виден — недообучаемая) |
## Словарь алиасов (src/studio_norm.py)
Порт `STUDIO_ALIASES` генератора датасета: «hdrezka studio»→HDrezka,
«read head sound»→Red Head Sound, «гаврилов»→Андрей Гаврилов, инициалы
«ю. сербин»→Юрий Сербин и т.д. Поиск спана автора в тексте пробует по
порядку: точное вхождение → алиасы → фамилия/инициалы → гибкий шаблон
«слова через произвольные кавычки/разделители» (ловит «Студия «Кравец»,
LostFilm» и «SDI Media \| TV 1000»).
+88
View File
@@ -0,0 +1,88 @@
# Инференс: CLI, Python API, JSON-контракт
## CLI (src/predict.py)
```bash
# одна дорожка текстом (префикс «Перевод:» не обязателен, но допустим)
python -m src.predict --text "Перевод: Профессиональный (многоголосый закадровый) | HDRezka Studio 18+"
# HTML-страница целиком -> все дорожки релиза
python -m src.predict --html data/samples/rt-topic.html
# текстовый дамп страницы
python -m src.predict --file data/pages/6907007.txt
# результат в файл + выбор модели
python -m src.predict --file page.txt --out result.json --model models/base
```
Кодировка входных файлов определяется автоматически (utf-8 → cp1251).
HTML распознаётся по содержимому, так что `--html` и `--file`
взаимозаменяемы.
## Python API
```python
from src.predict import DubPredictor
model = DubPredictor() # models/best; параметр device определяется сам
model.predict_text("Авторский (одноголосый закадровый) - Ю. Сербин")
# -> dict (см. контракт ниже)
model.predict_html(open("page.html", encoding="utf-8").read())
# -> list[dict] — по одному на каждую найденную дорожку
model.predict_page("Перевод: Дублированный | MovieDalen\n...") # текст без HTML
model.predict_file("page.txt")
```
## JSON-контракт
```json
{
"tracks": [
{
"segment": "Профессиональный (многоголосый закадровый) | HDRezka Studio 18+",
"translation_type": "проф. многоголосый",
"type_confidence": 0.99,
"studio_span": "HDRezka Studio",
"studio": "HDrezka",
"studio_confidence": 0.97,
"is_18plus": true,
"age_confidence": 0.99
}
]
}
```
| Поле | Тип | Описание |
|---|---|---|
| `segment` | string | текст дорожки, по которому сделано предсказание (префикс снят) |
| `translation_type` | string | один из 14 классов (см. docs/dataset.md) |
| `type_confidence` | float | softmax-вероятность класса |
| `studio_span` | string \| null | фрагмент текста, распознанный как имя автора |
| `studio` | string \| null | каноническое имя автора по словарю алиасов; null — автора в тексте нет |
| `studio_confidence` | float \| null | средняя вероятность токенов спана |
| `is_18plus` | bool | дорожка помечена 18+ |
| `age_confidence` | float | вероятность предсказанного класса 18+ |
## Как обрабатывается страница
1. HTML → текст (BeautifulSoup: `div.post_body` (rutracker) → `#tabs`
(kinozal) → весь документ);
2. строки вида «Перевод/Озвучка/Озвучивание/Озвучено/Звук/Аудио N: …»;
3. разбиение многострочных дорожек: нумерация «1. … 2. …», разделители
«+ ; /» перед словами типов, перечисление типов через запятую;
4. строки без признаков типа озвучки (например, чисто кодек-строки) не
попадают в результат;
5. каждая дорожка — отдельное предсказание модели.
## Ограничения
* `studio` — нормализация по словарю; неизвестные имена возвращаются как
есть (спан), без канонизации;
* если дорожка в тексте написана без какой-либо информации о типе, модель
вернёт наиболее вероятный класс (обычно «неизвестно») — фильтрация таких
строк уже сделана на этапе сегментации;
* классы с единичными примерами в обучении (см. docs/dataset.md)
предсказываются хуже — видно в confusion matrix.
+143
View File
@@ -0,0 +1,143 @@
# Интеграция из Rust / TypeScript
Два пути. **А (HTTP)** — самый простой: Python-процесс один раз загружает
модель, любой язык ходит по REST. **Б (ONNX)** — без Python в рантайме:
модель запускается локально в вашем процессе.
| | Путь А: HTTP-сервис | Путь Б: ONNX |
|---|---|---|
| Зависимости рантайма | запущенный Python-процесс | onnxruntime (+ токенизатор) в вашем языке |
| Сегментация HTML | да, на стороне сервиса | нет — в примерах обрабатывается одна дорожка; сегментацию нужно вызвать отдельно или портировать (`html_extract.py` ~60 строк) |
| Канонизация имён | да, на стороне сервиса | по `aliases.json` (простой словарь) |
| Скорость | сеть + инференс (~мс) | локальный инференс (~мс) |
| Кому подходит | сервис/бэкенд | CLI/десктоп/edge, где Python нельзя |
## Путь А: HTTP-сервис
Запуск (из корня проекта):
```bash
python -m src.service --model models/best --port 8000
# или открыто наружу: python -m src.service --host 0.0.0.0 --port 8000
```
API:
* `GET /health` → `{"status": "ok", "model": "...", "device": "...", "classes": [...]}`
* `POST /predict` — ровно одно из полей:
* `{"text": "Перевод: Дублированный | MovieDalen"}` → одна дорожка;
* `{"html": "<html>…вся страница…"}` → все дорожки релиза.
Ответ: `{"tracks": [ …контракт из docs/inference.md… ]}`.
curl:
```bash
curl -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" \
-d '{"text": "Перевод: Дублированный | MovieDalen"}'
```
> **Windows/Git Bash:** кириллица в аргументе `-d` может искажаться кодировкой
> консоли — используйте `--data-binary @req.json` с файлом в UTF-8.
### Rust (examples/rust/http_client)
```bash
cd examples/rust/http_client
cargo run -- --text "Перевод: Дублированный | MovieDalen"
cargo run -- --html page.html
DUB_URL=http://host:8000 cargo run -- --text "..."
```
Ядро клиента:
```rust
let resp: PredictResponse = client
.post("http://127.0.0.1:8000/predict")
.json(&serde_json::json!({ "html": page_html }))
.send().await?
.json().await?;
for t in &resp.tracks {
println!("{} — {}{}", t.translation_type,
t.studio.clone().unwrap_or_else(|| "—".into()),
if t.is_18plus { " 18+" } else { "" });
}
```
### TypeScript / Node.js (examples/typescript)
Без зависимостей (Node >= 18, глобальный fetch):
```bash
node examples/typescript/predict.mjs --text "Одноголосый закадровый — Дмитрий Есарев"
node examples/typescript/predict.mjs --html page.html
```
```ts
const res = await fetch("http://127.0.0.1:8000/predict", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Одноголосый закадровый — Дмитрий Есарев" }),
});
const { tracks } = await res.json();
```
Типизированная версия — `examples/typescript/predict.ts` (запуск: `npx tsx predict.ts --text "..."`).
## Путь Б: ONNX без Python
Экспорт (из корня проекта, после обучения):
```bash
python -m src.export_onnx # -> models/best-onnx/ + reports/onnx_parity.md
```
Состав `models/best-onnx/`: `model.onnx` (входы `input_ids`,
`attention_mask`, `token_type_ids` int64 [B, seq]; выходы `type_logits`
[B, 14], `bio_logits` [B, seq, 3], `age_logits` [B, 2]), `tokenizer.json`
(HF fast-токенизатор), `label_maps.json` (классы), `aliases.json`
(нормализация имён).
Эквивалентность PyTorch проверяется на всём held-out test (совпадение
типа/спана/18+ ≥ 99%) — отчёт в `reports/onnx_parity.md`.
Клиентская постобработка (одинакова во всех языках):
1. токенизировать дорожку (max_len 128, truncation);
2. прогнать ONNX, взять `argmax`;
3. спан: первый токен с меткой B, затем цепочка I; текст спана — по offsets
токенизатора (или `decode` токенов спана);
4. `is_18plus = argmax(age_logits) == 1`;
5. канонизация автора по `aliases.json`.
### Rust (examples/rust/onnx_local)
```bash
cd examples/rust/onnx_local && cargo build
ORT_DYLIB_PATH="$(find ../../.venv -name onnxruntime.dll | head -1)" \
./target/debug/dub-ai-onnx-local.exe ../../models/best-onnx \
"Перевод: Дублированный | MovieDalen"
```
Крейты: `ort 2.0.0-rc.15` + `tokenizers` (читает `tokenizer.json` как есть).
Библиотека onnxruntime грузится динамически — DLL можно взять из pip-пакета
`onnxruntime` (уже есть в `.venv` проекта) или из официального релиза;
проверено: тип, спан автора и 18+ совпадают с Python-инференсом.
### TypeScript (examples/typescript/onnx_local.mjs)
```bash
cd examples/typescript
npm init -y && npm i onnxruntime-node @huggingface/transformers
node onnx_local.mjs "../../models/best-onnx" "Перевод: Дублированный | MovieDalen"
```
`@huggingface/transformers` используется только как токенизатор;
инференс — `onnxruntime-node`.
## Замечание про сегментацию страниц в пути Б
Сервис (путь А) сам находит все дорожки в HTML. В пути Б примеры принимают
одну дорожку; для полной обработки страницы нужно сначала получить сегменты
(например, один вызов лёгкого HTTP-эндпоинта или порт `html_extract.py` —
это регулярки без зависимостей, кроме BeautifulSoup для HTML).
+77
View File
@@ -0,0 +1,77 @@
# Обучение на Windows с GPU (RTX 5070 Ti)
## Почему cu128
RTX 5070 Ti — архитектура Blackwell (compute capability `sm_120`). Колёса
PyTorch с PyPI для Windows по умолчанию CPU-only, а старые CUDA-сборки не
знают sm_120. Нужны колёса с индекса cu128 (torch >= 2.7; проверено на
torch 2.11.0+cu128).
## Установка (один раз)
```bat
cd C:\Users\shats\Dev\DUB-AI
python -m venv .venv
.venv\Scripts\python -m pip install --upgrade pip
:: torch с GPU-поддержкой (Blackwell)
.venv\Scripts\python -m pip install torch --index-url https://download.pytorch.org/whl/cu128
:: остальное
.venv\Scripts\python -m pip install -r requirements.txt
```
Проверка (ожидаемый вывод — `True` и имя карты):
```bat
.venv\Scripts\python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
:: True NVIDIA GeForce RTX 5070 Ti
```
## Обучение
```bat
.venv\Scripts\python -m src.prepare_data :: данные -> data/processed
.venv\Scripts\python -m src.train :: обе модели: 5-fold CV + финальные
.venv\Scripts\python -m src.train --models base :: только ruBert-base
.venv\Scripts\python -m src.train --epochs-override 5 --folds 3 :: быстрый прогон
```
Время на RTX 5070 Ti (3742 строки, max_len=128): rubert-tiny2 — ~2 мин
полный цикл; ruBert-base — ~7 мин. Автоматически: bf16 autocast на
CUDA, GroupKFold-сплиты, ранний stopping (patience 3), метрики и графики в
`reports/`, чекпоинты в `models/`, лучшая модель — `models/best`.
Гиперпараметры — в `MODEL_CONFIGS` (src/train.py): tiny2 — lr 1e-4, batch
64, до 20 эпох; base — lr 3e-5, batch 32, до 10 эпох; AdamW, weight_decay
0.01, linear warmup 10%, grad clip 1.0, seed 42.
## Переобучение на свежем датасете
Сборщик (`deepseek-harness/default-workspace/dataset.py`) дописывает
`labels.csv`. Когда появится новый снимок:
```bat
copy /Y <путь>\labels.csv data\labels.csv
.venv\Scripts\python -m src.prepare_data
.venv\Scripts\python -m src.train
```
## Troubleshooting
| Симптом | Причина | Решение |
|---|---|---|
| `torch.cuda.is_available()` = False | установлен CPU-torch | переустановить с `--index-url .../whl/cu128` |
| `CUDA error: no kernel image is available` | колёса без sm_120 (старый cu126 и т.п.) | то же — нужны именно cu128 |
| `CUDA driver version is insufficient` | старый драйвер NVIDIA | обновить драйвер (нужен 5xx+) |
| Память GPU занята другими приложениями (WDDM) | Windows держит ~4 ГБ VRAM под систему | batch уже мал (32-64 на коротких seq хватает); при нехватке уменьшить `batch` в MODEL_CONFIGS |
| Хочется обучить без GPU | — | `base` медленно, но `tiny2` обучаема на CPU за десятки минут: тот же запуск, torch можно ставить CPU-версией |
## Что сохраняется после обучения
* `models/<имя>/model.pt` — веса; `dub_config.json` — конфигурация;
`label_maps.json` — классы; файлы токенизатора; `train_config.json` —
гиперпараметры и финальные метрики.
* `models/best/` — копия лучшей модели (используется инференсом по умолчанию).
* `reports/summary.md` — таблица сравнения; `final_<имя>.json` — метрики;
`history_<имя>.png` — кривые; `confusion_<имя>.png` — матрица ошибок.